From b6115424b1bb326e9925d322c0c6b89d4cc37fc0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 08:17:16 -0500 Subject: [PATCH 1/2] fix: isolate dev runner worktree env --- doc/DEVELOPING.md | 2 + scripts/dev-runner.ts | 9 +++ .../src/__tests__/dev-runner-worktree.test.ts | 73 +++++++++++++++++ server/src/dev-runner-worktree.ts | 81 +++++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 server/src/__tests__/dev-runner-worktree.test.ts create mode 100644 server/src/dev-runner-worktree.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index dd203196..f86ab47b 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -188,6 +188,8 @@ Seed modes: After `worktree init`, both the server and the CLI auto-load the repo-local `.paperclip/.env` when run inside that worktree, so normal commands like `pnpm dev`, `paperclipai doctor`, and `paperclipai db:backup` stay scoped to the worktree instance. +`pnpm dev` now fails fast in a linked git worktree when `.paperclip/.env` is missing, instead of silently booting against the default instance/port. If that happens, run `paperclipai worktree init` in the worktree first. + Provisioned git worktrees also pause seeded routines that still have enabled schedule triggers in the isolated worktree database by default. This prevents copied daily/cron routines from firing unexpectedly inside the new workspace instance during development without disabling webhook/API-only routines. That repo-local env also sets: diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 0d9da9e6..1c991b99 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -7,6 +7,7 @@ import { stdin, stdout } from "node:process"; import { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs"; import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts"; +import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts"; import { findAdoptableLocalService, removeLocalServiceRegistryRecord, @@ -19,6 +20,14 @@ import { const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const; type BindMode = (typeof BIND_MODES)[number]; +const worktreeEnvBootstrap = bootstrapDevRunnerWorktreeEnv(repoRoot, process.env); +if (worktreeEnvBootstrap.missingEnv) { + console.error( + `[paperclip] linked git worktree at ${repoRoot} is missing ${path.relative(repoRoot, worktreeEnvBootstrap.envPath ?? "")}. Run \`paperclipai worktree init\` in this worktree before \`pnpm dev\`.`, + ); + process.exit(1); +} + const mode = process.argv[2] === "watch" ? "watch" : "dev"; const cliArgs = process.argv.slice(3); const scanIntervalMs = 1500; diff --git a/server/src/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts new file mode 100644 index 00000000..714cb54b --- /dev/null +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + bootstrapDevRunnerWorktreeEnv, + isLinkedGitWorktreeCheckout, + resolveWorktreeEnvFilePath, +} from "../dev-runner-worktree.ts"; + +const tempRoots = new Set(); + +afterEach(() => { + for (const root of tempRoots) { + fs.rmSync(root, { recursive: true, force: true }); + } + tempRoots.clear(); +}); + +function createTempRoot(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempRoots.add(root); + return root; +} + +describe("dev-runner worktree env bootstrap", () => { + it("detects linked git worktrees from .git files", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-"); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + + expect(isLinkedGitWorktreeCheckout(root)).toBe(true); + }); + + it("loads repo-local Paperclip env for initialized worktrees without overriding explicit env", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-env-"); + fs.mkdirSync(path.join(root, ".paperclip"), { recursive: true }); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + fs.writeFileSync( + resolveWorktreeEnvFilePath(root), + [ + "PAPERCLIP_HOME=/tmp/paperclip-worktrees", + "PAPERCLIP_INSTANCE_ID=feature-worktree", + "PAPERCLIP_IN_WORKTREE=true", + "PAPERCLIP_WORKTREE_NAME=feature-worktree", + "", + ].join("\n"), + "utf8", + ); + + const env: NodeJS.ProcessEnv = { + PAPERCLIP_INSTANCE_ID: "already-set", + }; + const result = bootstrapDevRunnerWorktreeEnv(root, env); + + expect(result).toEqual({ + envPath: resolveWorktreeEnvFilePath(root), + missingEnv: false, + }); + expect(env.PAPERCLIP_HOME).toBe("/tmp/paperclip-worktrees"); + expect(env.PAPERCLIP_INSTANCE_ID).toBe("already-set"); + expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + }); + + it("reports uninitialized linked worktrees so dev runner can fail fast", () => { + const root = createTempRoot("paperclip-dev-runner-worktree-missing-"); + fs.writeFileSync(path.join(root, ".git"), "gitdir: /tmp/paperclip/.git/worktrees/feature\n", "utf8"); + + expect(bootstrapDevRunnerWorktreeEnv(root, {})).toEqual({ + envPath: resolveWorktreeEnvFilePath(root), + missingEnv: true, + }); + }); +}); diff --git a/server/src/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts new file mode 100644 index 00000000..286087ef --- /dev/null +++ b/server/src/dev-runner-worktree.ts @@ -0,0 +1,81 @@ +import { existsSync, lstatSync, readFileSync } from "node:fs"; +import path from "node:path"; + +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; +} + +export function isLinkedGitWorktreeCheckout(rootDir: string): boolean { + const gitMetadataPath = path.join(rootDir, ".git"); + if (!existsSync(gitMetadataPath)) return false; + + const stat = lstatSync(gitMetadataPath); + if (!stat.isFile()) return false; + + return readFileSync(gitMetadataPath, "utf8").trimStart().startsWith("gitdir:"); +} + +export function resolveWorktreeEnvFilePath(rootDir: string): string { + return path.resolve(rootDir, ".paperclip", ".env"); +} + +export function bootstrapDevRunnerWorktreeEnv( + rootDir: string, + env: NodeJS.ProcessEnv = process.env, +): { + envPath: string | null; + missingEnv: boolean; +} { + if (!isLinkedGitWorktreeCheckout(rootDir)) { + return { + envPath: null, + missingEnv: false, + }; + } + + const envPath = resolveWorktreeEnvFilePath(rootDir); + if (!existsSync(envPath)) { + return { + envPath, + missingEnv: true, + }; + } + + const entries = parseEnvFile(readFileSync(envPath, "utf8")); + for (const [key, value] of Object.entries(entries)) { + if (typeof env[key] === "string" && env[key]!.trim().length > 0) continue; + env[key] = value; + } + + return { + envPath, + missingEnv: false, + }; +} From a5aed931ab34dacb78f47fd845b60a30a2df7285 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 08:35:53 -0500 Subject: [PATCH 2/2] fix(dev-runner): tighten worktree env bootstrap --- scripts/dev-runner.ts | 2 +- server/src/__tests__/dev-runner-worktree.test.ts | 2 ++ server/src/dev-runner-worktree.ts | 14 ++++++++++---- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 1c991b99..a26ef638 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -23,7 +23,7 @@ type BindMode = (typeof BIND_MODES)[number]; const worktreeEnvBootstrap = bootstrapDevRunnerWorktreeEnv(repoRoot, process.env); if (worktreeEnvBootstrap.missingEnv) { console.error( - `[paperclip] linked git worktree at ${repoRoot} is missing ${path.relative(repoRoot, worktreeEnvBootstrap.envPath ?? "")}. Run \`paperclipai worktree init\` in this worktree before \`pnpm dev\`.`, + `[paperclip] linked git worktree at ${repoRoot} is missing ${path.relative(repoRoot, worktreeEnvBootstrap.envPath)}. Run \`paperclipai worktree init\` in this worktree before \`pnpm dev\`.`, ); process.exit(1); } diff --git a/server/src/__tests__/dev-runner-worktree.test.ts b/server/src/__tests__/dev-runner-worktree.test.ts index 714cb54b..461f2aab 100644 --- a/server/src/__tests__/dev-runner-worktree.test.ts +++ b/server/src/__tests__/dev-runner-worktree.test.ts @@ -42,6 +42,7 @@ describe("dev-runner worktree env bootstrap", () => { "PAPERCLIP_INSTANCE_ID=feature-worktree", "PAPERCLIP_IN_WORKTREE=true", "PAPERCLIP_WORKTREE_NAME=feature-worktree", + "PAPERCLIP_OPTIONAL= # comment-only value", "", ].join("\n"), "utf8", @@ -59,6 +60,7 @@ describe("dev-runner worktree env bootstrap", () => { expect(env.PAPERCLIP_HOME).toBe("/tmp/paperclip-worktrees"); expect(env.PAPERCLIP_INSTANCE_ID).toBe("already-set"); expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(env.PAPERCLIP_OPTIONAL).toBe(""); }); it("reports uninitialized linked worktrees so dev runner can fail fast", () => { diff --git a/server/src/dev-runner-worktree.ts b/server/src/dev-runner-worktree.ts index 286087ef..4e2b5d8d 100644 --- a/server/src/dev-runner-worktree.ts +++ b/server/src/dev-runner-worktree.ts @@ -17,6 +17,10 @@ function parseEnvFile(contents: string): Record { entries[key] = ""; continue; } + if (value.startsWith("#")) { + entries[key] = ""; + continue; + } if ( (value.startsWith("\"") && value.endsWith("\"")) || @@ -32,6 +36,11 @@ function parseEnvFile(contents: string): Record { return entries; } +type WorktreeEnvBootstrapResult = + | { envPath: null; missingEnv: false } + | { envPath: string; missingEnv: true } + | { envPath: string; missingEnv: false }; + export function isLinkedGitWorktreeCheckout(rootDir: string): boolean { const gitMetadataPath = path.join(rootDir, ".git"); if (!existsSync(gitMetadataPath)) return false; @@ -49,10 +58,7 @@ export function resolveWorktreeEnvFilePath(rootDir: string): string { export function bootstrapDevRunnerWorktreeEnv( rootDir: string, env: NodeJS.ProcessEnv = process.env, -): { - envPath: string | null; - missingEnv: boolean; -} { +): WorktreeEnvBootstrapResult { if (!isLinkedGitWorktreeCheckout(rootDir)) { return { envPath: null,