import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { createServer } from "node:net"; import * as p from "@clack/prompts"; import pc from "picocolors"; import { applyPendingMigrations, ensurePostgresDatabase, formatDatabaseBackupResult, runDatabaseBackup, runDatabaseRestore, } from "@paperclipai/db"; import type { Command } from "commander"; import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js"; import { expandHomePrefix } from "../config/home.js"; import type { PaperclipConfig } from "../config/schema.js"; import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; import { printPaperclipCliBanner } from "../utils/banner.js"; import { resolveRuntimeLikePath } from "../utils/path-resolver.js"; import { buildWorktreeConfig, buildWorktreeEnvEntries, DEFAULT_WORKTREE_HOME, formatShellExports, isWorktreeSeedMode, resolveSuggestedWorktreeName, resolveWorktreeSeedPlan, resolveWorktreeLocalPaths, sanitizeWorktreeInstanceId, type WorktreeSeedMode, type WorktreeLocalPaths, } from "./worktree-lib.js"; type WorktreeInitOptions = { name?: string; instance?: string; home?: string; fromConfig?: string; fromDataDir?: string; fromInstance?: string; serverPort?: number; dbPort?: number; seed?: boolean; seedMode?: string; force?: boolean; }; type WorktreeEnvOptions = { config?: string; json?: boolean; }; type EmbeddedPostgresInstance = { initialise(): Promise; start(): Promise; stop(): Promise; }; type EmbeddedPostgresCtor = new (opts: { databaseDir: string; user: string; password: string; port: number; persistent: boolean; onLog?: (message: unknown) => void; onError?: (message: unknown) => void; }) => EmbeddedPostgresInstance; type EmbeddedPostgresHandle = { port: number; startedByThisProcess: boolean; stop: () => Promise; }; function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function readPidFilePort(postmasterPidFile: string): number | null { if (!existsSync(postmasterPidFile)) return null; try { const lines = readFileSync(postmasterPidFile, "utf8").split("\n"); const port = Number(lines[3]?.trim()); return Number.isInteger(port) && port > 0 ? port : null; } catch { return null; } } function readRunningPostmasterPid(postmasterPidFile: string): number | null { if (!existsSync(postmasterPidFile)) return null; try { const pid = Number(readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim()); if (!Number.isInteger(pid) || pid <= 0) return null; process.kill(pid, 0); return pid; } catch { return null; } } async function isPortAvailable(port: number): Promise { return await new Promise((resolve) => { const server = createServer(); server.unref(); server.once("error", () => resolve(false)); server.listen(port, "127.0.0.1", () => { server.close(() => resolve(true)); }); }); } async function findAvailablePort(preferredPort: number, reserved = new Set()): Promise { let port = Math.max(1, Math.trunc(preferredPort)); while (reserved.has(port) || !(await isPortAvailable(port))) { port += 1; } return port; } function detectGitBranchName(cwd: string): string | null { try { const value = execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); return nonEmpty(value); } catch { return null; } } function resolveSourceConfigPath(opts: WorktreeInitOptions): string { if (opts.fromConfig) return path.resolve(opts.fromConfig); const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default"); return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json"); } function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record, portOverride?: number): string { if (config.database.mode === "postgres") { const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString); if (!connectionString) { throw new Error( "Source instance uses postgres mode but has no connection string in config or adjacent .env.", ); } return connectionString; } const port = portOverride ?? config.database.embeddedPostgresPort; return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; } export function copySeededSecretsKey(input: { sourceConfigPath: string; sourceConfig: PaperclipConfig; sourceEnvEntries: Record; targetKeyFilePath: string; }): void { if (input.sourceConfig.secrets.provider !== "local_encrypted") { return; } mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); const sourceInlineMasterKey = nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ?? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY); if (sourceInlineMasterKey) { writeFileSync(input.targetKeyFilePath, sourceInlineMasterKey, { encoding: "utf8", mode: 0o600, }); try { chmodSync(input.targetKeyFilePath, 0o600); } catch { // best effort } return; } const sourceKeyFileOverride = nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) ?? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE); const sourceConfiguredKeyPath = sourceKeyFileOverride ?? input.sourceConfig.secrets.localEncrypted.keyFilePath; const sourceKeyFilePath = resolveRuntimeLikePath(sourceConfiguredKeyPath, input.sourceConfigPath); if (!existsSync(sourceKeyFilePath)) { throw new Error( `Cannot seed worktree database because source local_encrypted secrets key was not found at ${sourceKeyFilePath}.`, ); } copyFileSync(sourceKeyFilePath, input.targetKeyFilePath); try { chmodSync(input.targetKeyFilePath, 0o600); } catch { // best effort } } async function ensureEmbeddedPostgres(dataDir: string, preferredPort: number): Promise { const moduleName = "embedded-postgres"; let EmbeddedPostgres: EmbeddedPostgresCtor; try { const mod = await import(moduleName); EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; } catch { throw new Error( "Embedded PostgreSQL support requires dependency `embedded-postgres`. Reinstall dependencies and try again.", ); } const postmasterPidFile = path.resolve(dataDir, "postmaster.pid"); const runningPid = readRunningPostmasterPid(postmasterPidFile); if (runningPid) { return { port: readPidFilePort(postmasterPidFile) ?? preferredPort, startedByThisProcess: false, stop: async () => {}, }; } const port = await findAvailablePort(preferredPort); const instance = new EmbeddedPostgres({ databaseDir: dataDir, user: "paperclip", password: "paperclip", port, persistent: true, onLog: () => {}, onError: () => {}, }); if (!existsSync(path.resolve(dataDir, "PG_VERSION"))) { await instance.initialise(); } if (existsSync(postmasterPidFile)) { rmSync(postmasterPidFile, { force: true }); } await instance.start(); return { port, startedByThisProcess: true, stop: async () => { await instance.stop(); }, }; } async function seedWorktreeDatabase(input: { sourceConfigPath: string; sourceConfig: PaperclipConfig; targetConfig: PaperclipConfig; targetPaths: WorktreeLocalPaths; instanceId: string; seedMode: WorktreeSeedMode; }): Promise { const seedPlan = resolveWorktreeSeedPlan(input.seedMode); const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); copySeededSecretsKey({ sourceConfigPath: input.sourceConfigPath, sourceConfig: input.sourceConfig, sourceEnvEntries, targetKeyFilePath: input.targetPaths.secretsKeyFilePath, }); let sourceHandle: EmbeddedPostgresHandle | null = null; let targetHandle: EmbeddedPostgresHandle | null = null; try { if (input.sourceConfig.database.mode === "embedded-postgres") { sourceHandle = await ensureEmbeddedPostgres( input.sourceConfig.database.embeddedPostgresDataDir, input.sourceConfig.database.embeddedPostgresPort, ); } const sourceConnectionString = resolveSourceConnectionString( input.sourceConfig, sourceEnvEntries, sourceHandle?.port, ); const backup = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir: path.resolve(input.targetPaths.backupDir, "seed"), retentionDays: 7, filenamePrefix: `${input.instanceId}-seed`, includeMigrationJournal: true, excludeTables: seedPlan.excludedTables, nullifyColumns: seedPlan.nullifyColumns, }); targetHandle = await ensureEmbeddedPostgres( input.targetConfig.database.embeddedPostgresDataDir, input.targetConfig.database.embeddedPostgresPort, ); const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/postgres`; await ensurePostgresDatabase(adminConnectionString, "paperclip"); const targetConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${targetHandle.port}/paperclip`; await runDatabaseRestore({ connectionString: targetConnectionString, backupFile: backup.backupFile, }); await applyPendingMigrations(targetConnectionString); return formatDatabaseBackupResult(backup); } finally { if (targetHandle?.startedByThisProcess) { await targetHandle.stop(); } if (sourceHandle?.startedByThisProcess) { await sourceHandle.stop(); } } } export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise { printPaperclipCliBanner(); p.intro(pc.bgCyan(pc.black(" paperclipai worktree init "))); const cwd = process.cwd(); const name = resolveSuggestedWorktreeName( cwd, opts.name ?? detectGitBranchName(cwd) ?? undefined, ); const seedMode = opts.seedMode ?? "minimal"; if (!isWorktreeSeedMode(seedMode)) { throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`); } const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); const paths = resolveWorktreeLocalPaths({ cwd, homeDir: opts.home ?? DEFAULT_WORKTREE_HOME, instanceId, }); const sourceConfigPath = resolveSourceConfigPath(opts); const sourceConfig = existsSync(sourceConfigPath) ? readConfig(sourceConfigPath) : null; if ((existsSync(paths.configPath) || existsSync(paths.instanceRoot)) && !opts.force) { throw new Error( `Worktree config already exists at ${paths.configPath} or instance data exists at ${paths.instanceRoot}. Re-run with --force to replace it.`, ); } if (opts.force) { rmSync(paths.repoConfigDir, { recursive: true, force: true }); rmSync(paths.instanceRoot, { recursive: true, force: true }); } const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); const serverPort = await findAvailablePort(preferredServerPort); const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); const targetConfig = buildWorktreeConfig({ sourceConfig, paths, serverPort, databasePort, }); writeConfig(targetConfig, paths.configPath); mergePaperclipEnvEntries(buildWorktreeEnvEntries(paths), paths.envPath); ensureAgentJwtSecret(paths.configPath); loadPaperclipEnvFile(paths.configPath); let seedSummary: string | null = null; if (opts.seed !== false) { if (!sourceConfig) { throw new Error( `Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`, ); } const spinner = p.spinner(); spinner.start(`Seeding isolated worktree database from source instance (${seedMode})...`); try { seedSummary = await seedWorktreeDatabase({ sourceConfigPath, sourceConfig, targetConfig, targetPaths: paths, instanceId, seedMode, }); spinner.stop(`Seeded isolated worktree database (${seedMode}).`); } catch (error) { spinner.stop(pc.red("Failed to seed worktree database.")); throw error; } } p.log.message(pc.dim(`Repo config: ${paths.configPath}`)); p.log.message(pc.dim(`Repo env: ${paths.envPath}`)); p.log.message(pc.dim(`Isolated home: ${paths.homeDir}`)); p.log.message(pc.dim(`Instance: ${paths.instanceId}`)); p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); if (seedSummary) { p.log.message(pc.dim(`Seed mode: ${seedMode}`)); p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); } p.outro( pc.green( `Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`, ), ); } export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise { const configPath = resolveConfigPath(opts.config); const envPath = resolvePaperclipEnvFile(configPath); const envEntries = readPaperclipEnvEntries(envPath); const out = { PAPERCLIP_CONFIG: configPath, ...(envEntries.PAPERCLIP_HOME ? { PAPERCLIP_HOME: envEntries.PAPERCLIP_HOME } : {}), ...(envEntries.PAPERCLIP_INSTANCE_ID ? { PAPERCLIP_INSTANCE_ID: envEntries.PAPERCLIP_INSTANCE_ID } : {}), ...(envEntries.PAPERCLIP_CONTEXT ? { PAPERCLIP_CONTEXT: envEntries.PAPERCLIP_CONTEXT } : {}), ...envEntries, }; if (opts.json) { console.log(JSON.stringify(out, null, 2)); return; } console.log(formatShellExports(out)); } export function registerWorktreeCommands(program: Command): void { const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers"); worktree .command("init") .description("Create repo-local config/env and an isolated instance for this worktree") .option("--name ", "Display name used to derive the instance id") .option("--instance ", "Explicit isolated instance id") .option("--home ", `Home root for worktree instances (default: ${DEFAULT_WORKTREE_HOME})`) .option("--from-config ", "Source config.json to seed from") .option("--from-data-dir ", "Source PAPERCLIP_HOME used when deriving the source config") .option("--from-instance ", "Source instance id when deriving the source config", "default") .option("--server-port ", "Preferred server port", (value) => Number(value)) .option("--db-port ", "Preferred embedded Postgres port", (value) => Number(value)) .option("--seed-mode ", "Seed profile: minimal or full (default: minimal)", "minimal") .option("--no-seed", "Skip database seeding from the source instance") .option("--force", "Replace existing repo-local config and isolated instance data", false) .action(worktreeInitCommand); worktree .command("env") .description("Print shell exports for the current worktree-local Paperclip instance") .option("-c, --config ", "Path to config file") .option("--json", "Print JSON instead of shell exports") .action(worktreeEnvCommand); }