Files
boocode/apps/server/src/services/project_bootstrap.ts
indifferentketchup 4a9f207fe8 v1.5.1: bootstrap fixes (git + ssh in container, Tailscale host rewrite, /opt/projects label)
- 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>
2026-05-16 05:11:39 +00:00

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