diff --git a/scripts/provision-worktree.sh b/scripts/provision-worktree.sh index 14a31349..0108419d 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,37 @@ 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" + +if [[ ! -e "$worktree_config_path" && ! -L "$worktree_config_path" && -e "$source_config_path" ]]; then + ln -s "$source_config_path" "$worktree_config_path" +fi + +if [[ ! -e "$worktree_env_path" && -e "$source_env_path" ]]; then + cp "$source_env_path" "$worktree_env_path" + chmod 600 "$worktree_env_path" +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 +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 source_path="$base_cwd/$relative_path" diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index dad02d38..6e07f6c6 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"; @@ -282,6 +284,135 @@ 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 () => { + const repoRoot = await createTempRepo(); + const previousCwd = process.cwd(); + const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-home-")); + 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; + + 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 configLinkPath = path.join(workspace.cwd, ".paperclip", "config.json"); + const envPath = path.join(workspace.cwd, ".paperclip", ".env"); + const envContents = await fs.readFile(envPath, "utf8"); + + expect(await fs.readlink(configLinkPath)).toBe(sharedConfigPath); + expect(envContents).toContain('DATABASE_URL="postgres://worktree:test@db.example.com:6543/paperclip"'); + expect(envContents).toContain("PAPERCLIP_IN_WORKTREE=true"); + expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=PAP-885-show-worktree-banner"); + + process.chdir(workspace.cwd); + expect(resolvePaperclipConfigPath()).toBe(configLinkPath); + } 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();