diff --git a/cli/package.json b/cli/package.json index 21de193a..24a8bf66 100644 --- a/cli/package.json +++ b/cli/package.json @@ -47,6 +47,7 @@ "drizzle-orm": "0.38.4", "dotenv": "^17.0.1", "commander": "^13.1.0", + "embedded-postgres": "^18.1.0-beta.16", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts new file mode 100644 index 00000000..f393c10c --- /dev/null +++ b/cli/src/__tests__/worktree.test.ts @@ -0,0 +1,110 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildWorktreeConfig, + buildWorktreeEnvEntries, + formatShellExports, + resolveWorktreeLocalPaths, + rewriteLocalUrlPort, + sanitizeWorktreeInstanceId, +} from "../commands/worktree-lib.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +function buildSourceConfig(): PaperclipConfig { + return { + $meta: { + version: 1, + updatedAt: "2026-03-09T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: "/tmp/main/db", + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: "/tmp/main/backups", + }, + }, + logging: { + mode: "file", + logDir: "/tmp/main/logs", + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: ["localhost"], + serveUi: true, + }, + auth: { + baseUrlMode: "explicit", + publicBaseUrl: "http://127.0.0.1:3100", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: "/tmp/main/storage", + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: "/tmp/main/secrets/master.key", + }, + }, + }; +} + +describe("worktree helpers", () => { + it("sanitizes instance ids", () => { + expect(sanitizeWorktreeInstanceId("feature/worktree-support")).toBe("feature-worktree-support"); + expect(sanitizeWorktreeInstanceId(" ")).toBe("worktree"); + }); + + it("rewrites loopback auth URLs to the new port only", () => { + expect(rewriteLocalUrlPort("http://127.0.0.1:3100", 3110)).toBe("http://127.0.0.1:3110/"); + expect(rewriteLocalUrlPort("https://paperclip.example", 3110)).toBe("https://paperclip.example"); + }); + + it("builds isolated config and env paths for a worktree", () => { + const paths = resolveWorktreeLocalPaths({ + cwd: "/tmp/paperclip-feature", + homeDir: "/tmp/paperclip-worktrees", + instanceId: "feature-worktree-support", + }); + const config = buildWorktreeConfig({ + sourceConfig: buildSourceConfig(), + paths, + serverPort: 3110, + databasePort: 54339, + now: new Date("2026-03-09T12:00:00.000Z"), + }); + + expect(config.database.embeddedPostgresDataDir).toBe( + path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"), + ); + expect(config.database.embeddedPostgresPort).toBe(54339); + expect(config.server.port).toBe(3110); + expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/"); + expect(config.storage.localDisk.baseDir).toBe( + path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "data", "storage"), + ); + + const env = buildWorktreeEnvEntries(paths); + expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); + expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); + }); +}); diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts new file mode 100644 index 00000000..bc96a700 --- /dev/null +++ b/cli/src/commands/worktree-lib.ts @@ -0,0 +1,172 @@ +import path from "node:path"; +import type { PaperclipConfig } from "../config/schema.js"; +import { expandHomePrefix } from "../config/home.js"; + +export const DEFAULT_WORKTREE_HOME = "~/.paperclip-worktrees"; + +export type WorktreeLocalPaths = { + cwd: string; + repoConfigDir: string; + configPath: string; + envPath: string; + homeDir: string; + instanceId: string; + instanceRoot: string; + contextPath: string; + embeddedPostgresDataDir: string; + backupDir: string; + logDir: string; + secretsKeyFilePath: string; + storageDir: string; +}; + +function nonEmpty(value: string | null | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +export function sanitizeWorktreeInstanceId(rawValue: string): string { + const trimmed = rawValue.trim().toLowerCase(); + const normalized = trimmed + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return normalized || "worktree"; +} + +export function resolveSuggestedWorktreeName(cwd: string, explicitName?: string): string { + return nonEmpty(explicitName) ?? path.basename(path.resolve(cwd)); +} + +export function resolveWorktreeLocalPaths(opts: { + cwd: string; + homeDir?: string; + instanceId: string; +}): WorktreeLocalPaths { + const cwd = path.resolve(opts.cwd); + const homeDir = path.resolve(expandHomePrefix(opts.homeDir ?? DEFAULT_WORKTREE_HOME)); + const instanceRoot = path.resolve(homeDir, "instances", opts.instanceId); + const repoConfigDir = path.resolve(cwd, ".paperclip"); + return { + cwd, + repoConfigDir, + configPath: path.resolve(repoConfigDir, "config.json"), + envPath: path.resolve(repoConfigDir, ".env"), + homeDir, + instanceId: opts.instanceId, + instanceRoot, + contextPath: path.resolve(homeDir, "context.json"), + embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), + backupDir: path.resolve(instanceRoot, "data", "backups"), + logDir: path.resolve(instanceRoot, "logs"), + secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), + storageDir: path.resolve(instanceRoot, "data", "storage"), + }; +} + +export function rewriteLocalUrlPort(rawUrl: string | undefined, port: number): string | undefined { + if (!rawUrl) return undefined; + try { + const parsed = new URL(rawUrl); + if (!isLoopbackHost(parsed.hostname)) return rawUrl; + parsed.port = String(port); + return parsed.toString(); + } catch { + return rawUrl; + } +} + +export function buildWorktreeConfig(input: { + sourceConfig: PaperclipConfig | null; + paths: WorktreeLocalPaths; + serverPort: number; + databasePort: number; + now?: Date; +}): PaperclipConfig { + const { sourceConfig, paths, serverPort, databasePort } = input; + const nowIso = (input.now ?? new Date()).toISOString(); + + const source = sourceConfig; + const authPublicBaseUrl = rewriteLocalUrlPort(source?.auth.publicBaseUrl, serverPort); + + return { + $meta: { + version: 1, + updatedAt: nowIso, + source: "configure", + }, + ...(source?.llm ? { llm: source.llm } : {}), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: paths.embeddedPostgresDataDir, + embeddedPostgresPort: databasePort, + backup: { + enabled: source?.database.backup.enabled ?? true, + intervalMinutes: source?.database.backup.intervalMinutes ?? 60, + retentionDays: source?.database.backup.retentionDays ?? 30, + dir: paths.backupDir, + }, + }, + logging: { + mode: source?.logging.mode ?? "file", + logDir: paths.logDir, + }, + server: { + deploymentMode: source?.server.deploymentMode ?? "local_trusted", + exposure: source?.server.exposure ?? "private", + host: source?.server.host ?? "127.0.0.1", + port: serverPort, + allowedHostnames: source?.server.allowedHostnames ?? [], + serveUi: source?.server.serveUi ?? true, + }, + auth: { + baseUrlMode: source?.auth.baseUrlMode ?? "auto", + ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), + disableSignUp: source?.auth.disableSignUp ?? false, + }, + storage: { + provider: source?.storage.provider ?? "local_disk", + localDisk: { + baseDir: paths.storageDir, + }, + s3: { + bucket: source?.storage.s3.bucket ?? "paperclip", + region: source?.storage.s3.region ?? "us-east-1", + endpoint: source?.storage.s3.endpoint, + prefix: source?.storage.s3.prefix ?? "", + forcePathStyle: source?.storage.s3.forcePathStyle ?? false, + }, + }, + secrets: { + provider: source?.secrets.provider ?? "local_encrypted", + strictMode: source?.secrets.strictMode ?? false, + localEncrypted: { + keyFilePath: paths.secretsKeyFilePath, + }, + }, + }; +} + +export function buildWorktreeEnvEntries(paths: WorktreeLocalPaths): Record { + return { + PAPERCLIP_HOME: paths.homeDir, + PAPERCLIP_INSTANCE_ID: paths.instanceId, + PAPERCLIP_CONFIG: paths.configPath, + PAPERCLIP_CONTEXT: paths.contextPath, + }; +} + +function shellEscape(value: string): string { + return `'${value.replaceAll("'", `'\"'\"'`)}'`; +} + +export function formatShellExports(entries: Record): string { + return Object.entries(entries) + .filter(([, value]) => typeof value === "string" && value.trim().length > 0) + .map(([key, value]) => `export ${key}=${shellEscape(value)}`) + .join("\n"); +} diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts new file mode 100644 index 00000000..6ccba042 --- /dev/null +++ b/cli/src/commands/worktree.ts @@ -0,0 +1,384 @@ +import { existsSync, readFileSync, rmSync } 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 { + 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 { + buildWorktreeConfig, + buildWorktreeEnvEntries, + DEFAULT_WORKTREE_HOME, + formatShellExports, + resolveSuggestedWorktreeName, + resolveWorktreeLocalPaths, + sanitizeWorktreeInstanceId, + 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; + 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`; +} + +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, + }); + + 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; +}): Promise { + const sourceEnvFile = resolvePaperclipEnvFile(input.sourceConfigPath); + const sourceEnvEntries = readPaperclipEnvEntries(sourceEnvFile); + 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, + }); + + 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, + }); + + 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 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..."); + try { + seedSummary = await seedWorktreeDatabase({ + sourceConfigPath, + sourceConfig, + targetConfig, + targetPaths: paths, + instanceId, + }); + spinner.stop("Seeded isolated worktree database."); + } 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 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("--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); +} diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 0ca4bcc1..4bc8f16e 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -25,13 +25,17 @@ function parseEnvFile(contents: string) { function renderEnvFile(entries: Record) { const lines = [ "# Paperclip environment variables", - "# Generated by `paperclipai onboard`", + "# Generated by Paperclip CLI commands", ...Object.entries(entries).map(([key, value]) => `${key}=${value}`), "", ]; return lines.join("\n"); } +export function resolvePaperclipEnvFile(configPath?: string): string { + return resolveEnvFilePath(configPath); +} + export function resolveAgentJwtEnvFile(configPath?: string): string { return resolveEnvFilePath(configPath); } @@ -82,13 +86,33 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre } export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void { + mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath); +} + +export function readPaperclipEnvEntries(filePath = resolveEnvFilePath()): Record { + if (!fs.existsSync(filePath)) return {}; + return parseEnvFile(fs.readFileSync(filePath, "utf-8")); +} + +export function writePaperclipEnvEntries(entries: Record, filePath = resolveEnvFilePath()): void { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); - - const current = fs.existsSync(filePath) ? parseEnvFile(fs.readFileSync(filePath, "utf-8")) : {}; - current[JWT_SECRET_ENV_KEY] = secret; - - fs.writeFileSync(filePath, renderEnvFile(current), { + fs.writeFileSync(filePath, renderEnvFile(entries), { mode: 0o600, }); } + +export function mergePaperclipEnvEntries( + entries: Record, + filePath = resolveEnvFilePath(), +): Record { + const current = readPaperclipEnvEntries(filePath); + const next = { + ...current, + ...Object.fromEntries( + Object.entries(entries).filter(([, value]) => typeof value === "string" && value.trim().length > 0), + ), + }; + writePaperclipEnvEntries(next, filePath); + return next; +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 9c31f5ae..19ef69f9 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -16,6 +16,8 @@ import { registerApprovalCommands } from "./commands/client/approval.js"; import { registerActivityCommands } from "./commands/client/activity.js"; import { registerDashboardCommands } from "./commands/client/dashboard.js"; import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js"; +import { loadPaperclipEnvFile } from "./config/env.js"; +import { registerWorktreeCommands } from "./commands/worktree.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = @@ -33,6 +35,7 @@ program.hook("preAction", (_thisCommand, actionCommand) => { hasConfigOption: optionNames.has("config"), hasContextOption: optionNames.has("context"), }); + loadPaperclipEnvFile(options.config); }); program @@ -132,6 +135,7 @@ registerAgentCommands(program); registerApprovalCommands(program); registerActivityCommands(program); registerDashboardCommands(program); +registerWorktreeCommands(program); const auth = program.command("auth").description("Authentication and bootstrap utilities"); diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index d3362600..9578ead2 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -124,6 +124,42 @@ When a local agent run has no resolved project/session workspace, Paperclip fall This path honors `PAPERCLIP_HOME` and `PAPERCLIP_INSTANCE_ID` in non-default setups. +## Worktree-local Instances + +When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. + +Instead, create a repo-local Paperclip config plus an isolated instance for the worktree: + +```sh +paperclipai worktree init +``` + +This command: + +- writes repo-local files at `.paperclip/config.json` and `.paperclip/.env` +- creates an isolated instance under `~/.paperclip-worktrees/instances//` +- picks a free app port and embedded PostgreSQL port +- by default seeds the isolated DB from your main instance via a logical SQL snapshot + +After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. + +Print shell exports explicitly when needed: + +```sh +paperclipai worktree env +# or: +eval "$(paperclipai worktree env)" +``` + +Useful options: + +```sh +paperclipai worktree init --no-seed +paperclipai worktree init --from-instance default +paperclipai worktree init --from-data-dir ~/.paperclip +paperclipai worktree init --force +``` + ## Quick Health Checks In another terminal: diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index 951540b1..de8c290d 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -9,6 +9,7 @@ export type RunDatabaseBackupOptions = { retentionDays: number; filenamePrefix?: string; connectTimeoutSeconds?: number; + includeMigrationJournal?: boolean; }; export type RunDatabaseBackupResult = { @@ -17,6 +18,12 @@ export type RunDatabaseBackupResult = { prunedCount: number; }; +export type RunDatabaseRestoreOptions = { + connectionString: string; + backupFile: string; + connectTimeoutSeconds?: number; +}; + function timestamp(date: Date = new Date()): string { const pad = (n: number) => String(n).padStart(2, "0"); return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; @@ -51,6 +58,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise const filenamePrefix = opts.filenamePrefix ?? "paperclip"; const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + const includeMigrationJournal = opts.includeMigrationJournal === true; const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); try { @@ -89,7 +97,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' AND c.relkind = 'r' - AND c.relname != '__drizzle_migrations' + AND (${includeMigrationJournal}::boolean OR c.relname != '__drizzle_migrations') ORDER BY c.relname `; @@ -326,6 +334,18 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise } } +export async function runDatabaseRestore(opts: RunDatabaseRestoreOptions): Promise { + const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); + const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); + + try { + await sql`SELECT 1`; + await sql.file(opts.backupFile).execute(); + } finally { + await sql.end(); + } +} + export function formatDatabaseBackupResult(result: RunDatabaseBackupResult): string { const size = formatBackupSize(result.sizeBytes); const pruned = result.prunedCount > 0 ? `; pruned ${result.prunedCount} old backup(s)` : ""; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3cafa7af..f280cee1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,8 +12,10 @@ export { } from "./client.js"; export { runDatabaseBackup, + runDatabaseRestore, formatDatabaseBackupResult, type RunDatabaseBackupOptions, type RunDatabaseBackupResult, + type RunDatabaseRestoreOptions, } from "./backup-lib.js"; export * from "./schema/index.js";