mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
Merge pull request #3386 from paperclipai/pap-1347-dev-runner-worktree-env
fix: isolate dev runner worktree env
This commit is contained in:
commit
a692e37f3e
4 changed files with 173 additions and 0 deletions
|
|
@ -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.
|
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.
|
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:
|
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 { createCapturedOutputBuffer, parseJsonResponseWithLimit } from "./dev-runner-output.mjs";
|
||||||
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs";
|
||||||
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
import { createDevServiceIdentity, repoRoot } from "./dev-service-profile.ts";
|
||||||
|
import { bootstrapDevRunnerWorktreeEnv } from "../server/src/dev-runner-worktree.ts";
|
||||||
import {
|
import {
|
||||||
findAdoptableLocalService,
|
findAdoptableLocalService,
|
||||||
removeLocalServiceRegistryRecord,
|
removeLocalServiceRegistryRecord,
|
||||||
|
|
@ -19,6 +20,14 @@ import {
|
||||||
const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const;
|
const BIND_MODES = ["loopback", "lan", "tailnet", "custom"] as const;
|
||||||
type BindMode = (typeof BIND_MODES)[number];
|
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 mode = process.argv[2] === "watch" ? "watch" : "dev";
|
||||||
const cliArgs = process.argv.slice(3);
|
const cliArgs = process.argv.slice(3);
|
||||||
const scanIntervalMs = 1500;
|
const scanIntervalMs = 1500;
|
||||||
|
|
|
||||||
75
server/src/__tests__/dev-runner-worktree.test.ts
Normal file
75
server/src/__tests__/dev-runner-worktree.test.ts
Normal file
|
|
@ -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
server/src/dev-runner-worktree.ts
Normal file
87
server/src/dev-runner-worktree.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue