Spaces:
Paused
Paused
| 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 { | |
| // Ignore sibling configs that are missing or malformed. | |
| } | |
| } | |
| 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 { | |
| // Leave invalid configs to the normal startup validation path. | |
| } | |
| } | |
| 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); | |
| } | |
| } | |