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 { 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, }; }