From aa799bba4cd22f698d85ece17a94bd58add9ee9e Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 14:24:06 -0500 Subject: [PATCH 1/2] Fix worktree seed source selection Co-Authored-By: Paperclip --- cli/src/__tests__/worktree.test.ts | 54 ++++++++++++++++++++++++++++++ cli/src/commands/worktree.ts | 9 ++++- doc/DEVELOPING.md | 2 +- 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index fe325cd2..b6e2eb47 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -7,6 +7,7 @@ import { copyGitHooksToWorktreeGitDir, copySeededSecretsKey, rebindWorkspaceCwd, + resolveSourceConfigPath, resolveGitWorktreeAddArgs, resolveWorktreeMakeTargetPath, worktreeInitCommand, @@ -305,6 +306,59 @@ describe("worktree helpers", () => { } }); + 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"); + const localConfigPath = path.join(repoRoot, ".paperclip", "config.json"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(localConfigPath), { recursive: true }); + fs.writeFileSync(localConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(repoRoot); + + expect(fs.realpathSync(resolveSourceConfigPath({}))).toBe(fs.realpathSync(localConfigPath)); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it("preserves the source config path across worktree:make cwd changes", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-override-")); + const sourceConfigPath = path.join(tempRoot, "source", "config.json"); + const targetRoot = path.join(tempRoot, "target"); + const originalCwd = process.cwd(); + const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG; + + try { + fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true }); + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig()), "utf8"); + delete process.env.PAPERCLIP_CONFIG; + process.chdir(targetRoot); + + expect(resolveSourceConfigPath({ sourceConfigPathOverride: sourceConfigPath })).toBe( + path.resolve(sourceConfigPath), + ); + } finally { + process.chdir(originalCwd); + if (originalPaperclipConfig === undefined) { + delete process.env.PAPERCLIP_CONFIG; + } else { + process.env.PAPERCLIP_CONFIG = originalPaperclipConfig; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("rebinds same-repo workspace paths onto the current worktree root", () => { expect( rebindWorkspaceCwd({ diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index fca320b9..b77317fd 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -56,6 +56,7 @@ type WorktreeInitOptions = { fromConfig?: string; fromDataDir?: string; fromInstance?: string; + sourceConfigPathOverride?: string; serverPort?: number; dbPort?: number; seed?: boolean; @@ -426,8 +427,12 @@ async function rebindSeededProjectWorkspaces(input: { } } -function resolveSourceConfigPath(opts: WorktreeInitOptions): string { +export function resolveSourceConfigPath(opts: WorktreeInitOptions): string { + if (opts.sourceConfigPathOverride) return path.resolve(opts.sourceConfigPathOverride); if (opts.fromConfig) return path.resolve(opts.fromConfig); + if (!opts.fromDataDir && !opts.fromInstance) { + return resolveConfigPath(); + } const sourceHome = path.resolve(expandHomePrefix(opts.fromDataDir ?? "~/.paperclip")); const sourceInstanceId = sanitizeWorktreeInstanceId(opts.fromInstance ?? "default"); return path.resolve(sourceHome, "instances", sourceInstanceId, "config.json"); @@ -751,6 +756,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt const name = resolveWorktreeMakeName(nameArg); const startPoint = resolveWorktreeStartPoint(opts.startPoint); const sourceCwd = process.cwd(); + const sourceConfigPath = resolveSourceConfigPath(opts); const targetPath = resolveWorktreeMakeTargetPath(name); if (existsSync(targetPath)) { throw new Error(`Target path already exists: ${targetPath}`); @@ -810,6 +816,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt await runWorktreeInit({ ...opts, name, + sourceConfigPathOverride: sourceConfigPath, }); } catch (error) { throw error; diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 4b379dcb..e3668516 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -142,7 +142,7 @@ This command: - creates an isolated instance under `~/.paperclip-worktrees/instances//` - when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir - picks a free app port and embedded PostgreSQL port -- by default seeds the isolated DB in `minimal` mode from your main instance via a logical SQL snapshot +- by default seeds the isolated DB in `minimal` mode from the current effective Paperclip instance/config (repo-local worktree config when present, otherwise the default instance) via a logical SQL snapshot Seed modes: From 626a8f1976c7638bf23e35a8b0fb58f317492ff5 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 13 Mar 2026 15:07:49 -0500 Subject: [PATCH 2/2] fix(cli): quote env values with special characters --- cli/src/__tests__/agent-jwt-env.test.ts | 18 ++++++++++++++++++ cli/src/__tests__/worktree.test.ts | 2 +- cli/src/config/env.ts | 9 ++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/cli/src/__tests__/agent-jwt-env.test.ts b/cli/src/__tests__/agent-jwt-env.test.ts index 40bb1554..baf5db51 100644 --- a/cli/src/__tests__/agent-jwt-env.test.ts +++ b/cli/src/__tests__/agent-jwt-env.test.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ensureAgentJwtSecret, + mergePaperclipEnvEntries, readAgentJwtSecretFromEnv, + readPaperclipEnvEntries, resolveAgentJwtEnvFile, } from "../config/env.js"; import { agentJwtSecretCheck } from "../checks/agent-jwt-secret-check.js"; @@ -58,4 +60,20 @@ describe("agent jwt env helpers", () => { const result = agentJwtSecretCheck(configPath); expect(result.status).toBe("pass"); }); + + it("quotes hash-prefixed env values so dotenv round-trips them", () => { + const configPath = tempConfigPath(); + const envPath = resolveAgentJwtEnvFile(configPath); + + mergePaperclipEnvEntries( + { + PAPERCLIP_WORKTREE_COLOR: "#439edb", + }, + envPath, + ); + + const contents = fs.readFileSync(envPath, "utf-8"); + expect(contents).toContain('PAPERCLIP_WORKTREE_COLOR="#439edb"'); + expect(readPaperclipEnvEntries(envPath).PAPERCLIP_WORKTREE_COLOR).toBe("#439edb"); + }); }); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index b6e2eb47..a8333ba5 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -294,7 +294,7 @@ describe("worktree helpers", () => { const envContents = fs.readFileSync(envPath, "utf8"); expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo"); - expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=#[0-9a-f]{6}/); + expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/); } finally { process.chdir(originalCwd); if (originalJwtSecret === undefined) { diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 4bc8f16e..a7266ea2 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -22,11 +22,18 @@ function parseEnvFile(contents: string) { } } +function formatEnvValue(value: string): string { + if (/^[A-Za-z0-9_./:@-]+$/.test(value)) { + return value; + } + return JSON.stringify(value); +} + function renderEnvFile(entries: Record) { const lines = [ "# Paperclip environment variables", "# Generated by Paperclip CLI commands", - ...Object.entries(entries).map(([key, value]) => `${key}=${value}`), + ...Object.entries(entries).map(([key, value]) => `${key}=${formatEnvValue(value)}`), "", ]; return lines.join("\n");