Merge pull request #3386 from paperclipai/pap-1347-dev-runner-worktree-env

fix: isolate dev runner worktree env
This commit is contained in:
Dotta
2026-04-11 08:45:16 -05:00
committed by GitHub
4 changed files with 173 additions and 0 deletions
+2
View File
@@ -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:
+9
View File
@@ -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;
@@ -0,0 +1,75 @@
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<string>();
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",
"PAPERCLIP_OPTIONAL= # comment-only value",
"",
].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");
expect(env.PAPERCLIP_OPTIONAL).toBe("");
});
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,
});
});
});
+87
View File
@@ -0,0 +1,87 @@
import { existsSync, lstatSync, readFileSync } from "node:fs";
import path from "node:path";
function parseEnvFile(contents: string): Record<string, string> {
const entries: Record<string, string> = {};
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("#")) {
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;
}
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;
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,
): WorktreeEnvBootstrapResult {
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,
};
}