diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 0108419d..ea5e0e0f 100644 --- a/scripts/provision-worktree.sh +++ b/scripts/provision-worktree.sh @@ -31,25 +31,274 @@ source_env_path="$(dirname "$source_config_path")/.env" mkdir -p "$paperclip_dir" -if [[ ! -e "$worktree_config_path" && ! -L "$worktree_config_path" && -e "$source_config_path" ]]; then - ln -s "$source_config_path" "$worktree_config_path" -fi +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 [[ ! -e "$worktree_env_path" && -e "$source_env_path" ]]; then - cp "$source_env_path" "$worktree_env_path" - chmod 600 "$worktree_env_path" -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 -tmp_env="$(mktemp "${TMPDIR:-/tmp}/paperclip-worktree-env.XXXXXX")" -if [[ -e "$worktree_env_path" ]]; then - grep -vE '^(PAPERCLIP_IN_WORKTREE|PAPERCLIP_WORKTREE_NAME)=' "$worktree_env_path" > "$tmp_env" || true + 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 -{ - printf 'PAPERCLIP_IN_WORKTREE=true\n' - printf 'PAPERCLIP_WORKTREE_NAME=%s\n' "$worktree_name" -} >> "$tmp_env" -mv "$tmp_env" "$worktree_env_path" -chmod 600 "$worktree_env_path" while IFS= read -r relative_path; do [[ -n "$relative_path" ]] || continue diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 6e07f6c6..6a55a72b 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -126,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; }); @@ -284,10 +285,11 @@ describe("realizeExecutionWorkspace", () => { await expect(fs.readFile(path.join(reused.cwd, ".paperclip-provision-created"), "utf8")).resolves.toBe("false\n"); }); - it("seeds repo-local Paperclip config and worktree branding when provisioning", async () => { + 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"); @@ -295,6 +297,7 @@ describe("realizeExecutionWorkspace", () => { 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( @@ -397,17 +400,36 @@ describe("realizeExecutionWorkspace", () => { }, }); - const configLinkPath = path.join(workspace.cwd, ".paperclip", "config.json"); + 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(await fs.readlink(configLinkPath)).toBe(sharedConfigPath); - expect(envContents).toContain('DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"'); + 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=PAP-885-show-worktree-banner"); + expect(envContents).toContain( + `PAPERCLIP_WORKTREE_NAME=${JSON.stringify("PAP-885-show-worktree-banner")}`, + ); process.chdir(workspace.cwd); - expect(resolvePaperclipConfigPath()).toBe(configLinkPath); + expect(resolvePaperclipConfigPath()).toBe(configPath); } finally { process.chdir(previousCwd); }