diff --git a/server/package.json b/server/package.json index 843f9ca7..c4053237 100644 --- a/server/package.json +++ b/server/package.json @@ -33,7 +33,7 @@ ], "scripts": { "dev": "tsx src/index.ts", - "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", + "dev:watch": "cross-env PAPERCLIP_MIGRATION_PROMPT=never PAPERCLIP_MIGRATION_AUTO_APPLY=true tsx ./scripts/dev-watch.ts", "prepare:ui-dist": "bash ../scripts/prepare-server-ui-dist.sh", "build": "tsc && mkdir -p dist/onboarding-assets && cp -R src/onboarding-assets/. dist/onboarding-assets/", "prepack": "pnpm run prepare:ui-dist", diff --git a/server/scripts/dev-watch.ts b/server/scripts/dev-watch.ts new file mode 100644 index 00000000..69a85245 --- /dev/null +++ b/server/scripts/dev-watch.ts @@ -0,0 +1,33 @@ +import { spawn } from "node:child_process"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveServerDevWatchIgnorePaths } from "../src/dev-watch-ignore.ts"; + +const require = createRequire(import.meta.url); +const tsxCliPath = require.resolve("tsx/dist/cli.mjs"); +const serverRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const ignoreArgs = resolveServerDevWatchIgnorePaths(serverRoot).flatMap((ignorePath) => ["--ignore", ignorePath]); + +const child = spawn( + process.execPath, + [tsxCliPath, "watch", ...ignoreArgs, "src/index.ts"], + { + cwd: serverRoot, + env: process.env, + stdio: "inherit", + }, +); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); + +child.on("error", (error) => { + console.error(error); + process.exit(1); +}); diff --git a/server/src/__tests__/dev-watch-ignore.test.ts b/server/src/__tests__/dev-watch-ignore.test.ts new file mode 100644 index 00000000..0331f61b --- /dev/null +++ b/server/src/__tests__/dev-watch-ignore.test.ts @@ -0,0 +1,34 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveServerDevWatchIgnorePaths } from "../dev-watch-ignore.js"; + +describe("resolveServerDevWatchIgnorePaths", () => { + it("includes both the worktree UI paths and their real shared targets", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-watch-")); + const sharedUiRoot = path.join(tempRoot, "shared-ui"); + const worktreeRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees", "PAP-884"); + const serverRoot = path.join(worktreeRoot, "server"); + const worktreeUiRoot = path.join(worktreeRoot, "ui"); + + fs.mkdirSync(path.join(sharedUiRoot, "node_modules"), { recursive: true }); + fs.mkdirSync(path.join(sharedUiRoot, ".vite"), { recursive: true }); + fs.mkdirSync(path.join(sharedUiRoot, "dist"), { recursive: true }); + fs.mkdirSync(serverRoot, { recursive: true }); + fs.mkdirSync(worktreeUiRoot, { recursive: true }); + + fs.symlinkSync(path.join(sharedUiRoot, "node_modules"), path.join(worktreeUiRoot, "node_modules")); + fs.symlinkSync(path.join(sharedUiRoot, ".vite"), path.join(worktreeUiRoot, ".vite")); + fs.symlinkSync(path.join(sharedUiRoot, "dist"), path.join(worktreeUiRoot, "dist")); + + const ignorePaths = resolveServerDevWatchIgnorePaths(serverRoot); + + expect(ignorePaths).toContain(path.join(worktreeUiRoot, "node_modules")); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "node_modules"))); + expect(ignorePaths).toContain(path.join(worktreeUiRoot, ".vite")); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, ".vite"))); + expect(ignorePaths).toContain(path.join(worktreeUiRoot, "dist")); + expect(ignorePaths).toContain(fs.realpathSync(path.join(sharedUiRoot, "dist"))); + }); +}); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts new file mode 100644 index 00000000..2ec475af --- /dev/null +++ b/server/src/__tests__/worktree-config.test.ts @@ -0,0 +1,237 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + applyRuntimePortSelectionToConfig, + maybePersistWorktreeRuntimePorts, + maybeRepairLegacyWorktreeConfigAndEnvFiles, +} from "../worktree-config.js"; + +const ORIGINAL_ENV = { ...process.env }; +const ORIGINAL_CWD = process.cwd(); + +afterEach(() => { + process.chdir(ORIGINAL_CWD); + + for (const key of Object.keys(process.env)) { + if (!(key in ORIGINAL_ENV)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(ORIGINAL_ENV)) { + process.env[key] = value; + } +}); + +function buildLegacyConfig(sharedRoot: string) { + return { + $meta: { + version: 1, + updatedAt: "2026-03-26T00:00:00.000Z", + source: "configure", + }, + database: { + mode: "embedded-postgres" as const, + embeddedPostgresDataDir: path.join(sharedRoot, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(sharedRoot, "data", "backups"), + }, + }, + logging: { + mode: "file" as const, + logDir: path.join(sharedRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted" as const, + exposure: "private" as const, + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "explicit" as const, + publicBaseUrl: "http://127.0.0.1:3100", + disableSignUp: false, + }, + storage: { + provider: "local_disk" as const, + localDisk: { + baseDir: path.join(sharedRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted" as const, + strictMode: false, + localEncrypted: { + keyFilePath: path.join(sharedRoot, "secrets", "master.key"), + }, + }, + }; +} + +describe("worktree config repair", () => { + it("repairs legacy repo-local worktree config and env files into an isolated instance", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-")); + const worktreeRoot = path.join(tempRoot, "PAP-884-ai-commits-component"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const sharedRoot = path.join(tempRoot, ".paperclip", "instances", "default"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile(configPath, JSON.stringify(buildLegacyConfig(sharedRoot), null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component", + "PAPERCLIP_AGENT_JWT_SECRET=shared-secret", + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + delete process.env.PAPERCLIP_HOME; + delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_CONFIG; + delete process.env.PAPERCLIP_CONTEXT; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + + expect(result).toEqual({ + repairedConfig: true, + repairedEnv: true, + }); + + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component"); + + expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups")); + expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs")); + expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage")); + expect(repairedConfig.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); + expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"'); + expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`); + expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`); + expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"'); + expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); + expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component"); + }); + + it("persists runtime-selected worktree ports back into config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-ports-")); + const worktreeRoot = path.join(tempRoot, "PAP-878-create-a-mine-tab-in-inbox"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const instanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(instanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(instanceRoot, "db"), + embeddedPostgresPort: 54331, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(instanceRoot, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(instanceRoot, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(instanceRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(instanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-878-create-a-mine-tab-in-inbox"; + process.env.PAPERCLIP_HOME = isolatedHome; + process.env.PAPERCLIP_INSTANCE_ID = "pap-878-create-a-mine-tab-in-inbox"; + process.env.PAPERCLIP_CONFIG = configPath; + + maybePersistWorktreeRuntimePorts({ + serverPort: 3103, + databasePort: 54335, + }); + + const writtenConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(writtenConfig.server.port).toBe(3103); + expect(writtenConfig.database.embeddedPostgresPort).toBe(54335); + expect(writtenConfig.auth.publicBaseUrl).toBe("http://127.0.0.1:3103/"); + }); + + it("can update the in-memory config without rewriting env-driven ports", () => { + const { config, changed } = applyRuntimePortSelectionToConfig(buildLegacyConfig("/tmp/shared"), { + serverPort: 3104, + databasePort: 54340, + allowServerPortWrite: false, + allowDatabasePortWrite: true, + }); + + expect(changed).toBe(true); + expect(config.server.port).toBe(3100); + expect(config.database.embeddedPostgresPort).toBe(54340); + expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3104/"); + }); +}); diff --git a/server/src/config.ts b/server/src/config.ts index 6943af7a..4a1cc17b 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -3,6 +3,7 @@ import { existsSync, realpathSync } from "node:fs"; import { resolve } from "node:path"; import { config as loadDotenv } from "dotenv"; import { resolvePaperclipEnvPath } from "./paths.js"; +import { maybeRepairLegacyWorktreeConfigAndEnvFiles } from "./worktree-config.js"; import { AUTH_BASE_URL_MODES, DEPLOYMENT_EXPOSURES, @@ -36,6 +37,8 @@ if (!isSameFile && existsSync(CWD_ENV_PATH)) { loadDotenv({ path: CWD_ENV_PATH, override: false, quiet: true }); } +maybeRepairLegacyWorktreeConfigAndEnvFiles(); + type DatabaseMode = "embedded-postgres" | "postgres"; export interface Config { diff --git a/server/src/dev-watch-ignore.ts b/server/src/dev-watch-ignore.ts new file mode 100644 index 00000000..6e7d90ce --- /dev/null +++ b/server/src/dev-watch-ignore.ts @@ -0,0 +1,21 @@ +import fs from "node:fs"; +import path from "node:path"; + +function addIgnorePath(target: Set, candidate: string): void { + target.add(candidate); + try { + target.add(fs.realpathSync(candidate)); + } catch { + // Ignore paths that do not exist in the current checkout. + } +} + +export function resolveServerDevWatchIgnorePaths(serverRoot: string): string[] { + const ignorePaths = new Set(); + + for (const relativePath of ["../ui/node_modules", "../ui/.vite", "../ui/dist"]) { + addIgnorePath(ignorePaths, path.resolve(serverRoot, relativePath)); + } + + return [...ignorePaths]; +} diff --git a/server/src/index.ts b/server/src/index.ts index d4f41c6e..c37157c0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -30,6 +30,7 @@ import { heartbeatService, reconcilePersistedRuntimeServicesOnStartup, routineSe import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; import { getBoardClaimWarningUrl, initializeBoardClaimChallenge } from "./board-claim.js"; +import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js"; type BetterAuthSessionUser = { id: string; @@ -69,7 +70,7 @@ export interface StartedServer { } export async function startServer(): Promise { - const config = loadConfig(); + let config = loadConfig(); if (process.env.PAPERCLIP_SECRETS_PROVIDER === undefined) { process.env.PAPERCLIP_SECRETS_PROVIDER = config.secretsProvider; } @@ -167,6 +168,18 @@ export async function startServer(): Promise { const normalized = host.trim().toLowerCase(); return normalized === "127.0.0.1" || normalized === "localhost" || normalized === "::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; + } + } const LOCAL_BOARD_USER_ID = "local-board"; const LOCAL_BOARD_USER_EMAIL = "local@paperclip.local"; @@ -233,6 +246,7 @@ export async function startServer(): Promise { let embeddedPostgresStartedByThisProcess = false; let migrationSummary: MigrationSummary = "skipped"; let activeDatabaseConnectionString: string; + let resolvedEmbeddedPostgresPort: number | null = null; let startupDbInfo: | { mode: "external-postgres"; connectionString: string } | { mode: "embedded-postgres"; dataDir: string; port: number }; @@ -395,6 +409,7 @@ export async function startServer(): Promise { db = createDb(embeddedConnectionString); logger.info("Embedded PostgreSQL ready"); activeDatabaseConnectionString = embeddedConnectionString; + resolvedEmbeddedPostgresPort = port; startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } @@ -476,6 +491,19 @@ export async function startServer(): Promise { } const listenPort = await detectPort(config.port); + if (listenPort !== config.port) { + config.port = listenPort; + } + if (resolvedEmbeddedPostgresPort !== null && resolvedEmbeddedPostgresPort !== config.embeddedPostgresPort) { + config.embeddedPostgresPort = resolvedEmbeddedPostgresPort; + } + if (config.authBaseUrlMode === "explicit" && config.authPublicBaseUrl) { + config.authPublicBaseUrl = rewriteLocalUrlPort(config.authPublicBaseUrl, listenPort); + } + maybePersistWorktreeRuntimePorts({ + serverPort: listenPort, + databasePort: resolvedEmbeddedPostgresPort, + }); const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; const storageService = createStorageServiceFromConfig(config); const app = await createApp(db as any, { diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts new file mode 100644 index 00000000..51397b84 --- /dev/null +++ b/server/src/worktree-config.ts @@ -0,0 +1,357 @@ +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 buildIsolatedWorktreeConfig( + config: PaperclipConfig, + context: WorktreeRuntimeContext, +): PaperclipConfig { + const nextConfig: PaperclipConfig = { + ...config, + database: { + ...config.database, + ...(config.database.mode === "embedded-postgres" + ? { + embeddedPostgresDataDir: context.embeddedPostgresDataDir, + backup: { + ...config.database.backup, + dir: context.backupDir, + }, + } + : {}), + }, + 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, config.server.port), + }; + } + + 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; + if (needsWorktreeConfigRepair(parsed, context)) { + writeConfigFile(context.configPath, buildIsolatedWorktreeConfig(parsed, context)); + 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); + } +}