- Dockerfile: install git + openssh-client in runtime image; pre-populate /root/.ssh/known_hosts with the Tailscale ssh-keyscan for 100.114.205.53:2222 (Gitea SSH). Without these, the bootstrap push step from inside the container fails with "command not found" or host-key prompts. - docker-compose.yml: mount ./secrets/boocode_gitea as /root/.ssh/id_ed25519:ro so the container can authenticate to Gitea over SSH for the initial push. - .gitignore: add secrets/ so the keypair never lands in the repo. - project_bootstrap.ts: rewrite the Gitea-returned ssh_url's hostname from git.indifferentketchup.com to 100.114.205.53 before adding it as origin, so the push hits the Tailscale interface that the known_hosts entry covers. - CreateProjectModal.tsx: preview label now reads "Folder: /opt/projects/<name>" to match the new BOOTSTRAP_ROOT (was /opt/). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { mkdir, writeFile, realpath } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { resolve, sep } from 'node:path';
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
import type { Config } from '../config.js';
|
|
import { createGiteaRepo, GiteaRepoExistsError } from './gitea.js';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const GITIGNORE_TEMPLATE = `# OS / editor
|
|
.DS_Store
|
|
*.swp
|
|
*~
|
|
|
|
# Node
|
|
node_modules/
|
|
dist/
|
|
build/
|
|
.env
|
|
.env.local
|
|
|
|
# Python
|
|
__pycache__/
|
|
*.pyc
|
|
.venv/
|
|
venv/
|
|
|
|
# AI agents
|
|
.claude/
|
|
.opencode/
|
|
|
|
# Backups
|
|
*.bak*
|
|
`;
|
|
|
|
const GIT_USER_NAME = 'indifferentketchup';
|
|
const GIT_USER_EMAIL = 'samkintop@gmail.com';
|
|
|
|
export interface BootstrapResult {
|
|
folder_real_path: string;
|
|
folder_name: string;
|
|
gitea_remote_url: string | null;
|
|
folder_created: boolean;
|
|
git_initialized: boolean;
|
|
first_commit: boolean;
|
|
gitea_remote_created: boolean;
|
|
gitea_pushed: boolean;
|
|
warnings: string[];
|
|
}
|
|
|
|
const SAFE_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
|
|
export function sanitizeFolderName(raw: string): string {
|
|
return raw
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/\s+/g, '-')
|
|
.replace(/[^a-z0-9-]/g, '')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 64);
|
|
}
|
|
|
|
export class BootstrapNameError extends Error {}
|
|
export class BootstrapCollisionError extends Error {}
|
|
export class BootstrapPathError extends Error {}
|
|
|
|
export async function bootstrapProject(
|
|
config: Config,
|
|
log: FastifyBaseLogger,
|
|
options: {
|
|
name: string;
|
|
commitMessage: string;
|
|
visibility: 'private' | 'public';
|
|
createGiteaRemote: boolean;
|
|
}
|
|
): Promise<BootstrapResult> {
|
|
const folder = sanitizeFolderName(options.name);
|
|
if (folder.length === 0 || !SAFE_NAME.test(folder)) {
|
|
throw new BootstrapNameError(`invalid name after sanitization: "${folder}"`);
|
|
}
|
|
|
|
// Bootstrap target resolution. Uses BOOTSTRAP_ROOT (writable), not
|
|
// PROJECT_ROOT_WHITELIST (which may be a wider read-only scope for
|
|
// add-existing flow).
|
|
const bootstrapReal = await realpath(config.BOOTSTRAP_ROOT);
|
|
const fullPath = resolve(bootstrapReal, folder);
|
|
if (!fullPath.startsWith(bootstrapReal + sep)) {
|
|
throw new BootstrapPathError('path escapes bootstrap root');
|
|
}
|
|
if (existsSync(fullPath)) {
|
|
throw new BootstrapCollisionError(`path already exists: ${fullPath}`);
|
|
}
|
|
|
|
const warnings: string[] = [];
|
|
let folder_created = false;
|
|
let git_initialized = false;
|
|
let first_commit = false;
|
|
let gitea_remote_created = false;
|
|
let gitea_pushed = false;
|
|
let gitea_remote_url: string | null = null;
|
|
|
|
// Step 1: mkdir
|
|
await mkdir(fullPath, { recursive: false });
|
|
folder_created = true;
|
|
log.info({ fullPath }, 'project_bootstrap: folder created');
|
|
|
|
// Step 2: write .gitignore
|
|
await writeFile(resolve(fullPath, '.gitignore'), GITIGNORE_TEMPLATE, 'utf8');
|
|
|
|
// Step 3: git init -b main
|
|
await execFileAsync('git', ['init', '-b', 'main'], { cwd: fullPath });
|
|
git_initialized = true;
|
|
|
|
// Step 4: git add + commit (per-command -c, no global config touch)
|
|
await execFileAsync('git', ['add', '.gitignore'], { cwd: fullPath });
|
|
await execFileAsync(
|
|
'git',
|
|
[
|
|
'-c', `user.name=${GIT_USER_NAME}`,
|
|
'-c', `user.email=${GIT_USER_EMAIL}`,
|
|
'commit',
|
|
'-m', options.commitMessage,
|
|
],
|
|
{ cwd: fullPath }
|
|
);
|
|
first_commit = true;
|
|
log.info({ folder }, 'project_bootstrap: initial commit');
|
|
|
|
// Step 5: optional Gitea remote
|
|
if (options.createGiteaRemote) {
|
|
if (!config.GITEA_TOKEN) {
|
|
warnings.push('Gitea remote skipped — token not configured');
|
|
} else {
|
|
try {
|
|
const repo = await createGiteaRepo(
|
|
{ baseUrl: config.GITEA_BASE_URL, user: config.GITEA_USER, token: config.GITEA_TOKEN },
|
|
folder,
|
|
{ private: options.visibility === 'private' }
|
|
);
|
|
gitea_remote_created = true;
|
|
gitea_remote_url = repo.html_url;
|
|
log.info({ folder, html_url: repo.html_url }, 'project_bootstrap: gitea repo created');
|
|
|
|
// Step 6: git remote add + push
|
|
try {
|
|
const sshUrl = repo.ssh_url.replace('git.indifferentketchup.com', '100.114.205.53');
|
|
await execFileAsync('git', ['remote', 'add', 'origin', sshUrl], { cwd: fullPath });
|
|
await execFileAsync('git', ['push', '-u', 'origin', 'main'], { cwd: fullPath });
|
|
gitea_pushed = true;
|
|
log.info({ folder }, 'project_bootstrap: pushed to gitea');
|
|
} catch (pushErr) {
|
|
const msg = pushErr instanceof Error ? pushErr.message : String(pushErr);
|
|
warnings.push(`Push to Gitea failed: ${msg.slice(0, 200)}`);
|
|
log.warn({ err: pushErr, folder }, 'project_bootstrap: push failed');
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof GiteaRepoExistsError) {
|
|
warnings.push('Gitea repo already exists with this name; local repo created without remote');
|
|
} else {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
warnings.push(`Gitea remote creation failed: ${msg.slice(0, 200)}`);
|
|
}
|
|
log.warn({ err, folder }, 'project_bootstrap: gitea remote step failed');
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
folder_real_path: fullPath,
|
|
folder_name: folder,
|
|
gitea_remote_url,
|
|
folder_created,
|
|
git_initialized,
|
|
first_commit,
|
|
gitea_remote_created,
|
|
gitea_pushed,
|
|
warnings,
|
|
};
|
|
}
|