Merge pull request #816 from paperclipai/fix/worktree-seed-and-env-quoting

fix(cli): preserve worktree seed source config and quote special env values
This commit is contained in:
Dotta
2026-03-13 15:18:03 -05:00
committed by GitHub
5 changed files with 90 additions and 4 deletions

View File

@@ -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");
});
});

View File

@@ -7,6 +7,7 @@ import {
copyGitHooksToWorktreeGitDir,
copySeededSecretsKey,
rebindWorkspaceCwd,
resolveSourceConfigPath,
resolveGitWorktreeAddArgs,
resolveWorktreeMakeTargetPath,
worktreeInitCommand,
@@ -293,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) {
@@ -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({

View File

@@ -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;

View File

@@ -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<string, string>) {
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");

View File

@@ -142,7 +142,7 @@ This command:
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
- 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: