import { randomInt } from "node:crypto"; 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 const WORKTREE_SEED_MODES = ["minimal", "full"] as const; export type WorktreeSeedMode = (typeof WORKTREE_SEED_MODES)[number]; export type WorktreeSeedPlan = { mode: WorktreeSeedMode; excludedTables: string[]; nullifyColumns: Record; }; const MINIMAL_WORKTREE_EXCLUDED_TABLES = [ "activity_log", "agent_runtime_state", "agent_task_sessions", "agent_wakeup_requests", "cost_events", "heartbeat_run_events", "heartbeat_runs", "workspace_runtime_services", ]; const MINIMAL_WORKTREE_NULLIFIED_COLUMNS: Record = { issues: ["checkout_run_id", "execution_run_id"], }; 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; }; export type WorktreeUiBranding = { name: string; color: string; }; export function isWorktreeSeedMode(value: string): value is WorktreeSeedMode { return (WORKTREE_SEED_MODES as readonly string[]).includes(value); } export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPlan { if (mode === "full") { return { mode, excludedTables: [], nullifyColumns: {}, }; } return { mode, excludedTables: [...MINIMAL_WORKTREE_EXCLUDED_TABLES], nullifyColumns: { ...MINIMAL_WORKTREE_NULLIFIED_COLUMNS, }, }; } 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)); } function hslComponentToHex(n: number): string { return Math.round(Math.max(0, Math.min(255, n))) .toString(16) .padStart(2, "0"); } function hslToHex(hue: number, saturation: number, lightness: number): string { const s = Math.max(0, Math.min(100, saturation)) / 100; const l = Math.max(0, Math.min(100, lightness)) / 100; const c = (1 - Math.abs((2 * l) - 1)) * s; const h = ((hue % 360) + 360) % 360; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - (c / 2); let r = 0; let g = 0; let b = 0; if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; } else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; } return `#${hslComponentToHex((r + m) * 255)}${hslComponentToHex((g + m) * 255)}${hslComponentToHex((b + m) * 255)}`; } export function generateWorktreeColor(): string { return hslToHex(randomInt(0, 360), 68, 56); } 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, branding?: WorktreeUiBranding, ): Record { return { PAPERCLIP_HOME: paths.homeDir, PAPERCLIP_INSTANCE_ID: paths.instanceId, PAPERCLIP_CONFIG: paths.configPath, PAPERCLIP_CONTEXT: paths.contextPath, PAPERCLIP_IN_WORKTREE: "true", ...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}), ...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}), }; } 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"); }