| 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<string, string> { |
| const entries: Record<string, string> = {}; |
|
|
| 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<string, string> { |
| if (!fs.existsSync(envPath)) return {}; |
| return parseEnvFile(fs.readFileSync(envPath, "utf8")); |
| } |
|
|
| function formatEnvEntries(entries: Record<string, string>): 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<number>; |
| databasePorts: Set<number>; |
| } { |
| const serverPorts = new Set<number>(); |
| const databasePorts = new Set<number>(); |
| const siblingConfigPaths = new Set<string>(); |
| 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 { |
| |
| } |
| } |
|
|
| return { serverPorts, databasePorts }; |
| } |
|
|
| function findNextUnclaimedPort(preferredPort: number, claimedPorts: Set<number>): 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 { |
| |
| } |
| } |
|
|
| const existingEnvEntries = readEnvEntries(context.envPath); |
| const desiredEnvEntries: Record<string, string> = { |
| ...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); |
| } |
| } |
|
|