forked from farhoodlabs/paperclip
Merge pull request #3386 from paperclipai/pap-1347-dev-runner-worktree-env
fix: isolate dev runner worktree env
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user