import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { PaperclipConfig } from "@paperclipai/shared"; import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js"; function nonEmpty(value: string | null | undefined): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } function expandHomePrefix(value: string): string { if (value === "~") return os.homedir(); if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); return value; } function resolveHomeAwarePath(value: string): string { return path.resolve(expandHomePrefix(value)); } 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"; } function isLoopbackHost(hostname: string): boolean { const value = hostname.trim().toLowerCase(); return value === "127.0.0.1" || value === "localhost" || value === "::1"; } 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; } } function parseEnvFile(contents: string): Record { const entries: Record = {}; for (const rawLine of contents.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; const match = rawLine.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/); if (!match) continue; const [, key, rawValue] = match; const value = rawValue.trim(); if (!value) { entries[key] = ""; continue; } if ( (value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'")) ) { entries[key] = value.slice(1, -1); continue; } entries[key] = value.replace(/\s+#.*$/, "").trim(); } return entries; } function readEnvEntries(envPath: string): Record { if (!fs.existsSync(envPath)) return {}; return parseEnvFile(fs.readFileSync(envPath, "utf8")); } function formatEnvEntries(entries: Record): string { return [ "# Paperclip environment variables", "# Generated by Paperclip worktree repair", ...Object.entries(entries).map(([key, value]) => `${key}=${JSON.stringify(value)}`), "", ].join("\n"); } function isPathInside(candidatePath: string, rootPath: string): boolean { const candidate = path.resolve(candidatePath); const root = path.resolve(rootPath); return candidate === root || candidate.startsWith(`${root}${path.sep}`); } type WorktreeRuntimeContext = { configPath: string; envPath: string; worktreeName: string; instanceId: string; homeDir: string; instanceRoot: string; contextPath: string; embeddedPostgresDataDir: string; backupDir: string; logDir: string; storageDir: string; secretsKeyFilePath: string; }; function resolveWorktreeRuntimeContext( env: NodeJS.ProcessEnv, overrideConfigPath?: string, ): WorktreeRuntimeContext | null { if (env.PAPERCLIP_IN_WORKTREE !== "true") return null; const configPath = resolvePaperclipConfigPath(overrideConfigPath); const envPath = resolvePaperclipEnvPath(configPath); const worktreeRoot = path.resolve(path.dirname(configPath), ".."); const worktreeName = nonEmpty(env.PAPERCLIP_WORKTREE_NAME) ?? path.basename(worktreeRoot); const instanceId = nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? sanitizeWorktreeInstanceId(worktreeName); const homeDir = resolveHomeAwarePath( nonEmpty(env.PAPERCLIP_HOME) ?? nonEmpty(env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees", ); const instanceRoot = path.resolve(homeDir, "instances", instanceId); return { configPath, envPath, worktreeName, instanceId, homeDir, instanceRoot, contextPath: path.resolve(homeDir, "context.json"), embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), backupDir: path.resolve(instanceRoot, "data", "backups"), logDir: path.resolve(instanceRoot, "logs"), storageDir: path.resolve(instanceRoot, "data", "storage"), secretsKeyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), }; } function writeConfigFile(configPath: string, config: PaperclipConfig): void { fs.mkdirSync(path.dirname(configPath), { recursive: true }); fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); } function resolveRepoManagedWorktreesRoot(worktreeRoot: string): string | null { const normalized = path.resolve(worktreeRoot); const marker = `${path.sep}.paperclip${path.sep}worktrees${path.sep}`; const index = normalized.indexOf(marker); if (index === -1) return null; const repoRoot = normalized.slice(0, index); return path.resolve(repoRoot, ".paperclip", "worktrees"); } function collectSiblingWorktreePorts(context: WorktreeRuntimeContext): { serverPorts: Set; databasePorts: Set; } { const serverPorts = new Set(); const databasePorts = new Set(); const siblingConfigPaths = new Set(); const instancesDir = path.resolve(context.homeDir, "instances"); if (fs.existsSync(instancesDir)) { for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === context.instanceId) continue; const siblingConfigPath = path.resolve(instancesDir, entry.name, "config.json"); if (fs.existsSync(siblingConfigPath)) { siblingConfigPaths.add(siblingConfigPath); } } } const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(path.dirname(context.configPath)); if (repoManagedWorktreesRoot && fs.existsSync(repoManagedWorktreesRoot)) { for (const entry of fs.readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const siblingConfigPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json"); if (path.resolve(siblingConfigPath) === path.resolve(context.configPath)) continue; if (fs.existsSync(siblingConfigPath)) { siblingConfigPaths.add(siblingConfigPath); } } } for (const siblingConfigPath of siblingConfigPaths) { try { const siblingConfig = JSON.parse(fs.readFileSync(siblingConfigPath, "utf8")) as PaperclipConfig; if (Number.isInteger(siblingConfig.server.port) && siblingConfig.server.port > 0) { serverPorts.add(siblingConfig.server.port); } if ( siblingConfig.database.mode === "embedded-postgres" && Number.isInteger(siblingConfig.database.embeddedPostgresPort) && siblingConfig.database.embeddedPostgresPort > 0 ) { databasePorts.add(siblingConfig.database.embeddedPostgresPort); } } catch { // Ignore sibling configs that are missing or malformed. } } return { serverPorts, databasePorts }; } function findNextUnclaimedPort(preferredPort: number, claimedPorts: Set): number { let port = Math.max(1, Math.trunc(preferredPort)); while (claimedPorts.has(port)) { port += 1; } return port; } function buildIsolatedWorktreeConfig( config: PaperclipConfig, context: WorktreeRuntimeContext, portOverrides?: { serverPort?: number; databasePort?: number; }, ): PaperclipConfig { const serverPort = portOverrides?.serverPort ?? config.server.port; const databasePort = config.database.mode === "embedded-postgres" ? portOverrides?.databasePort ?? config.database.embeddedPostgresPort : undefined; const nextConfig: PaperclipConfig = { ...config, database: { ...config.database, ...(config.database.mode === "embedded-postgres" ? { embeddedPostgresDataDir: context.embeddedPostgresDataDir, embeddedPostgresPort: databasePort ?? config.database.embeddedPostgresPort, backup: { ...config.database.backup, dir: context.backupDir, }, } : {}), }, server: { ...config.server, port: serverPort, }, logging: { ...config.logging, logDir: context.logDir, }, storage: { ...config.storage, localDisk: { ...config.storage.localDisk, baseDir: context.storageDir, }, }, secrets: { ...config.secrets, localEncrypted: { ...config.secrets.localEncrypted, keyFilePath: context.secretsKeyFilePath, }, }, }; if (config.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) { nextConfig.auth = { ...config.auth, publicBaseUrl: rewriteLocalUrlPort(config.auth.publicBaseUrl, serverPort), }; } return nextConfig; } function needsWorktreeConfigRepair( config: PaperclipConfig, context: WorktreeRuntimeContext, ): boolean { if (config.database.mode === "embedded-postgres") { if (!isPathInside(config.database.embeddedPostgresDataDir, context.instanceRoot)) { return true; } if (!isPathInside(config.database.backup.dir, context.instanceRoot)) { return true; } } if (!isPathInside(config.logging.logDir, context.instanceRoot)) { return true; } if (!isPathInside(config.storage.localDisk.baseDir, context.instanceRoot)) { return true; } if (!isPathInside(config.secrets.localEncrypted.keyFilePath, context.instanceRoot)) { return true; } return false; } export function applyRuntimePortSelectionToConfig( config: PaperclipConfig, input: { serverPort: number; databasePort?: number | null; allowServerPortWrite?: boolean; allowDatabasePortWrite?: boolean; }, ): { config: PaperclipConfig; changed: boolean } { let changed = false; let nextConfig = config; if (input.allowServerPortWrite !== false && config.server.port !== input.serverPort) { nextConfig = { ...nextConfig, server: { ...nextConfig.server, port: input.serverPort, }, }; changed = true; } if ( input.allowDatabasePortWrite !== false && nextConfig.database.mode === "embedded-postgres" && typeof input.databasePort === "number" && nextConfig.database.embeddedPostgresPort !== input.databasePort ) { nextConfig = { ...nextConfig, database: { ...nextConfig.database, embeddedPostgresPort: input.databasePort, }, }; changed = true; } if (nextConfig.auth.baseUrlMode === "explicit" && nextConfig.auth.publicBaseUrl) { const rewritten = rewriteLocalUrlPort(nextConfig.auth.publicBaseUrl, input.serverPort); if (rewritten && rewritten !== nextConfig.auth.publicBaseUrl) { nextConfig = { ...nextConfig, auth: { ...nextConfig.auth, publicBaseUrl: rewritten, }, }; changed = true; } } return { config: nextConfig, changed }; } export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { repairedConfig: boolean; repairedEnv: boolean; } { const context = resolveWorktreeRuntimeContext(process.env); if (!context) { return { repairedConfig: false, repairedEnv: false }; } process.env.PAPERCLIP_HOME = context.homeDir; process.env.PAPERCLIP_INSTANCE_ID = context.instanceId; process.env.PAPERCLIP_CONFIG = context.configPath; process.env.PAPERCLIP_CONTEXT = context.contextPath; process.env.PAPERCLIP_WORKTREE_NAME = context.worktreeName; let repairedConfig = false; if (fs.existsSync(context.configPath)) { try { const parsed = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig; const siblingPorts = collectSiblingWorktreePorts(context); const hasSiblingPortCollision = siblingPorts.serverPorts.has(parsed.server.port) || (parsed.database.mode === "embedded-postgres" && siblingPorts.databasePorts.has(parsed.database.embeddedPostgresPort)); if (needsWorktreeConfigRepair(parsed, context) || hasSiblingPortCollision) { const selectedServerPort = findNextUnclaimedPort( parsed.server.port === 3100 ? 3101 : parsed.server.port, siblingPorts.serverPorts, ); const selectedDatabasePort = parsed.database.mode === "embedded-postgres" ? findNextUnclaimedPort( parsed.database.embeddedPostgresPort === 54329 ? 54330 : parsed.database.embeddedPostgresPort, new Set([...siblingPorts.databasePorts, selectedServerPort]), ) : undefined; writeConfigFile( context.configPath, buildIsolatedWorktreeConfig(parsed, context, { serverPort: selectedServerPort, databasePort: selectedDatabasePort, }), ); repairedConfig = true; } } catch { // Leave invalid configs to the normal startup validation path. } } const existingEnvEntries = readEnvEntries(context.envPath); const desiredEnvEntries: Record = { ...existingEnvEntries, PAPERCLIP_HOME: context.homeDir, PAPERCLIP_INSTANCE_ID: context.instanceId, PAPERCLIP_CONFIG: context.configPath, PAPERCLIP_CONTEXT: context.contextPath, PAPERCLIP_IN_WORKTREE: "true", PAPERCLIP_WORKTREE_NAME: context.worktreeName, }; const repairedEnv = Object.entries(desiredEnvEntries).some( ([key, value]) => existingEnvEntries[key] !== value, ); if (repairedEnv) { fs.mkdirSync(path.dirname(context.envPath), { recursive: true }); fs.writeFileSync(context.envPath, formatEnvEntries(desiredEnvEntries), { mode: 0o600 }); } return { repairedConfig, repairedEnv }; } export function maybePersistWorktreeRuntimePorts(input: { serverPort: number; databasePort?: number | null; }): void { const context = resolveWorktreeRuntimeContext(process.env); if (!context || !fs.existsSync(context.configPath)) return; let fileConfig: PaperclipConfig; try { fileConfig = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig; } catch { return; } const { config, changed } = applyRuntimePortSelectionToConfig(fileConfig, { serverPort: input.serverPort, databasePort: input.databasePort, allowServerPortWrite: !nonEmpty(process.env.PORT), allowDatabasePortWrite: !nonEmpty(process.env.DATABASE_URL), }); if (changed) { writeConfigFile(context.configPath, config); } }