Spaces:
Paused
Paused
| import { | |
| chmodSync, | |
| copyFileSync, | |
| existsSync, | |
| mkdirSync, | |
| readdirSync, | |
| readFileSync, | |
| readlinkSync, | |
| rmSync, | |
| statSync, | |
| symlinkSync, | |
| 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 { eq } from "drizzle-orm"; | |
| import { | |
| applyPendingMigrations, | |
| createDb, | |
| ensurePostgresDatabase, | |
| formatDatabaseBackupResult, | |
| projectWorkspaces, | |
| 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, | |
| generateWorktreeColor, | |
| 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; | |
| sourceConfigPathOverride?: string; | |
| serverPort?: number; | |
| dbPort?: number; | |
| seed?: boolean; | |
| seedMode?: string; | |
| force?: boolean; | |
| }; | |
| type WorktreeMakeOptions = WorktreeInitOptions & { | |
| startPoint?: string; | |
| }; | |
| type WorktreeEnvOptions = { | |
| config?: string; | |
| json?: boolean; | |
| }; | |
| type EmbeddedPostgresInstance = { | |
| initialise(): Promise<void>; | |
| start(): Promise<void>; | |
| stop(): Promise<void>; | |
| }; | |
| type EmbeddedPostgresCtor = new (opts: { | |
| databaseDir: string; | |
| user: string; | |
| password: string; | |
| port: number; | |
| persistent: boolean; | |
| initdbFlags?: string[]; | |
| onLog?: (message: unknown) => void; | |
| onError?: (message: unknown) => void; | |
| }) => EmbeddedPostgresInstance; | |
| type EmbeddedPostgresHandle = { | |
| port: number; | |
| startedByThisProcess: boolean; | |
| stop: () => Promise<void>; | |
| }; | |
| type GitWorkspaceInfo = { | |
| root: string; | |
| commonDir: string; | |
| gitDir: string; | |
| hooksPath: string; | |
| }; | |
| type CopiedGitHooksResult = { | |
| sourceHooksPath: string; | |
| targetHooksPath: string; | |
| copied: boolean; | |
| }; | |
| type SeedWorktreeDatabaseResult = { | |
| backupSummary: string; | |
| reboundWorkspaces: Array<{ | |
| name: string; | |
| fromCwd: string; | |
| toCwd: string; | |
| }>; | |
| }; | |
| function nonEmpty(value: string | null | undefined): string | null { | |
| return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; | |
| } | |
| function isCurrentSourceConfigPath(sourceConfigPath: string): boolean { | |
| const currentConfigPath = process.env.PAPERCLIP_CONFIG; | |
| if (!currentConfigPath || currentConfigPath.trim().length === 0) { | |
| return false; | |
| } | |
| return path.resolve(currentConfigPath) === path.resolve(sourceConfigPath); | |
| } | |
| const WORKTREE_NAME_PREFIX = "paperclip-"; | |
| function resolveWorktreeMakeName(name: string): string { | |
| const value = nonEmpty(name); | |
| if (!value) { | |
| throw new Error("Worktree name is required."); | |
| } | |
| if (!/^[A-Za-z0-9._-]+$/.test(value)) { | |
| throw new Error( | |
| "Worktree name must contain only letters, numbers, dots, underscores, or dashes.", | |
| ); | |
| } | |
| return value.startsWith(WORKTREE_NAME_PREFIX) ? value : `${WORKTREE_NAME_PREFIX}${value}`; | |
| } | |
| function resolveWorktreeHome(explicit?: string): string { | |
| return explicit ?? process.env.PAPERCLIP_WORKTREES_DIR ?? DEFAULT_WORKTREE_HOME; | |
| } | |
| function resolveWorktreeStartPoint(explicit?: string): string | undefined { | |
| return explicit ?? nonEmpty(process.env.PAPERCLIP_WORKTREE_START_POINT) ?? undefined; | |
| } | |
| export function resolveWorktreeMakeTargetPath(name: string): string { | |
| return path.resolve(os.homedir(), resolveWorktreeMakeName(name)); | |
| } | |
| function extractExecSyncErrorMessage(error: unknown): string | null { | |
| if (!error || typeof error !== "object") { | |
| return error instanceof Error ? error.message : null; | |
| } | |
| const stderr = "stderr" in error ? error.stderr : null; | |
| if (typeof stderr === "string") { | |
| return nonEmpty(stderr); | |
| } | |
| if (stderr instanceof Buffer) { | |
| return nonEmpty(stderr.toString("utf8")); | |
| } | |
| return error instanceof Error ? nonEmpty(error.message) : null; | |
| } | |
| function localBranchExists(cwd: string, branchName: string): boolean { | |
| try { | |
| execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { | |
| cwd, | |
| stdio: "ignore", | |
| }); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| export function resolveGitWorktreeAddArgs(input: { | |
| branchName: string; | |
| targetPath: string; | |
| branchExists: boolean; | |
| startPoint?: string; | |
| }): string[] { | |
| if (input.branchExists && !input.startPoint) { | |
| return ["worktree", "add", input.targetPath, input.branchName]; | |
| } | |
| const commitish = input.startPoint ?? "HEAD"; | |
| return ["worktree", "add", "-b", input.branchName, input.targetPath, commitish]; | |
| } | |
| 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<boolean> { | |
| return await new Promise<boolean>((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<number>()): Promise<number> { | |
| 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 detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null { | |
| try { | |
| const root = execFileSync("git", ["rev-parse", "--show-toplevel"], { | |
| cwd, | |
| encoding: "utf8", | |
| stdio: ["ignore", "pipe", "ignore"], | |
| }).trim(); | |
| const commonDirRaw = execFileSync("git", ["rev-parse", "--git-common-dir"], { | |
| cwd: root, | |
| encoding: "utf8", | |
| stdio: ["ignore", "pipe", "ignore"], | |
| }).trim(); | |
| const gitDirRaw = execFileSync("git", ["rev-parse", "--git-dir"], { | |
| cwd: root, | |
| encoding: "utf8", | |
| stdio: ["ignore", "pipe", "ignore"], | |
| }).trim(); | |
| const hooksPathRaw = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { | |
| cwd: root, | |
| encoding: "utf8", | |
| stdio: ["ignore", "pipe", "ignore"], | |
| }).trim(); | |
| return { | |
| root: path.resolve(root), | |
| commonDir: path.resolve(root, commonDirRaw), | |
| gitDir: path.resolve(root, gitDirRaw), | |
| hooksPath: path.resolve(root, hooksPathRaw), | |
| }; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function copyDirectoryContents(sourceDir: string, targetDir: string): boolean { | |
| if (!existsSync(sourceDir)) return false; | |
| const entries = readdirSync(sourceDir, { withFileTypes: true }); | |
| if (entries.length === 0) return false; | |
| mkdirSync(targetDir, { recursive: true }); | |
| let copied = false; | |
| for (const entry of entries) { | |
| const sourcePath = path.resolve(sourceDir, entry.name); | |
| const targetPath = path.resolve(targetDir, entry.name); | |
| if (entry.isDirectory()) { | |
| mkdirSync(targetPath, { recursive: true }); | |
| copyDirectoryContents(sourcePath, targetPath); | |
| copied = true; | |
| continue; | |
| } | |
| if (entry.isSymbolicLink()) { | |
| rmSync(targetPath, { recursive: true, force: true }); | |
| symlinkSync(readlinkSync(sourcePath), targetPath); | |
| copied = true; | |
| continue; | |
| } | |
| copyFileSync(sourcePath, targetPath); | |
| try { | |
| chmodSync(targetPath, statSync(sourcePath).mode & 0o777); | |
| } catch { | |
| // best effort | |
| } | |
| copied = true; | |
| } | |
| return copied; | |
| } | |
| export function copyGitHooksToWorktreeGitDir(cwd: string): CopiedGitHooksResult | null { | |
| const workspace = detectGitWorkspaceInfo(cwd); | |
| if (!workspace) return null; | |
| const sourceHooksPath = workspace.hooksPath; | |
| const targetHooksPath = path.resolve(workspace.gitDir, "hooks"); | |
| if (sourceHooksPath === targetHooksPath) { | |
| return { | |
| sourceHooksPath, | |
| targetHooksPath, | |
| copied: false, | |
| }; | |
| } | |
| return { | |
| sourceHooksPath, | |
| targetHooksPath, | |
| copied: copyDirectoryContents(sourceHooksPath, targetHooksPath), | |
| }; | |
| } | |
| export function rebindWorkspaceCwd(input: { | |
| sourceRepoRoot: string; | |
| targetRepoRoot: string; | |
| workspaceCwd: string; | |
| }): string | null { | |
| const sourceRepoRoot = path.resolve(input.sourceRepoRoot); | |
| const targetRepoRoot = path.resolve(input.targetRepoRoot); | |
| const workspaceCwd = path.resolve(input.workspaceCwd); | |
| const relative = path.relative(sourceRepoRoot, workspaceCwd); | |
| if (!relative || relative === "") { | |
| return targetRepoRoot; | |
| } | |
| if (relative.startsWith("..") || path.isAbsolute(relative)) { | |
| return null; | |
| } | |
| return path.resolve(targetRepoRoot, relative); | |
| } | |
| async function rebindSeededProjectWorkspaces(input: { | |
| targetConnectionString: string; | |
| currentCwd: string; | |
| }): Promise<SeedWorktreeDatabaseResult["reboundWorkspaces"]> { | |
| const targetRepo = detectGitWorkspaceInfo(input.currentCwd); | |
| if (!targetRepo) return []; | |
| const db = createDb(input.targetConnectionString); | |
| const closableDb = db as typeof db & { | |
| $client?: { end?: (opts?: { timeout?: number }) => Promise<void> }; | |
| }; | |
| try { | |
| const rows = await db | |
| .select({ | |
| id: projectWorkspaces.id, | |
| name: projectWorkspaces.name, | |
| cwd: projectWorkspaces.cwd, | |
| }) | |
| .from(projectWorkspaces); | |
| const rebound: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; | |
| for (const row of rows) { | |
| const workspaceCwd = nonEmpty(row.cwd); | |
| if (!workspaceCwd) continue; | |
| const sourceRepo = detectGitWorkspaceInfo(workspaceCwd); | |
| if (!sourceRepo) continue; | |
| if (sourceRepo.commonDir !== targetRepo.commonDir) continue; | |
| const reboundCwd = rebindWorkspaceCwd({ | |
| sourceRepoRoot: sourceRepo.root, | |
| targetRepoRoot: targetRepo.root, | |
| workspaceCwd, | |
| }); | |
| if (!reboundCwd) continue; | |
| const normalizedCurrent = path.resolve(workspaceCwd); | |
| if (reboundCwd === normalizedCurrent) continue; | |
| if (!existsSync(reboundCwd)) continue; | |
| await db | |
| .update(projectWorkspaces) | |
| .set({ | |
| cwd: reboundCwd, | |
| updatedAt: new Date(), | |
| }) | |
| .where(eq(projectWorkspaces.id, row.id)); | |
| rebound.push({ | |
| name: row.name, | |
| fromCwd: normalizedCurrent, | |
| toCwd: reboundCwd, | |
| }); | |
| } | |
| return rebound; | |
| } finally { | |
| await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined); | |
| } | |
| } | |
| export function resolveSourceConfigPath(opts: WorktreeInitOptions): string { | |
| if (opts.sourceConfigPathOverride) return path.resolve(opts.sourceConfigPathOverride); | |
| if (opts.fromConfig) return path.resolve(opts.fromConfig); | |
| if (!opts.fromDataDir && !opts.fromInstance) { | |
| return resolveConfigPath(); | |
| } | |
| 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<string, string>, 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<string, string>; | |
| targetKeyFilePath: string; | |
| }): void { | |
| if (input.sourceConfig.secrets.provider !== "local_encrypted") { | |
| return; | |
| } | |
| mkdirSync(path.dirname(input.targetKeyFilePath), { recursive: true }); | |
| const allowProcessEnvFallback = isCurrentSourceConfigPath(input.sourceConfigPath); | |
| const sourceInlineMasterKey = | |
| nonEmpty(input.sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY) ?? | |
| (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY) : null); | |
| 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) ?? | |
| (allowProcessEnvFallback ? nonEmpty(process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE) : null); | |
| 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<EmbeddedPostgresHandle> { | |
| 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, | |
| initdbFlags: ["--encoding=UTF8", "--locale=C"], | |
| 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<SeedWorktreeDatabaseResult> { | |
| 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); | |
| const reboundWorkspaces = await rebindSeededProjectWorkspaces({ | |
| targetConnectionString, | |
| currentCwd: input.targetPaths.cwd, | |
| }); | |
| return { | |
| backupSummary: formatDatabaseBackupResult(backup), | |
| reboundWorkspaces, | |
| }; | |
| } finally { | |
| if (targetHandle?.startedByThisProcess) { | |
| await targetHandle.stop(); | |
| } | |
| if (sourceHandle?.startedByThisProcess) { | |
| await sourceHandle.stop(); | |
| } | |
| } | |
| } | |
| async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> { | |
| const cwd = process.cwd(); | |
| const worktreeName = 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 ?? worktreeName); | |
| const paths = resolveWorktreeLocalPaths({ | |
| cwd, | |
| homeDir: resolveWorktreeHome(opts.home), | |
| instanceId, | |
| }); | |
| const branding = { | |
| name: worktreeName, | |
| color: generateWorktreeColor(), | |
| }; | |
| 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); | |
| const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath)); | |
| const existingAgentJwtSecret = | |
| nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ?? | |
| nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET); | |
| mergePaperclipEnvEntries( | |
| { | |
| ...buildWorktreeEnvEntries(paths, branding), | |
| ...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), | |
| }, | |
| paths.envPath, | |
| ); | |
| ensureAgentJwtSecret(paths.configPath); | |
| loadPaperclipEnvFile(paths.configPath); | |
| const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd); | |
| let seedSummary: string | null = null; | |
| let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = []; | |
| 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 { | |
| const seeded = await seedWorktreeDatabase({ | |
| sourceConfigPath, | |
| sourceConfig, | |
| targetConfig, | |
| targetPaths: paths, | |
| instanceId, | |
| seedMode, | |
| }); | |
| seedSummary = seeded.backupSummary; | |
| reboundWorkspaceSummary = seeded.reboundWorkspaces; | |
| 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(`Worktree badge: ${branding.name} (${branding.color})`)); | |
| p.log.message(pc.dim(`Server port: ${serverPort} | DB port: ${databasePort}`)); | |
| if (copiedGitHooks?.copied) { | |
| p.log.message( | |
| pc.dim(`Mirrored git hooks: ${copiedGitHooks.sourceHooksPath} -> ${copiedGitHooks.targetHooksPath}`), | |
| ); | |
| } | |
| if (seedSummary) { | |
| p.log.message(pc.dim(`Seed mode: ${seedMode}`)); | |
| p.log.message(pc.dim(`Seed snapshot: ${seedSummary}`)); | |
| for (const rebound of reboundWorkspaceSummary) { | |
| p.log.message( | |
| pc.dim(`Rebound workspace ${rebound.name}: ${rebound.fromCwd} -> ${rebound.toCwd}`), | |
| ); | |
| } | |
| } | |
| p.outro( | |
| pc.green( | |
| `Worktree ready. Run Paperclip inside this repo and the CLI/server will use ${paths.instanceId} automatically.`, | |
| ), | |
| ); | |
| } | |
| export async function worktreeInitCommand(opts: WorktreeInitOptions): Promise<void> { | |
| printPaperclipCliBanner(); | |
| p.intro(pc.bgCyan(pc.black(" paperclipai worktree init "))); | |
| await runWorktreeInit(opts); | |
| } | |
| export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOptions): Promise<void> { | |
| printPaperclipCliBanner(); | |
| p.intro(pc.bgCyan(pc.black(" paperclipai worktree:make "))); | |
| const name = resolveWorktreeMakeName(nameArg); | |
| const startPoint = resolveWorktreeStartPoint(opts.startPoint); | |
| const sourceCwd = process.cwd(); | |
| const sourceConfigPath = resolveSourceConfigPath(opts); | |
| const targetPath = resolveWorktreeMakeTargetPath(name); | |
| if (existsSync(targetPath)) { | |
| throw new Error(`Target path already exists: ${targetPath}`); | |
| } | |
| mkdirSync(path.dirname(targetPath), { recursive: true }); | |
| if (startPoint) { | |
| const [remote] = startPoint.split("/", 1); | |
| try { | |
| execFileSync("git", ["fetch", remote], { | |
| cwd: sourceCwd, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| } catch (error) { | |
| throw new Error( | |
| `Failed to fetch from remote "${remote}": ${extractExecSyncErrorMessage(error) ?? String(error)}`, | |
| ); | |
| } | |
| } | |
| const worktreeArgs = resolveGitWorktreeAddArgs({ | |
| branchName: name, | |
| targetPath, | |
| branchExists: !startPoint && localBranchExists(sourceCwd, name), | |
| startPoint, | |
| }); | |
| const spinner = p.spinner(); | |
| spinner.start(`Creating git worktree at ${targetPath}...`); | |
| try { | |
| execFileSync("git", worktreeArgs, { | |
| cwd: sourceCwd, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| spinner.stop(`Created git worktree at ${targetPath}.`); | |
| } catch (error) { | |
| spinner.stop(pc.red("Failed to create git worktree.")); | |
| throw new Error(extractExecSyncErrorMessage(error) ?? String(error)); | |
| } | |
| const installSpinner = p.spinner(); | |
| installSpinner.start("Installing dependencies..."); | |
| try { | |
| execFileSync("pnpm", ["install"], { | |
| cwd: targetPath, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| installSpinner.stop("Installed dependencies."); | |
| } catch (error) { | |
| installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway).")); | |
| p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); | |
| } | |
| const originalCwd = process.cwd(); | |
| try { | |
| process.chdir(targetPath); | |
| await runWorktreeInit({ | |
| ...opts, | |
| name, | |
| sourceConfigPathOverride: sourceConfigPath, | |
| }); | |
| } catch (error) { | |
| throw error; | |
| } finally { | |
| process.chdir(originalCwd); | |
| } | |
| } | |
| type WorktreeCleanupOptions = { | |
| instance?: string; | |
| home?: string; | |
| force?: boolean; | |
| }; | |
| type GitWorktreeListEntry = { | |
| worktree: string; | |
| branch: string | null; | |
| bare: boolean; | |
| detached: boolean; | |
| }; | |
| function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] { | |
| const raw = execFileSync("git", ["worktree", "list", "--porcelain"], { | |
| cwd, | |
| encoding: "utf8", | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| const entries: GitWorktreeListEntry[] = []; | |
| let current: Partial<GitWorktreeListEntry> = {}; | |
| for (const line of raw.split("\n")) { | |
| if (line.startsWith("worktree ")) { | |
| current = { worktree: line.slice("worktree ".length) }; | |
| } else if (line.startsWith("branch ")) { | |
| current.branch = line.slice("branch ".length); | |
| } else if (line === "bare") { | |
| current.bare = true; | |
| } else if (line === "detached") { | |
| current.detached = true; | |
| } else if (line === "" && current.worktree) { | |
| entries.push({ | |
| worktree: current.worktree, | |
| branch: current.branch ?? null, | |
| bare: current.bare ?? false, | |
| detached: current.detached ?? false, | |
| }); | |
| current = {}; | |
| } | |
| } | |
| if (current.worktree) { | |
| entries.push({ | |
| worktree: current.worktree, | |
| branch: current.branch ?? null, | |
| bare: current.bare ?? false, | |
| detached: current.detached ?? false, | |
| }); | |
| } | |
| return entries; | |
| } | |
| function branchHasUniqueCommits(cwd: string, branchName: string): boolean { | |
| try { | |
| const output = execFileSync( | |
| "git", | |
| ["log", "--oneline", branchName, "--not", "--remotes", "--exclude", `refs/heads/${branchName}`, "--branches"], | |
| { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, | |
| ).trim(); | |
| return output.length > 0; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function branchExistsOnAnyRemote(cwd: string, branchName: string): boolean { | |
| try { | |
| const output = execFileSync( | |
| "git", | |
| ["branch", "-r", "--list", `*/${branchName}`], | |
| { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, | |
| ).trim(); | |
| return output.length > 0; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function worktreePathHasUncommittedChanges(worktreePath: string): boolean { | |
| try { | |
| const output = execFileSync( | |
| "git", | |
| ["status", "--porcelain"], | |
| { cwd: worktreePath, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, | |
| ).trim(); | |
| return output.length > 0; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| export async function worktreeCleanupCommand(nameArg: string, opts: WorktreeCleanupOptions): Promise<void> { | |
| printPaperclipCliBanner(); | |
| p.intro(pc.bgCyan(pc.black(" paperclipai worktree:cleanup "))); | |
| const name = resolveWorktreeMakeName(nameArg); | |
| const sourceCwd = process.cwd(); | |
| const targetPath = resolveWorktreeMakeTargetPath(name); | |
| const instanceId = sanitizeWorktreeInstanceId(opts.instance ?? name); | |
| const homeDir = path.resolve(expandHomePrefix(resolveWorktreeHome(opts.home))); | |
| const instanceRoot = path.resolve(homeDir, "instances", instanceId); | |
| // ── 1. Assess current state ────────────────────────────────────────── | |
| const hasBranch = localBranchExists(sourceCwd, name); | |
| const hasTargetDir = existsSync(targetPath); | |
| const hasInstanceData = existsSync(instanceRoot); | |
| const worktrees = parseGitWorktreeList(sourceCwd); | |
| const linkedWorktree = worktrees.find( | |
| (wt) => wt.branch === `refs/heads/${name}` || path.resolve(wt.worktree) === path.resolve(targetPath), | |
| ); | |
| if (!hasBranch && !hasTargetDir && !hasInstanceData && !linkedWorktree) { | |
| p.log.info("Nothing to clean up — no branch, worktree directory, or instance data found."); | |
| p.outro(pc.green("Already clean.")); | |
| return; | |
| } | |
| // ── 2. Safety checks ──────────────────────────────────────────────── | |
| const problems: string[] = []; | |
| if (hasBranch && branchHasUniqueCommits(sourceCwd, name)) { | |
| const onRemote = branchExistsOnAnyRemote(sourceCwd, name); | |
| if (onRemote) { | |
| p.log.info( | |
| `Branch "${name}" has unique local commits, but the branch also exists on a remote — safe to delete locally.`, | |
| ); | |
| } else { | |
| problems.push( | |
| `Branch "${name}" has commits not found on any other branch or remote. ` + | |
| `Deleting it will lose work. Push it first, or use --force.`, | |
| ); | |
| } | |
| } | |
| if (hasTargetDir && worktreePathHasUncommittedChanges(targetPath)) { | |
| problems.push( | |
| `Worktree directory ${targetPath} has uncommitted changes. Commit or stash first, or use --force.`, | |
| ); | |
| } | |
| if (problems.length > 0 && !opts.force) { | |
| for (const problem of problems) { | |
| p.log.error(problem); | |
| } | |
| throw new Error("Safety checks failed. Resolve the issues above or re-run with --force."); | |
| } | |
| if (problems.length > 0 && opts.force) { | |
| for (const problem of problems) { | |
| p.log.warning(`Overridden by --force: ${problem}`); | |
| } | |
| } | |
| // ── 3. Clean up (idempotent steps) ────────────────────────────────── | |
| // 3a. Remove the git worktree registration | |
| if (linkedWorktree) { | |
| const worktreeDirExists = existsSync(linkedWorktree.worktree); | |
| const spinner = p.spinner(); | |
| if (worktreeDirExists) { | |
| spinner.start(`Removing git worktree at ${linkedWorktree.worktree}...`); | |
| try { | |
| const removeArgs = ["worktree", "remove", linkedWorktree.worktree]; | |
| if (opts.force) removeArgs.push("--force"); | |
| execFileSync("git", removeArgs, { | |
| cwd: sourceCwd, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| spinner.stop(`Removed git worktree at ${linkedWorktree.worktree}.`); | |
| } catch (error) { | |
| spinner.stop(pc.yellow(`Could not remove worktree cleanly, will prune instead.`)); | |
| p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); | |
| } | |
| } else { | |
| spinner.start("Pruning stale worktree entry..."); | |
| execFileSync("git", ["worktree", "prune"], { | |
| cwd: sourceCwd, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| spinner.stop("Pruned stale worktree entry."); | |
| } | |
| } else { | |
| // Even without a linked worktree, prune to clean up any orphaned entries | |
| execFileSync("git", ["worktree", "prune"], { | |
| cwd: sourceCwd, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| } | |
| // 3b. Remove the worktree directory if it still exists (e.g. partial creation) | |
| if (existsSync(targetPath)) { | |
| const spinner = p.spinner(); | |
| spinner.start(`Removing worktree directory ${targetPath}...`); | |
| rmSync(targetPath, { recursive: true, force: true }); | |
| spinner.stop(`Removed worktree directory ${targetPath}.`); | |
| } | |
| // 3c. Delete the local branch (now safe — worktree is gone) | |
| if (localBranchExists(sourceCwd, name)) { | |
| const spinner = p.spinner(); | |
| spinner.start(`Deleting local branch "${name}"...`); | |
| try { | |
| const deleteFlag = opts.force ? "-D" : "-d"; | |
| execFileSync("git", ["branch", deleteFlag, name], { | |
| cwd: sourceCwd, | |
| stdio: ["ignore", "pipe", "pipe"], | |
| }); | |
| spinner.stop(`Deleted local branch "${name}".`); | |
| } catch (error) { | |
| spinner.stop(pc.yellow(`Could not delete branch "${name}".`)); | |
| p.log.warning(extractExecSyncErrorMessage(error) ?? String(error)); | |
| } | |
| } | |
| // 3d. Remove instance data | |
| if (existsSync(instanceRoot)) { | |
| const spinner = p.spinner(); | |
| spinner.start(`Removing instance data at ${instanceRoot}...`); | |
| rmSync(instanceRoot, { recursive: true, force: true }); | |
| spinner.stop(`Removed instance data at ${instanceRoot}.`); | |
| } | |
| p.outro(pc.green("Cleanup complete.")); | |
| } | |
| export async function worktreeEnvCommand(opts: WorktreeEnvOptions): Promise<void> { | |
| 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"); | |
| program | |
| .command("worktree:make") | |
| .description("Create ~/NAME as a git worktree, then initialize an isolated Paperclip instance inside it") | |
| .argument("<name>", "Worktree name — auto-prefixed with paperclip- if needed (created at ~/paperclip-NAME)") | |
| .option("--start-point <ref>", "Remote ref to base the new branch on (env: PAPERCLIP_WORKTREE_START_POINT)") | |
| .option("--instance <id>", "Explicit isolated instance id") | |
| .option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) | |
| .option("--from-config <path>", "Source config.json to seed from") | |
| .option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config") | |
| .option("--from-instance <id>", "Source instance id when deriving the source config", "default") | |
| .option("--server-port <port>", "Preferred server port", (value) => Number(value)) | |
| .option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value)) | |
| .option("--seed-mode <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(worktreeMakeCommand); | |
| worktree | |
| .command("init") | |
| .description("Create repo-local config/env and an isolated instance for this worktree") | |
| .option("--name <name>", "Display name used to derive the instance id") | |
| .option("--instance <id>", "Explicit isolated instance id") | |
| .option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) | |
| .option("--from-config <path>", "Source config.json to seed from") | |
| .option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config") | |
| .option("--from-instance <id>", "Source instance id when deriving the source config", "default") | |
| .option("--server-port <port>", "Preferred server port", (value) => Number(value)) | |
| .option("--db-port <port>", "Preferred embedded Postgres port", (value) => Number(value)) | |
| .option("--seed-mode <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>", "Path to config file") | |
| .option("--json", "Print JSON instead of shell exports") | |
| .action(worktreeEnvCommand); | |
| program | |
| .command("worktree:cleanup") | |
| .description("Safely remove a worktree, its branch, and its isolated instance data") | |
| .argument("<name>", "Worktree name — auto-prefixed with paperclip- if needed") | |
| .option("--instance <id>", "Explicit instance id (if different from the worktree name)") | |
| .option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`) | |
| .option("--force", "Bypass safety checks (uncommitted changes, unique commits)", false) | |
| .action(worktreeCleanupCommand); | |
| } | |