diff --git a/README.md b/README.md index 391a0feb..f7ade1b3 100644 --- a/README.md +++ b/README.md @@ -234,16 +234,27 @@ See [doc/DEVELOPING.md](doc/DEVELOPING.md) for the full development guide. ## Roadmap -- ⚪ Get OpenClaw onboarding easier -- ⚪ Get cloud agents working e.g. Cursor / e2b agents -- ⚪ ClipMart - buy and sell entire agent companies -- ⚪ Easy agent configurations / easier to understand -- ⚪ Better support for harness engineering -- 🟢 Plugin system (e.g. if you want to add a knowledgebase, custom tracing, queues, etc) -- ⚪ Better docs +- ✅ Plugin system (e.g. add a knowledge base, custom tracing, queues, etc) +- ✅ Get OpenClaw / claw-style agent employees +- ✅ companies.sh - import and export entire organizations +- ✅ Easy AGENTS.md configurations +- ✅ Skills Manager +- ✅ Scheduled Routines +- ✅ Better Budgeting +- ⚪ Artifacts & Deployments +- ⚪ CEO Chat +- ⚪ MAXIMIZER MODE +- ⚪ Multiple Human Users +- ⚪ Cloud / Sandbox agents (e.g. Cursor / e2b agents) +- ⚪ Cloud deployments +- ⚪ Desktop App
+## Community & Plugins + +Find Plugins and more at [awesome-paperclip](https://github.com/gsxdsm/awesome-paperclip) + ## Contributing We welcome contributions. See the [contributing guide](CONTRIBUTING.md) for details. diff --git a/cli/src/__tests__/company-import-export-e2e.test.ts b/cli/src/__tests__/company-import-export-e2e.test.ts index 27334105..c543249e 100644 --- a/cli/src/__tests__/company-import-export-e2e.test.ts +++ b/cli/src/__tests__/company-import-export-e2e.test.ts @@ -6,33 +6,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { createStoredZipArchive } from "./helpers/zip.js"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - const execFileAsync = promisify(execFile); type ServerProcess = ReturnType; -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - async function getAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = net.createServer(); @@ -53,30 +35,13 @@ async function getAvailablePort(): Promise { }); } -async function startTempDatabase() { - const dataDir = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-db-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; - const { applyPendingMigrations, ensurePostgresDatabase } = await import("@paperclipai/db"); - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - - return { connectionString, dataDir, instance }; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres company import/export e2e tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } function writeTestConfig(configPath: string, tempRoot: string, port: number, connectionString: string) { @@ -265,26 +230,23 @@ async function waitForServer( ); } -describe("paperclipai company import/export e2e", () => { +describeEmbeddedPostgres("paperclipai company import/export e2e", () => { let tempRoot = ""; let configPath = ""; let exportDir = ""; let apiBase = ""; let serverProcess: ServerProcess | null = null; - let dbDataDir = ""; - let dbInstance: EmbeddedPostgresInstance | null = null; + let tempDb: Awaited> | null = null; beforeAll(async () => { tempRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-company-cli-e2e-")); configPath = path.join(tempRoot, "config", "config.json"); exportDir = path.join(tempRoot, "exported-company"); - const db = await startTempDatabase(); - dbDataDir = db.dataDir; - dbInstance = db.instance; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-cli-db-"); const port = await getAvailablePort(); - writeTestConfig(configPath, tempRoot, port, db.connectionString); + writeTestConfig(configPath, tempRoot, port, tempDb.connectionString); apiBase = `http://127.0.0.1:${port}`; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); @@ -294,7 +256,7 @@ describe("paperclipai company import/export e2e", () => { ["paperclipai", "run", "--config", configPath], { cwd: repoRoot, - env: createServerEnv(configPath, port, db.connectionString), + env: createServerEnv(configPath, port, tempDb.connectionString), stdio: ["ignore", "pipe", "pipe"], }, ); @@ -311,10 +273,7 @@ describe("paperclipai company import/export e2e", () => { afterAll(async () => { await stopServerProcess(serverProcess); - await dbInstance?.stop(); - if (dbDataDir) { - rmSync(dbDataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); if (tempRoot) { rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/cli/src/__tests__/helpers/embedded-postgres.ts b/cli/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000..4318162a --- /dev/null +++ b/cli/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,6 @@ +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "@paperclipai/db"; diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index ca48b001..3c2079d2 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -344,6 +344,87 @@ describe("worktree helpers", () => { } }); + it("avoids ports already claimed by sibling worktree instance configs", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-claimed-ports-")); + const repoRoot = path.join(tempRoot, "repo"); + const homeDir = path.join(tempRoot, ".paperclip-worktrees"); + const siblingInstanceRoot = path.join(homeDir, "instances", "existing-worktree"); + const originalCwd = process.cwd(); + + try { + fs.mkdirSync(repoRoot, { recursive: true }); + fs.mkdirSync(siblingInstanceRoot, { recursive: true }); + fs.writeFileSync( + path.join(siblingInstanceRoot, "config.json"), + JSON.stringify( + { + ...buildSourceConfig(), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(siblingInstanceRoot, "logs"), + }, + server: { + deploymentMode: "authenticated", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: ["localhost"], + serveUi: true, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(siblingInstanceRoot, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(siblingInstanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + ); + + process.chdir(repoRoot); + await worktreeInitCommand({ + seed: false, + fromConfig: path.join(tempRoot, "missing", "config.json"), + home: homeDir, + }); + + const config = JSON.parse(fs.readFileSync(path.join(repoRoot, ".paperclip", "config.json"), "utf8")); + expect(config.server.port).toBe(3102); + expect(config.database.embeddedPostgresPort).not.toBe(54330); + expect(config.database.embeddedPostgresPort).not.toBe(config.server.port); + expect(config.database.embeddedPostgresPort).toBeGreaterThan(54330); + } finally { + process.chdir(originalCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("defaults the seed source config to the current repo-local Paperclip config", () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-")); const repoRoot = path.join(tempRoot, "repo"); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 7a2bd127..a528bf5b 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -465,6 +465,62 @@ async function findAvailablePort(preferredPort: number, reserved = new Set; + databasePorts: Set; +} { + const serverPorts = new Set(); + const databasePorts = new Set(); + const configPaths = new Set(); + const instancesDir = path.resolve(homeDir, "instances"); + if (existsSync(instancesDir)) { + for (const entry of readdirSync(instancesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === currentInstanceId) continue; + + const configPath = path.resolve(instancesDir, entry.name, "config.json"); + if (existsSync(configPath)) { + configPaths.add(configPath); + } + } + } + + const repoManagedWorktreesRoot = resolveRepoManagedWorktreesRoot(cwd); + if (repoManagedWorktreesRoot && existsSync(repoManagedWorktreesRoot)) { + for (const entry of readdirSync(repoManagedWorktreesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const configPath = path.resolve(repoManagedWorktreesRoot, entry.name, ".paperclip", "config.json"); + if (existsSync(configPath)) { + configPaths.add(configPath); + } + } + } + + for (const configPath of configPaths) { + try { + const config = readConfig(configPath); + if (config?.server.port) { + serverPorts.add(config.server.port); + } + if (config?.database.mode === "embedded-postgres") { + databasePorts.add(config.database.embeddedPostgresPort); + } + } catch { + // Ignore malformed sibling configs. + } + } + + return { serverPorts, databasePorts }; +} + function detectGitBranchName(cwd: string): string | null { try { const value = execFileSync("git", ["branch", "--show-current"], { @@ -886,10 +942,14 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { rmSync(paths.instanceRoot, { recursive: true, force: true }); } + const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd); const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1); - const serverPort = await findAvailablePort(preferredServerPort); + const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts); const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1); - const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); + const databasePort = await findAvailablePort( + preferredDbPort, + new Set([...claimedPorts.databasePorts, serverPort]), + ); const targetConfig = buildWorktreeConfig({ sourceConfig, paths, diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 752fce15..622130ac 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -1,83 +1,24 @@ import { createHash } from "node:crypto"; import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import postgres from "postgres"; import { applyPendingMigrations, - ensurePostgresDatabase, inspectMigrations, } from "./client.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./test-embedded-postgres.js"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -const tempPaths: string[] = []; -const runningInstances: EmbeddedPostgresInstance[] = []; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} +const cleanups: Array<() => Promise> = []; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; async function createTempDatabase(): Promise { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-db-client-")); - tempPaths.push(dataDir); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - runningInstances.push(instance); - - const adminUrl = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminUrl, "paperclip"); - return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + const db = await startEmbeddedPostgresTestDatabase("paperclip-db-client-"); + cleanups.push(db.cleanup); + return db.connectionString; } async function migrationHash(migrationFile: string): Promise { @@ -89,19 +30,19 @@ async function migrationHash(migrationFile: string): Promise { } afterEach(async () => { - while (runningInstances.length > 0) { - const instance = runningInstances.pop(); - if (!instance) continue; - await instance.stop(); - } - while (tempPaths.length > 0) { - const tempPath = tempPaths.pop(); - if (!tempPath) continue; - fs.rmSync(tempPath, { recursive: true, force: true }); + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + await cleanup?.(); } }); -describe("applyPendingMigrations", () => { +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("applyPendingMigrations", () => { it( "applies an inserted earlier migration without replaying later legacy migrations", async () => { diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 5c32ab13..b5ccb5d4 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,6 +11,12 @@ export { type MigrationBootstrapResult, type Db, } from "./client.js"; +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "./test-embedded-postgres.js"; export { runDatabaseBackup, runDatabaseRestore, diff --git a/packages/db/src/test-embedded-postgres.ts b/packages/db/src/test-embedded-postgres.ts new file mode 100644 index 00000000..04fa642d --- /dev/null +++ b/packages/db/src/test-embedded-postgres.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +export type EmbeddedPostgresTestSupport = { + supported: boolean; + reason?: string; +}; + +export type EmbeddedPostgresTestDatabase = { + connectionString: string; + cleanup(): Promise; +}; + +let embeddedPostgresSupportPromise: Promise | null = null; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +function formatEmbeddedPostgresError(error: unknown): string { + if (error instanceof Error && error.message.length > 0) return error.message; + if (typeof error === "string" && error.length > 0) return error; + return "embedded Postgres startup failed"; +} + +async function probeEmbeddedPostgresSupport(): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-postgres-probe-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + return { supported: true }; + } catch (error) { + return { + supported: false, + reason: formatEmbeddedPostgresError(error), + }; + } finally { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +} + +export async function getEmbeddedPostgresTestSupport(): Promise { + if (!embeddedPostgresSupportPromise) { + embeddedPostgresSupportPromise = probeEmbeddedPostgresSupport(); + } + return await embeddedPostgresSupportPromise; +} + +export async function startEmbeddedPostgresTestDatabase( + tempDirPrefix: string, +): Promise { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), tempDirPrefix)); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + + try { + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + + return { + connectionString, + cleanup: async () => { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + }, + }; + } catch (error) { + await instance.stop().catch(() => {}); + fs.rmSync(dataDir, { recursive: true, force: true }); + throw new Error( + `Failed to start embedded PostgreSQL test database: ${formatEmbeddedPostgresError(error)}`, + ); + } +} diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 14a31349..ea5e0e0f 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -3,6 +3,12 @@ set -euo pipefail base_cwd="${PAPERCLIP_WORKSPACE_BASE_CWD:?PAPERCLIP_WORKSPACE_BASE_CWD is required}" worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}" +paperclip_home="${PAPERCLIP_HOME:-$HOME/.paperclip}" +paperclip_instance_id="${PAPERCLIP_INSTANCE_ID:-default}" +paperclip_dir="$worktree_cwd/.paperclip" +worktree_config_path="$paperclip_dir/config.json" +worktree_env_path="$paperclip_dir/.env" +worktree_name="${PAPERCLIP_WORKSPACE_BRANCH:-$(basename "$worktree_cwd")}" if [[ ! -d "$base_cwd" ]]; then echo "Base workspace does not exist: $base_cwd" >&2 @@ -14,6 +20,286 @@ if [[ ! -d "$worktree_cwd" ]]; then exit 1 fi +source_config_path="${PAPERCLIP_CONFIG:-}" +if [[ -z "$source_config_path" && ( -e "$base_cwd/.paperclip/config.json" || -L "$base_cwd/.paperclip/config.json" ) ]]; then + source_config_path="$base_cwd/.paperclip/config.json" +fi +if [[ -z "$source_config_path" ]]; then + source_config_path="$paperclip_home/instances/$paperclip_instance_id/config.json" +fi +source_env_path="$(dirname "$source_config_path")/.env" + +mkdir -p "$paperclip_dir" + +run_isolated_worktree_init() { + if command -v paperclipai >/dev/null 2>&1; then + paperclipai worktree init --force --name "$worktree_name" --from-config "$source_config_path" + return 0 + fi + + if command -v pnpm >/dev/null 2>&1 && pnpm paperclipai --help >/dev/null 2>&1; then + pnpm paperclipai worktree init --force --name "$worktree_name" --from-config "$source_config_path" + return 0 + fi + + return 1 +} + +write_fallback_worktree_config() { + WORKTREE_NAME="$worktree_name" \ + BASE_CWD="$base_cwd" \ + WORKTREE_CWD="$worktree_cwd" \ + PAPERCLIP_DIR="$paperclip_dir" \ + SOURCE_CONFIG_PATH="$source_config_path" \ + SOURCE_ENV_PATH="$source_env_path" \ + PAPERCLIP_WORKTREES_DIR="${PAPERCLIP_WORKTREES_DIR:-}" \ + node <<'EOF' +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const net = require("node:net"); + +function expandHomePrefix(value) { + if (!value) return value; + if (value === "~") return os.homedir(); + if (value.startsWith("~/")) return path.resolve(os.homedir(), value.slice(2)); + return value; +} + +function nonEmpty(value) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function sanitizeInstanceId(value) { + const trimmed = String(value ?? "").trim().toLowerCase(); + const normalized = trimmed + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[-_]+|[-_]+$/g, ""); + return normalized || "worktree"; +} + +function parseEnvFile(contents) { + const entries = {}; + 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; +} + +async function findAvailablePort(preferredPort, reserved = new Set()) { + const startPort = Number.isFinite(preferredPort) && preferredPort > 0 ? Math.trunc(preferredPort) : 0; + if (startPort > 0) { + for (let port = startPort; port < startPort + 100; port += 1) { + if (reserved.has(port)) continue; + const available = await new Promise((resolve) => { + const server = net.createServer(); + server.unref(); + server.once("error", () => resolve(false)); + server.listen(port, "127.0.0.1", () => { + server.close(() => resolve(true)); + }); + }); + if (available) return port; + } + } + + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate a port."))); + return; + } + const port = address.port; + server.close(() => resolve(port)); + }); + }); +} + +function isLoopbackHost(hostname) { + const value = hostname.trim().toLowerCase(); + return value === "127.0.0.1" || value === "localhost" || value === "::1"; +} + +function rewriteLocalUrlPort(rawUrl, port) { + 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 resolveRuntimeLikePath(value, configPath) { + const expanded = expandHomePrefix(value); + if (path.isAbsolute(expanded)) return expanded; + return path.resolve(path.dirname(configPath), expanded); +} + +async function main() { + const worktreeName = process.env.WORKTREE_NAME; + const paperclipDir = process.env.PAPERCLIP_DIR; + const sourceConfigPath = process.env.SOURCE_CONFIG_PATH; + const sourceEnvPath = process.env.SOURCE_ENV_PATH; + const worktreeHome = path.resolve(expandHomePrefix(nonEmpty(process.env.PAPERCLIP_WORKTREES_DIR) ?? "~/.paperclip-worktrees")); + const instanceId = sanitizeInstanceId(worktreeName); + const instanceRoot = path.resolve(worktreeHome, "instances", instanceId); + const configPath = path.resolve(paperclipDir, "config.json"); + const envPath = path.resolve(paperclipDir, ".env"); + + let sourceConfig = null; + if (sourceConfigPath && fs.existsSync(sourceConfigPath)) { + sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8")); + } + + const sourceEnvEntries = + sourceEnvPath && fs.existsSync(sourceEnvPath) + ? parseEnvFile(fs.readFileSync(sourceEnvPath, "utf8")) + : {}; + + const preferredServerPort = Number(sourceConfig?.server?.port ?? 3101) + 1; + const serverPort = await findAvailablePort(preferredServerPort); + const preferredDbPort = Number(sourceConfig?.database?.embeddedPostgresPort ?? 54329) + 1; + const databasePort = await findAvailablePort(preferredDbPort, new Set([serverPort])); + + fs.rmSync(configPath, { force: true }); + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + fs.mkdirSync(instanceRoot, { recursive: true }); + + const authPublicBaseUrl = rewriteLocalUrlPort(sourceConfig?.auth?.publicBaseUrl, serverPort); + const targetConfig = { + $meta: { + version: 1, + updatedAt: new Date().toISOString(), + source: "configure", + }, + ...(sourceConfig?.llm ? { llm: sourceConfig.llm } : {}), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.resolve(instanceRoot, "db"), + embeddedPostgresPort: databasePort, + backup: { + enabled: sourceConfig?.database?.backup?.enabled ?? true, + intervalMinutes: sourceConfig?.database?.backup?.intervalMinutes ?? 60, + retentionDays: sourceConfig?.database?.backup?.retentionDays ?? 30, + dir: path.resolve(instanceRoot, "data", "backups"), + }, + }, + logging: { + mode: sourceConfig?.logging?.mode ?? "file", + logDir: path.resolve(instanceRoot, "logs"), + }, + server: { + deploymentMode: sourceConfig?.server?.deploymentMode ?? "local_trusted", + exposure: sourceConfig?.server?.exposure ?? "private", + host: sourceConfig?.server?.host ?? "127.0.0.1", + port: serverPort, + allowedHostnames: sourceConfig?.server?.allowedHostnames ?? [], + serveUi: sourceConfig?.server?.serveUi ?? true, + }, + auth: { + baseUrlMode: sourceConfig?.auth?.baseUrlMode ?? "auto", + ...(authPublicBaseUrl ? { publicBaseUrl: authPublicBaseUrl } : {}), + disableSignUp: sourceConfig?.auth?.disableSignUp ?? false, + }, + storage: { + provider: sourceConfig?.storage?.provider ?? "local_disk", + localDisk: { + baseDir: path.resolve(instanceRoot, "data", "storage"), + }, + s3: { + bucket: sourceConfig?.storage?.s3?.bucket ?? "paperclip", + region: sourceConfig?.storage?.s3?.region ?? "us-east-1", + endpoint: sourceConfig?.storage?.s3?.endpoint, + prefix: sourceConfig?.storage?.s3?.prefix ?? "", + forcePathStyle: sourceConfig?.storage?.s3?.forcePathStyle ?? false, + }, + }, + secrets: { + provider: sourceConfig?.secrets?.provider ?? "local_encrypted", + strictMode: sourceConfig?.secrets?.strictMode ?? false, + localEncrypted: { + keyFilePath: path.resolve(instanceRoot, "secrets", "master.key"), + }, + }, + }; + + fs.writeFileSync(configPath, `${JSON.stringify(targetConfig, null, 2)}\n`, { mode: 0o600 }); + + const inlineMasterKey = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY); + if (inlineMasterKey) { + fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true }); + fs.writeFileSync(targetConfig.secrets.localEncrypted.keyFilePath, inlineMasterKey, { + encoding: "utf8", + mode: 0o600, + }); + } else { + const sourceKeyFilePath = nonEmpty(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE) + ? resolveRuntimeLikePath(sourceEnvEntries.PAPERCLIP_SECRETS_MASTER_KEY_FILE, sourceConfigPath) + : nonEmpty(sourceConfig?.secrets?.localEncrypted?.keyFilePath) + ? resolveRuntimeLikePath(sourceConfig.secrets.localEncrypted.keyFilePath, sourceConfigPath) + : null; + + if (sourceKeyFilePath && fs.existsSync(sourceKeyFilePath)) { + fs.mkdirSync(path.resolve(instanceRoot, "secrets"), { recursive: true }); + fs.copyFileSync(sourceKeyFilePath, targetConfig.secrets.localEncrypted.keyFilePath); + fs.chmodSync(targetConfig.secrets.localEncrypted.keyFilePath, 0o600); + } + } + + const envLines = [ + "PAPERCLIP_HOME=" + JSON.stringify(worktreeHome), + "PAPERCLIP_INSTANCE_ID=" + JSON.stringify(instanceId), + "PAPERCLIP_CONFIG=" + JSON.stringify(configPath), + "PAPERCLIP_CONTEXT=" + JSON.stringify(path.resolve(worktreeHome, "context.json")), + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=" + JSON.stringify(worktreeName), + ]; + + const agentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET); + if (agentJwtSecret) { + envLines.push("PAPERCLIP_AGENT_JWT_SECRET=" + JSON.stringify(agentJwtSecret)); + } + + fs.writeFileSync(envPath, `${envLines.join("\n")}\n`, { mode: 0o600 }); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); +EOF +} + +if ! run_isolated_worktree_init; then + echo "paperclipai CLI not available in this workspace; writing isolated fallback config without DB seeding." >&2 + write_fallback_worktree_config +fi + while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue source_path="$base_cwd/$relative_path" 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__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index d0e3cc31..6b18d162 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -1,89 +1,29 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { spawn, type ChildProcess } from "node:child_process"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { - applyPendingMigrations, - createDb, - ensurePostgresDatabase, agents, agentWakeupRequests, companies, + createDb, heartbeatRunEvents, heartbeatRuns, issues, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { runningProcesses } from "../adapters/index.ts"; import { heartbeatService } from "../services/heartbeat.ts"; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; - -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; -} - -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-recovery-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, instance, dataDir }; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres heartbeat recovery tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } function spawnAliveProcess() { @@ -92,17 +32,14 @@ function spawnAliveProcess() { }); } -describe("heartbeat orphaned process recovery", () => { +describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; const childProcesses = new Set(); beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-heartbeat-recovery-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -125,10 +62,7 @@ describe("heartbeat orphaned process recovery", () => { } childProcesses.clear(); runningProcesses.clear(); - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function seedRunFixture(input?: { diff --git a/server/src/__tests__/helpers/embedded-postgres.ts b/server/src/__tests__/helpers/embedded-postgres.ts new file mode 100644 index 00000000..4318162a --- /dev/null +++ b/server/src/__tests__/helpers/embedded-postgres.ts @@ -0,0 +1,6 @@ +export { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestDatabase, + type EmbeddedPostgresTestSupport, +} from "@paperclipai/db"; diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index ba27866f..1d20293b 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -1,99 +1,37 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, - applyPendingMigrations, companies, createDb, - ensurePostgresDatabase, issueComments, issues, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.ts"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres issue service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-issues-service-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("issueService.list participantAgentId", () => { +describeEmbeddedPostgres("issueService.list participantAgentId", () => { let db!: ReturnType; let svc!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issues-service-"); + db = createDb(tempDb.connectionString); svc = issueService(db); - instance = started.instance; - dataDir = started.dataDir; }, 20_000); afterEach(async () => { @@ -105,10 +43,7 @@ describe("issueService.list participantAgentId", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); it("returns issues an agent participated in across the supported signals", async () => { diff --git a/server/src/__tests__/routines-e2e.test.ts b/server/src/__tests__/routines-e2e.test.ts index 83689724..ab5fd778 100644 --- a/server/src/__tests__/routines-e2e.test.ts +++ b/server/src/__tests__/routines-e2e.test.ts @@ -1,8 +1,4 @@ import { randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { eq } from "drizzle-orm"; import express from "express"; import request from "supertest"; @@ -11,11 +7,9 @@ import { activityLog, agentWakeupRequests, agents, - applyPendingMigrations, companies, companyMemberships, createDb, - ensurePostgresDatabase, heartbeatRunEvents, heartbeatRuns, instanceSettings, @@ -26,6 +20,10 @@ import { routines, routineTriggers, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { errorHandler } from "../middleware/index.js"; import { accessService } from "../services/access.js"; @@ -78,82 +76,22 @@ vi.mock("../services/index.js", async () => { }; }); -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres routine route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-e2e-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("routine routes end-to-end", () => { +describeEmbeddedPostgres("routine routes end-to-end", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-e2e-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -174,10 +112,7 @@ describe("routine routes end-to-end", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function createApp(actor: Record) { diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index d5954246..d6aad0f2 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -1,19 +1,13 @@ import { createHmac, randomUUID } from "node:crypto"; -import fs from "node:fs"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, agents, - applyPendingMigrations, companies, companySecrets, companySecretVersions, createDb, - ensurePostgresDatabase, heartbeatRuns, issues, projects, @@ -21,85 +15,29 @@ import { routines, routineTriggers, } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; import { issueService } from "../services/issues.ts"; import { routineService } from "../services/routines.ts"; -type EmbeddedPostgresInstance = { - initialise(): Promise; - start(): Promise; - stop(): Promise; -}; +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; -type EmbeddedPostgresCtor = new (opts: { - databaseDir: string; - user: string; - password: string; - port: number; - persistent: boolean; - initdbFlags?: string[]; - onLog?: (message: unknown) => void; - onError?: (message: unknown) => void; -}) => EmbeddedPostgresInstance; - -async function getEmbeddedPostgresCtor(): Promise { - const mod = await import("embedded-postgres"); - return mod.default as EmbeddedPostgresCtor; +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres routines service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); } -async function getAvailablePort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); - server.unref(); - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(() => reject(new Error("Failed to allocate test port"))); - return; - } - const { port } = address; - server.close((error) => { - if (error) reject(error); - else resolve(port); - }); - }); - }); -} - -async function startTempDatabase() { - const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-routines-service-")); - const port = await getAvailablePort(); - const EmbeddedPostgres = await getEmbeddedPostgresCtor(); - const instance = new EmbeddedPostgres({ - databaseDir: dataDir, - user: "paperclip", - password: "paperclip", - port, - persistent: true, - initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], - onLog: () => {}, - onError: () => {}, - }); - await instance.initialise(); - await instance.start(); - - const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; - await ensurePostgresDatabase(adminConnectionString, "paperclip"); - const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; - await applyPendingMigrations(connectionString); - return { connectionString, dataDir, instance }; -} - -describe("routine service live-execution coalescing", () => { +describeEmbeddedPostgres("routine service live-execution coalescing", () => { let db!: ReturnType; - let instance: EmbeddedPostgresInstance | null = null; - let dataDir = ""; + let tempDb: Awaited> | null = null; beforeAll(async () => { - const started = await startTempDatabase(); - db = createDb(started.connectionString); - instance = started.instance; - dataDir = started.dataDir; + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-routines-service-"); + db = createDb(tempDb.connectionString); }, 20_000); afterEach(async () => { @@ -117,10 +55,7 @@ describe("routine service live-execution coalescing", () => { }); afterAll(async () => { - await instance?.stop(); - if (dataDir) { - fs.rmSync(dataDir, { recursive: true, force: true }); - } + await tempDb?.cleanup(); }); async function seedFixture(opts?: { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index dad02d38..6a55a72b 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -2,6 +2,7 @@ import { execFile } from "node:child_process"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -13,6 +14,7 @@ import { stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; +import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; @@ -124,6 +126,7 @@ afterEach(async () => { delete process.env.PAPERCLIP_CONFIG; delete process.env.PAPERCLIP_HOME; delete process.env.PAPERCLIP_INSTANCE_ID; + delete process.env.PAPERCLIP_WORKTREES_DIR; delete process.env.DATABASE_URL; }); @@ -282,6 +285,156 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n"); }); + it("writes an isolated repo-local Paperclip config and worktree branding when provisioning", async () => { + const repoRoot = await createTempRepo(); + const previousCwd = process.cwd(); + const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-")); + const isolatedWorktreeHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktrees-")); + const instanceId = "worktree-base"; + const sharedConfigDir = path.join(paperclipHome, "instances", instanceId); + const sharedConfigPath = path.join(sharedConfigDir, "config.json"); + const sharedEnvPath = path.join(sharedConfigDir, ".env"); + + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = instanceId; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedWorktreeHome; + + await fs.mkdir(sharedConfigDir, { recursive: true }); + await fs.writeFile( + sharedConfigPath, + JSON.stringify( + { + $meta: { + version: 1, + updatedAt: "2026-03-26T00:00:00.000Z", + source: "doctor", + }, + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(sharedConfigDir, "db"), + embeddedPostgresPort: 54329, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(sharedConfigDir, "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(sharedConfigDir, "logs"), + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3100, + allowedHostnames: [], + serveUi: true, + }, + auth: { + baseUrlMode: "auto", + disableSignUp: false, + }, + storage: { + provider: "local_disk", + localDisk: { + baseDir: path.join(sharedConfigDir, "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(sharedConfigDir, "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile(sharedEnvPath, 'DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"\n', "utf8"); + + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.copyFile( + fileURLToPath(new URL("../../../scripts/provision-worktree.sh", import.meta.url)), + path.join(repoRoot, "scripts", "provision-worktree.sh"), + ); + await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]); + await runGit(repoRoot, ["commit", "-m", "Add worktree provision script"]); + + try { + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-885", + title: "Show worktree banner", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + const configPath = path.join(workspace.cwd, ".paperclip", "config.json"); + const envPath = path.join(workspace.cwd, ".paperclip", ".env"); + const envContents = await fs.readFile(envPath, "utf8"); + const configContents = JSON.parse(await fs.readFile(configPath, "utf8")); + const configStats = await fs.lstat(configPath); + const expectedInstanceId = "pap-885-show-worktree-banner"; + const expectedInstanceRoot = path.join( + isolatedWorktreeHome, + "instances", + expectedInstanceId, + ); + + expect(configStats.isSymbolicLink()).toBe(false); + expect(configContents.database.embeddedPostgresDataDir).toBe(path.join(expectedInstanceRoot, "db")); + expect(configContents.database.embeddedPostgresDataDir).not.toBe(path.join(sharedConfigDir, "db")); + expect(configContents.server.port).not.toBe(3100); + expect(configContents.secrets.localEncrypted.keyFilePath).toBe( + path.join(expectedInstanceRoot, "secrets", "master.key"), + ); + expect(envContents).not.toContain("DATABASE_URL="); + expect(envContents).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedWorktreeHome)}`); + expect(envContents).toContain(`PAPERCLIP_INSTANCE_ID=${JSON.stringify(expectedInstanceId)}`); + expect(envContents).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`); + expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true"); + expect(envContents).toContain( + `PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`, + ); + + process.chdir(workspace.cwd); + expect(resolvePaperclipConfigPath()).toBe(configPath); + } finally { + process.chdir(previousCwd); + } + }); + it("records worktree setup and provision operations when a recorder is provided", async () => { const repoRoot = await createTempRepo(); const { recorder, operations } = createWorkspaceOperationRecorderDouble(); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts new file mode 100644 index 00000000..3317a254 --- /dev/null +++ b/server/src/__tests__/worktree-config.test.ts @@ -0,0 +1,426 @@ +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("avoids sibling worktree ports when repairing legacy configs", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repair-ports-")); + const worktreeRoot = path.join(tempRoot, "PAP-880-thumbs-capture-for-evals-feature"); + 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"); + const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.mkdir(siblingInstanceRoot, { 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-880-thumbs-capture-for-evals-feature", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(siblingInstanceRoot, "config.json"), + JSON.stringify( + { + ...buildLegacyConfig(siblingInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "data", "backups"), + }, + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-880-thumbs-capture-for-evals-feature"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(result.repairedConfig).toBe(true); + expect(repairedConfig.server.port).toBe(3102); + expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); + }); + + it("rebalances duplicate ports for already isolated worktree configs", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-rebalance-")); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const repoWorktreesRoot = path.join(tempRoot, "repo", ".paperclip", "worktrees"); + const siblingWorktreeRoot = path.join(repoWorktreesRoot, "PAP-878-create-a-mine-tab-in-inbox"); + const siblingInstanceRoot = path.join(isolatedHome, "instances", "pap-878-create-a-mine-tab-in-inbox"); + const currentWorktreeRoot = path.join(repoWorktreesRoot, "PAP-884-ai-commits-component"); + const paperclipDir = path.join(currentWorktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const currentInstanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component"); + const siblingConfigPath = path.join(siblingWorktreeRoot, ".paperclip", "config.json"); + + await fs.mkdir(paperclipDir, { recursive: true }); + await fs.mkdir(path.dirname(siblingConfigPath), { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + ...buildLegacyConfig(currentInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(currentInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(currentInstanceRoot, "data", "backups"), + }, + }, + logging: { + mode: "file", + logDir: path.join(currentInstanceRoot, "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(currentInstanceRoot, "data", "storage"), + }, + s3: { + bucket: "paperclip", + region: "us-east-1", + prefix: "", + forcePathStyle: false, + }, + }, + secrets: { + provider: "local_encrypted", + strictMode: false, + localEncrypted: { + keyFilePath: path.join(currentInstanceRoot, "secrets", "master.key"), + }, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=PAP-884-ai-commits-component", + "", + ].join("\n"), + "utf8", + ); + await fs.writeFile( + siblingConfigPath, + JSON.stringify( + { + ...buildLegacyConfig(siblingInstanceRoot), + database: { + mode: "embedded-postgres", + embeddedPostgresDataDir: path.join(siblingInstanceRoot, "db"), + embeddedPostgresPort: 54330, + backup: { + enabled: true, + intervalMinutes: 60, + retentionDays: 30, + dir: path.join(siblingInstanceRoot, "data", "backups"), + }, + }, + server: { + deploymentMode: "local_trusted", + exposure: "private", + host: "127.0.0.1", + port: 3101, + allowedHostnames: [], + serveUi: true, + }, + }, + null, + 2, + ) + "\n", + "utf8", + ); + + process.chdir(currentWorktreeRoot); + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "PAP-884-ai-commits-component"; + process.env.PAPERCLIP_WORKTREES_DIR = isolatedHome; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + + expect(result.repairedConfig).toBe(true); + expect(repairedConfig.server.port).toBe(3102); + expect(repairedConfig.database.embeddedPostgresPort).toBe(54331); + }); + + 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..3656b7dc --- /dev/null +++ b/server/src/worktree-config.ts @@ -0,0 +1,467 @@ +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); + } +}