fix: stage complete installed Codex runtime dependencies

Preserve prepared Git installation assets and consumer lifecycle behavior. Bundle the qualified Codex graph and host artifacts without changing agent defaults.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-08 07:01:54 -05:00
1 parent 71cd0a2621
commit 5fa4abaead
10 files changed
+1209 -19

No files matched your search

+422 -6
View File
@@ -28,6 +28,7 @@ import { resolveCliVersion } from "../version.js";
import { systemdServiceName } from "../services/service-manager.js";
const ORIGINAL_ENV = { ...process.env };
const SERVER_PACKAGE_FILES = (JSON.parse(fs.readFileSync(new URL("../../../server/package.json", import.meta.url), "utf8")) as { files: string[] }).files;
describe("managed install commands", () => {
let root: string;
@@ -105,7 +106,7 @@ describe("managed install commands", () => {
expect(runCommand.mock.calls[0]?.[0]).toBe(process.execPath);
});
const createGitCheckoutRunCommand = (sha: string) =>
const createGitCheckoutRunCommand = (sha: string, { bundledServer = false }: { bundledServer?: boolean } = {}) =>
vi.fn(async (file: string, args: string[], _options?: Parameters<CommandRunner>[2]) => {
if (file === "curl" && !args.includes("--output")) return { stdout: JSON.stringify({ sha }), stderr: "" };
if (file === "curl") { fs.writeFileSync(args[args.indexOf("--output") + 1], "archive"); return { stdout: "", stderr: "" }; }
@@ -114,19 +115,45 @@ describe("managed install commands", () => {
const packages = [
{ dir: "packages/shared", name: "@paperclipai/shared", packageJson: { name: "@paperclipai/shared", version: "0.3.1" } },
{ dir: "packages/db", name: "@paperclipai/db", packageJson: { name: "@paperclipai/db", version: "0.3.1", dependencies: { "@paperclipai/shared": "workspace:*" }, bundleDependencies: ["embedded-postgres"] } },
{ dir: "server", name: "@paperclipai/server", packageJson: { name: "@paperclipai/server", version: "0.3.1", dependencies: { "@paperclipai/db": "workspace:*" } } },
{ dir: "server", name: "@paperclipai/server", packageJson: { name: "@paperclipai/server", version: "0.3.1", dependencies: { "@paperclipai/db": "workspace:*" }, ...(bundledServer ? { bundleDependencies: ["acpx"], files: SERVER_PACKAGE_FILES } : {}) } },
];
fs.mkdirSync(path.join(checkout, "cli"), { recursive: true });
fs.writeFileSync(path.join(checkout, "cli", "package.json"), JSON.stringify({ version: "0.3.1" }));
fs.writeFileSync(path.join(checkout, "pnpm-lock.yaml"), "source lockfile awaiting refresh\n");
fs.mkdirSync(path.join(checkout, "scripts"), { recursive: true });
fs.writeFileSync(path.join(checkout, "scripts", "release-package-manifest.json"), JSON.stringify(packages.map(({ dir, name }) => ({ dir, name }))));
for (const workspacePackage of packages) {
fs.mkdirSync(path.join(checkout, workspacePackage.dir), { recursive: true });
fs.writeFileSync(path.join(checkout, workspacePackage.dir, "package.json"), JSON.stringify(workspacePackage.packageJson));
}
const skill = path.join(checkout, "skills", "paperclip");
fs.mkdirSync(path.join(skill, "scripts"), { recursive: true });
fs.writeFileSync(path.join(skill, "SKILL.md"), "Skill from this Git checkout\n");
fs.writeFileSync(path.join(skill, "scripts", "helper.mjs"), "export const sameCheckout = true;\n");
for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) {
const stale = path.join(checkout, packageDir, "skills");
fs.mkdirSync(stale, { recursive: true });
fs.writeFileSync(path.join(stale, "stale.md"), "stale package-local skills\n");
}
return { stdout: "", stderr: "" };
}
if (file === "corepack") {
if (args.includes("build")) {
const dist = path.join(_options!.cwd as string, "server", "dist");
fs.mkdirSync(dist, { recursive: true });
fs.writeFileSync(path.join(dist, "index.js"), "export const builtServer = true;\n");
}
if (args.includes("prepare:ui-dist")) {
const ui = path.join(_options!.cwd as string, "server", "ui-dist");
fs.mkdirSync(ui, { recursive: true });
fs.writeFileSync(path.join(ui, "index.html"), "<html>Built from this Git checkout</html>");
}
if (args.includes("--resolution-only")) {
fs.writeFileSync(path.join(_options!.cwd as string, "pnpm-lock.yaml"), "resolved checkout lockfile\n");
}
if (args.includes("--frozen-lockfile")) {
expect(fs.readFileSync(path.join(_options!.cwd as string, "pnpm-lock.yaml"), "utf8")).toBe("resolved checkout lockfile\n");
}
if (args.includes("pack")) {
const destination = args[args.indexOf("--pack-destination") + 1];
const packageDir = args[args.indexOf("--dir") + 1];
@@ -137,14 +164,32 @@ describe("managed install commands", () => {
}
if (file === "bash") return { stdout: "", stderr: "" };
if (file === "npm" && args[0] === "pack") {
const packageName = args[1]?.includes("workspace-package-") ? "paperclipai-db" : "paperclipai";
const packageName = args[1]?.includes("workspace-package-")
? (JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string }).name.replace("@", "").replace("/", "-")
: "paperclipai";
if (packageName === "paperclipai-server") {
expect(fs.readFileSync(path.join(args[1], "ui-dist", "index.html"), "utf8"))
.toBe("<html>Built from this Git checkout</html>");
expect(fs.readFileSync(path.join(args[1], "skills", "paperclip", "SKILL.md"), "utf8"))
.toBe("Skill from this Git checkout\n");
expect(fs.readFileSync(path.join(args[1], "dist", "index.js"), "utf8"))
.toBe("export const builtServer = true;\n");
}
fs.writeFileSync(path.join(args[args.indexOf("--pack-destination") + 1], `${packageName}-0.3.1.tgz`), "package");
return { stdout: "", stderr: "" };
}
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; const packageRoot = path.join(prefix, "node_modules", "paperclipai"); fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "0.3.1" })); fs.writeFileSync(path.join(packageRoot, "dist", "index.js"), "#!/usr/bin/env node\n"); return { stdout: "", stderr: "" }; }
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) {
const packageJson = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string; version: string; files?: string[] };
fs.mkdirSync(args[2], { recursive: true });
fs.writeFileSync(path.join(args[2], "package.json"), JSON.stringify({ name: "@paperclipai/db", version: "0.3.1" }));
if (packageJson.name === "@paperclipai/server") {
// Match the real bundler's complete declared-file copy. Missing
// runtime assets must fail before packing an incomplete payload.
for (const entry of packageJson.files ?? []) {
fs.cpSync(path.join(args[1], entry), path.join(args[2], entry), { recursive: true });
}
}
fs.writeFileSync(path.join(args[2], "package.json"), JSON.stringify(packageJson));
return { stdout: "", stderr: "" };
}
if (file === process.execPath) return { stdout: "0.3.1\n", stderr: "" };
@@ -160,7 +205,17 @@ describe("managed install commands", () => {
expect(manifest).toMatchObject({ source: "git", repo: "HenkDz/paperclip", ref: "master", sha });
expect(manifest?.payloadPath).toContain(path.join("git", sha.slice(0, 12)));
expect(runCommand.mock.calls.filter(([command, args]) => command === "curl" && args.includes("--output"))).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install")).toHaveLength(1);
const pnpmInstallCalls = runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install");
expect(pnpmInstallCalls.map(([, args]) => args)).toEqual([
["pnpm", "install", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"],
["pnpm", "install", "--frozen-lockfile"],
]);
const checkoutPath = runCommand.mock.calls.find(([command]) => command === "tar")?.[1].at(-1);
expect(checkoutPath).toContain(path.join("installs", "git", `.${sha.slice(0, 12)}.tmp-`));
for (const [, , options] of pnpmInstallCalls) expect(options?.cwd).toBe(checkoutPath);
expect(runCommand.mock.calls.indexOf(pnpmInstallCalls[1])).toBeLessThan(
runCommand.mock.calls.findIndex(([command]) => command === "bash"),
);
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args.includes("pack"))).toHaveLength(2);
expect(runCommand.mock.calls.filter(([command, args]) => command === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs"))).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "npm" && args[0] === "pack")).toHaveLength(2);
@@ -168,6 +223,367 @@ describe("managed install commands", () => {
expect(installCall?.[1].filter((arg) => arg.endsWith(".tgz"))).toHaveLength(4);
});
it("cleans a failed Git checkout lockfile resolution and preserves the active install", async () => {
const previousSha = "1".repeat(40);
await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) });
const paths = resolveInstallStorePaths();
const previousManifest = fs.readFileSync(paths.manifestPath, "utf8");
const previousTarget = fs.readlinkSync(paths.currentPath);
const previousShim = fs.readFileSync(paths.shimPath, "utf8");
const sha = "2".repeat(40);
const checkoutCommands = createGitCheckoutRunCommand(sha);
let failedCheckout: string | undefined;
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
if (file === "corepack" && args.includes("--resolution-only")) {
failedCheckout = options?.cwd as string;
expect(fs.existsSync(paths.lockPath)).toBe(true);
fs.writeFileSync(path.join(failedCheckout, "pnpm-lock.yaml"), "partial failed resolution\n");
throw new Error("dependency lockfile resolution failed");
}
return checkoutCommands(file, args, options);
});
await expect(installCommand({ ref: sha, yes: true }, { runCommand }))
.rejects.toThrow("dependency lockfile resolution failed");
expect(failedCheckout).toBeDefined();
expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false);
expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]);
expect(runCommand.mock.calls.some(([, args]) => args.includes("--frozen-lockfile"))).toBe(false);
expect(runCommand.mock.calls.some(([file]) => file === "bash" || file === "npm")).toBe(false);
expect(fs.existsSync(paths.lockPath)).toBe(false);
expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest);
expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget);
expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim);
expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true);
});
it("builds bundled Git server UI from its checkout before staging the declared files", async () => {
process.env.NODE_ENV = "production";
const sha = "3".repeat(40);
const runCommand = createGitCheckoutRunCommand(sha, { bundledServer: true });
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths()))
.resolves.toMatchObject({ version: "0.3.1", reused: false });
const uiCalls = runCommand.mock.calls.filter(([file, args]) => file === "corepack" && args.includes("prepare:ui-dist"));
expect(uiCalls).toHaveLength(1);
expect(uiCalls[0][1]).toEqual(["pnpm", "--dir", "server", "prepare:ui-dist"]);
expect(uiCalls[0][2]?.env).not.toHaveProperty("NODE_ENV");
const serverStage = runCommand.mock.calls.findIndex(([file, args]) =>
file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs") && args[1]?.endsWith(path.sep + "server"));
expect(serverStage).toBeGreaterThan(runCommand.mock.calls.indexOf(uiCalls[0]));
expect(runCommand.mock.calls[serverStage][2]?.cwd).toBe(uiCalls[0][2]?.cwd);
});
it("packs the built Git stage without source-only hooks and preserves consumer install hooks", async () => {
const sha = "9".repeat(40);
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
let packedFiles: string[] = [];
let negativeControlPassed = false;
const npmEnv = {
...process.env,
PATH: ORIGINAL_ENV.PATH,
npm_config_cache: path.join(root, "npm-cache"),
npm_config_userconfig: path.join(root, ".npmrc"),
npm_config_offline: "true",
npm_config_ignore_scripts: "false",
};
fs.writeFileSync(npmEnv.npm_config_userconfig, "");
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs") && args[1]?.endsWith(path.sep + "server")) {
const result = await checkoutCommands(file, args, options);
const stagedManifest = path.join(args[2], "package.json");
const packageJson = JSON.parse(fs.readFileSync(stagedManifest, "utf8"));
// The real bundler preserves scripts but stages only declared runtime files.
packageJson.scripts = {
prepack: "node ../scripts/source-prepack.cjs",
postpack: "node ../scripts/source-postpack.cjs",
postinstall: "node dist/consumer-install.cjs",
};
packageJson.dependencies["@paperclipai/db"] = "0.3.1";
fs.writeFileSync(stagedManifest, JSON.stringify(packageJson));
fs.writeFileSync(path.join(args[2], "dist", "consumer-install.cjs"), "process.stdout.write('consumer hook retained');\n");
for (const hook of ["source-prepack", "source-postpack"]) {
fs.writeFileSync(path.join(options!.cwd as string, "scripts", `${hook}.cjs`), "process.stdout.write('source workspace hook');\n");
}
return result;
}
if (file === "npm" && args[0] === "pack" && args[1]?.includes("workspace-package-")) {
const manifest = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")) as { name: string };
if (manifest.name === "@paperclipai/server") {
const realOptions = { ...options, env: npmEnv, timeout: 20_000 };
// Removing the lifecycle boundary must reproduce the actual staged
// packaging failure, without any provider, build, or network access.
await expect(runCommandWithDiagnostics("npm", [...args.filter((arg) => arg !== "--ignore-scripts"), "--json"], realOptions))
.rejects.toThrow("source-prepack.cjs");
negativeControlPassed = true;
const result = await runCommandWithDiagnostics("npm", [...args, "--json"], realOptions);
const packed = JSON.parse(result.stdout) as Array<{ files: Array<{ path: string }> }>;
packedFiles = packed[0].files.map((entry) => entry.path);
expect(fs.existsSync(path.join(args[1], "ui-dist", "index.html"))).toBe(true);
expect(JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8")).scripts.postinstall)
.toBe("node dist/consumer-install.cjs");
return result;
}
}
return checkoutCommands(file, args, options);
});
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths()))
.resolves.toMatchObject({ version: "0.3.1", reused: false });
expect(negativeControlPassed).toBe(true);
expect(packedFiles).toEqual(expect.arrayContaining([
"dist/index.js", "dist/consumer-install.cjs", "ui-dist/index.html",
"skills/paperclip/SKILL.md", "skills/paperclip/scripts/helper.mjs",
]));
const consumerInstall = runCommand.mock.calls.find(([file, args]) => file === "npm" && args[0] === "install");
expect(consumerInstall?.[1]).not.toContain("--ignore-scripts");
expect(consumerInstall?.[2]?.env?.npm_config_ignore_scripts).toBeUndefined();
const sourcePack = runCommand.mock.calls.find(([file, args]) => file === "npm" && args[0] === "pack" && args[1] === "--pack-destination");
expect(sourcePack?.[1]).not.toContain("--ignore-scripts");
});
it("installs mixed-version Git packages offline with the dependency's own SDK version and consumer hooks", async () => {
const sha = "f".repeat(40);
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
const { materializePublishManifest } = await import(new URL("../../../scripts/prepare-bundled-package.mjs", import.meta.url).href);
const npmEnv = {
...process.env,
PATH: ORIGINAL_ENV.PATH,
npm_config_cache: path.join(root, "npm-cache"),
npm_config_userconfig: path.join(root, ".npmrc"),
npm_config_offline: "true",
npm_config_ignore_scripts: "false",
};
fs.writeFileSync(npmEnv.npm_config_userconfig, "");
let checkout: string;
let consumerInstalled = false;
let packedSdkReferences: Record<string, string> = {};
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
if (file === "tar") {
const result = await checkoutCommands(file, args, options);
checkout = args[args.indexOf("-C") + 1];
const sdk = path.join(checkout, "packages/plugins/sdk");
fs.mkdirSync(path.join(sdk, "dist"), { recursive: true });
fs.writeFileSync(path.join(sdk, "dist/index.cjs"), "module.exports.ownVersion = '1.0.0';\n");
fs.writeFileSync(path.join(sdk, "prepare.cjs"), "require('node:fs').writeFileSync('source-pack-hook.txt', 'source hook ran');\n");
fs.writeFileSync(path.join(sdk, "package.json"), JSON.stringify({
name: "@paperclipai/plugin-sdk", version: "1.0.0", main: "dist/index.cjs", files: ["dist"],
dependencies: { "@paperclipai/shared": "0.3.1" }, scripts: { prepack: "node prepare.cjs" },
}));
const manifestPath = path.join(checkout, "scripts/release-package-manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
manifest.push({ dir: "packages/plugins/sdk", name: "@paperclipai/plugin-sdk" });
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
const serverManifestPath = path.join(checkout, "server/package.json");
const serverManifest = JSON.parse(fs.readFileSync(serverManifestPath, "utf8"));
serverManifest.dependencies["@paperclipai/plugin-sdk"] = "workspace:*";
serverManifest.dependencies["@paperclipai/shared"] = "~0.3.0";
serverManifest.optionalDependencies = { "@paperclipai/db": "workspace:~" };
serverManifest.peerDependencies = { "@paperclipai/plugin-sdk": "workspace:^" };
fs.writeFileSync(serverManifestPath, JSON.stringify(serverManifest));
const dbManifestPath = path.join(checkout, "packages/db/package.json");
const dbManifest = JSON.parse(fs.readFileSync(dbManifestPath, "utf8"));
dbManifest.version = "0.2.0";
fs.writeFileSync(dbManifestPath, JSON.stringify(dbManifest));
fs.mkdirSync(path.join(checkout, "cli/dist"), { recursive: true });
fs.writeFileSync(path.join(checkout, "cli/dist/index.js"), "console.log('0.3.1');\n");
fs.writeFileSync(path.join(checkout, "cli/package.json"), JSON.stringify({
name: "paperclipai", version: "0.3.1", files: ["dist"], dependencies: { "@paperclipai/server": "0.3.1" },
}));
return result;
}
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) {
const result = await checkoutCommands(file, args, options);
const stagedManifestPath = path.join(args[2], "package.json");
// Use the real release manifest translator, including its owning-package
// workspace normalization. The installer must correct the Git-only case.
const manifest = materializePublishManifest(JSON.parse(fs.readFileSync(stagedManifestPath, "utf8")));
if (manifest.name === "@paperclipai/server") {
manifest.scripts = {
prepack: "node ../scripts/source-prepack-does-not-exist.cjs",
postpack: "node ../scripts/source-postpack-does-not-exist.cjs",
postinstall: "node dist/consumer-install.cjs",
};
fs.writeFileSync(path.join(args[2], "dist/consumer-install.cjs"),
"require('node:fs').writeFileSync('consumer-hook.txt', require('@paperclipai/plugin-sdk').ownVersion);\n");
}
fs.writeFileSync(stagedManifestPath, JSON.stringify(manifest));
return result;
}
if (file === "corepack" && args.includes("pack")) {
const source = path.join(checkout, args[args.indexOf("--dir") + 1]);
return runCommandWithDiagnostics("npm", ["pack", "--pack-destination", args[args.indexOf("--pack-destination") + 1], "--json"],
{ cwd: source, env: npmEnv, timeout: 20_000 });
}
if (file === "npm" && args[0] === "pack") {
if (args[1]?.includes("workspace-package-")) {
const manifest = JSON.parse(fs.readFileSync(path.join(args[1], "package.json"), "utf8"));
if (manifest.name === "@paperclipai/server") {
packedSdkReferences = Object.fromEntries(["dependencies", "optionalDependencies", "peerDependencies"]
.map((section) => [section, manifest[section]["@paperclipai/plugin-sdk"]]));
}
}
return runCommandWithDiagnostics(file, [...args, "--json"], { ...options, env: npmEnv, timeout: 20_000 });
}
if (file === "npm" && args[0] === "install") {
expect(args).not.toContain("--ignore-scripts");
expect(args.filter((arg) => arg.endsWith(".tgz"))).toHaveLength(5);
expect(args.some((arg) => arg.endsWith("paperclipai-plugin-sdk-1.0.0.tgz"))).toBe(true);
expect(fs.readFileSync(path.join(checkout, "packages/plugins/sdk/source-pack-hook.txt"), "utf8")).toBe("source hook ran");
const sourceManifest = JSON.parse(fs.readFileSync(path.join(checkout, "server/package.json"), "utf8"));
expect(sourceManifest.dependencies["@paperclipai/plugin-sdk"]).toBe("workspace:*");
expect(sourceManifest.optionalDependencies["@paperclipai/db"]).toBe("workspace:~");
expect(sourceManifest.peerDependencies["@paperclipai/plugin-sdk"]).toBe("workspace:^");
let result;
try {
result = await runCommandWithDiagnostics(file, args, { ...options, env: npmEnv, timeout: 20_000 });
} catch (error) {
throw new Error(`Offline consumer failed with SDK 1.0.0 archive and packed SDK references ${JSON.stringify(packedSdkReferences)}: ${String(error)}`, { cause: error });
}
const installedServer = path.join(args[args.indexOf("--prefix") + 1], "node_modules/@paperclipai/server");
const installed = JSON.parse(fs.readFileSync(path.join(installedServer, "package.json"), "utf8"));
expect(installed.dependencies["@paperclipai/plugin-sdk"]).toBe("1.0.0");
expect(installed.dependencies["@paperclipai/shared"]).toBe("~0.3.0");
expect(installed.dependencies["@paperclipai/db"]).toBe("0.2.0");
expect(installed.optionalDependencies["@paperclipai/db"]).toBe("~0.2.0");
expect(installed.peerDependencies["@paperclipai/plugin-sdk"]).toBe("^1.0.0");
expect(fs.readFileSync(path.join(installedServer, "consumer-hook.txt"), "utf8")).toBe("1.0.0");
consumerInstalled = true;
return result;
}
if (file === process.execPath && args.at(-1) === "--version") {
return runCommandWithDiagnostics(file, args, { ...options, env: npmEnv, timeout: 20_000 });
}
return checkoutCommands(file, args, options);
});
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths()))
.resolves.toMatchObject({ version: "0.3.1", reused: false });
expect(consumerInstalled).toBe(true);
}, 30_000);
it.each(["source build", "staged pack", "consumer install"])("preserves the active Git install after a failed %s", async (phase) => {
const previousSha = "a".repeat(40);
await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) });
const paths = resolveInstallStorePaths();
const previousManifest = fs.readFileSync(paths.manifestPath, "utf8");
const previousTarget = fs.readlinkSync(paths.currentPath);
const previousShim = fs.readFileSync(paths.shimPath, "utf8");
const sha = "e".repeat(40);
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
let failedCheckout: string | undefined;
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
if (file === "tar") failedCheckout = args[args.indexOf("-C") + 1];
const failedPhase = phase === "source build"
? file === "corepack" && args.includes("-r") && args.includes("build")
: phase === "staged pack"
? file === "npm" && args[0] === "pack" && args[1]?.includes("workspace-package-")
: file === "npm" && args[0] === "install";
if (failedPhase) throw new Error(`${phase} failed`);
return checkoutCommands(file, args, options);
});
await expect(installCommand({ ref: sha, yes: true }, { runCommand })).rejects.toThrow(`${phase} failed`);
expect(failedCheckout).toBeDefined();
expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false);
expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]);
if (phase === "source build") expect(runCommand.mock.calls.some(([file]) => file === "npm")).toBe(false);
if (phase === "staged pack") expect(runCommand.mock.calls.some(([file, args]) => file === "npm" && args[0] === "install")).toBe(false);
expect(fs.existsSync(paths.lockPath)).toBe(false);
expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest);
expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget);
expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim);
expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true);
});
it("stages same-checkout runtime skills in the release packages before bundled Git packaging", async () => {
const sha = "6".repeat(40);
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
let checkedSkills = false;
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) {
const checkout = options!.cwd as string;
for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) {
const skills = path.join(checkout, packageDir, "skills");
expect(fs.readdirSync(skills)).toEqual(["paperclip"]);
for (const file of ["SKILL.md", "scripts/helper.mjs"]) {
expect(fs.readFileSync(path.join(skills, "paperclip", file), "utf8"))
.toBe(fs.readFileSync(path.join(checkout, "skills", "paperclip", file), "utf8"));
}
}
// Other adapters' optional skills keep their established pack behavior.
expect(fs.existsSync(path.join(checkout, "packages/adapters/cursor-local/skills"))).toBe(false);
checkedSkills = true;
}
return checkoutCommands(file, args, options);
});
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths()))
.resolves.toMatchObject({ version: "0.3.1", reused: false });
expect(checkedSkills).toBe(true);
});
it("cleans missing Git runtime skills and preserves the active installed payload", async () => {
const previousSha = "7".repeat(40);
await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) });
const paths = resolveInstallStorePaths();
const previousManifest = fs.readFileSync(paths.manifestPath, "utf8");
const previousTarget = fs.readlinkSync(paths.currentPath);
const previousShim = fs.readFileSync(paths.shimPath, "utf8");
const sha = "8".repeat(40);
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
let failedCheckout: string | undefined;
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
const result = await checkoutCommands(file, args, options);
if (file === "tar") {
failedCheckout = args[args.indexOf("-C") + 1];
fs.rmSync(path.join(failedCheckout, "skills"), { recursive: true });
}
return result;
});
await expect(installCommand({ ref: sha, yes: true }, { runCommand }))
.rejects.toThrow(/ENOENT.*skills/);
expect(failedCheckout).toBeDefined();
expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false);
expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]);
expect(runCommand.mock.calls.some(([file, args]) => file === "npm" || (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")))).toBe(false);
expect(fs.existsSync(paths.lockPath)).toBe(false);
expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest);
expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget);
expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim);
expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true);
});
it("cleans a failed Git UI build and preserves the active installed payload", async () => {
const previousSha = "4".repeat(40);
await installCommand({ ref: previousSha, yes: true }, { runCommand: createGitCheckoutRunCommand(previousSha) });
const paths = resolveInstallStorePaths();
const previousManifest = fs.readFileSync(paths.manifestPath, "utf8");
const previousTarget = fs.readlinkSync(paths.currentPath);
const previousShim = fs.readFileSync(paths.shimPath, "utf8");
const sha = "5".repeat(40);
const checkoutCommands = createGitCheckoutRunCommand(sha, { bundledServer: true });
let failedCheckout: string | undefined;
const runCommand = vi.fn(async (file: string, args: string[], options?: Parameters<CommandRunner>[2]) => {
if (file === "corepack" && args.includes("prepare:ui-dist")) {
failedCheckout = options?.cwd as string;
throw new Error("UI build failed");
}
return checkoutCommands(file, args, options);
});
await expect(installCommand({ ref: sha, yes: true }, { runCommand })).rejects.toThrow("UI build failed");
expect(failedCheckout).toBeDefined();
expect(fs.existsSync(path.dirname(failedCheckout!))).toBe(false);
expect(fs.readdirSync(path.join(paths.installsRoot, "git"))).toEqual([previousSha.slice(0, 12)]);
expect(runCommand.mock.calls.some(([file]) => file === "npm")).toBe(false);
expect(fs.existsSync(paths.lockPath)).toBe(false);
expect(fs.readFileSync(paths.manifestPath, "utf8")).toBe(previousManifest);
expect(fs.readlinkSync(paths.currentPath)).toBe(previousTarget);
expect(fs.readFileSync(paths.shimPath, "utf8")).toBe(previousShim);
expect(fs.existsSync(readInstallManifest(paths)!.payloadPath)).toBe(true);
});
it("builds git checkouts with NODE_ENV cleared so ambient production mode keeps devDependencies", async () => {
process.env.NODE_ENV = "production";
const sha = "d".repeat(40);
@@ -178,7 +594,7 @@ describe("managed install commands", () => {
file === "corepack" ||
(file === "npm" && args[0] === "pack") ||
(file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")));
expect(buildCalls).toHaveLength(9);
expect(buildCalls).toHaveLength(11);
for (const call of buildCalls) {
const env = call[2]?.env;
expect(env, `${call[0]} ${call[1].join(" ")} must run with an explicit env`).toBeDefined();
+44 -2
View File
@@ -275,19 +275,61 @@ export async function installGitPayload(repo: string, sha: string, runCommand: C
await runGitHubCurl(["--fail", "--silent", "--show-error", "--location", "--output", archivePath, `https://codeload.github.com/${repo}/tar.gz/${sha}`], runCommand, { maxBuffer: 4 * 1024 * 1024 });
await runCommand("tar", ["-xzf", archivePath, "--strip-components=1", "-C", checkoutPath], { maxBuffer: 4 * 1024 * 1024 });
await runCommand("corepack", ["enable", "pnpm", "--install-directory", pnpmShimDir], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 4 * 1024 * 1024 });
// Exact source commits can precede the lockfile bot. Resolve only inside this
// downloaded checkout, without lifecycle scripts, before its frozen install.
await runCommand("corepack", ["pnpm", "install", "--resolution-only", "--ignore-scripts", "--no-frozen-lockfile"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "install", "--frozen-lockfile"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
await runCommand("bash", ["scripts/build-npm.sh", "--skip-checks", "--skip-typecheck"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "-r", "--filter", "@paperclipai/server...", "--if-present", "run", "build"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
// Bundled package staging bypasses the server's prepack hook. Produce its
// declared UI files from this same checkout before staging that package.
await runCommand("corepack", ["pnpm", "--dir", "server", "prepare:ui-dist"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
// Match release.sh's runtime-skill preparation. These packages declare
// skills separately from their build output; bundled staging bypasses it.
for (const packageDir of ["server", "packages/adapters/claude-local", "packages/adapters/codex-local"]) {
const destination = path.join(checkoutPath, packageDir, "skills");
fs.rmSync(destination, { recursive: true, force: true });
fs.cpSync(path.join(checkoutPath, "skills"), destination, { recursive: true });
}
const metadata = JSON.parse(fs.readFileSync(path.join(checkoutPath, "cli", "package.json"), "utf8")) as { version: string };
const workspacePackages = resolveGitInstallWorkspacePackages(checkoutPath);
const workspaceVersions = new Map(workspacePackages.map((entry) => {
const packageJson = JSON.parse(fs.readFileSync(path.join(checkoutPath, entry.dir, "package.json"), "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || !EXACT_VERSION_PATTERN.test(packageJson.version)) {
throw new Error(`Git install cannot stage ${entry.name}: its package version must be an exact version.`);
}
return [entry.name, packageJson.version] as const;
}));
for (const [index, workspacePackage] of workspacePackages.entries()) {
const packageDir = path.join(checkoutPath, workspacePackage.dir);
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as { bundleDependencies?: string[]; bundledDependencies?: string[] };
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as {
bundleDependencies?: string[]; bundledDependencies?: string[];
dependencies?: Record<string, string>; optionalDependencies?: Record<string, string>; peerDependencies?: Record<string, string>;
};
const bundledDependencies = packageJson.bundleDependencies ?? packageJson.bundledDependencies ?? [];
if (bundledDependencies.length > 0) {
const stagedPackage = path.join(stagingRoot, `workspace-package-${index}`);
await runCommand(process.execPath, [path.join(checkoutPath, "scripts", "prepare-bundled-package.mjs"), packageDir, stagedPackage], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 16 * 1024 * 1024 });
// Release packaging gives workspace packages one version. A Git checkout
// can retain independent versions, so bind each staged workspace reference
// to that dependency's actual package rather than the owning package.
const stagedManifestPath = path.join(stagedPackage, "package.json");
const stagedManifest = JSON.parse(fs.readFileSync(stagedManifestPath, "utf8")) as typeof packageJson;
for (const section of ["dependencies", "optionalDependencies", "peerDependencies"] as const) {
for (const [name, specifier] of Object.entries(packageJson[section] ?? {})) {
if (!specifier.startsWith("workspace:")) continue;
const version = workspaceVersions.get(name);
if (!version) throw new Error(`Git install cannot stage workspace dependency ${name}: its package version is missing.`);
const range = specifier.slice("workspace:".length);
const prefix = range === "^" || range === "~" ? range : "";
stagedManifest[section] = { ...stagedManifest[section], [name]: `${prefix}${version}` };
}
}
fs.writeFileSync(stagedManifestPath, `${JSON.stringify(stagedManifest, null, 2)}\n`);
// The stage contains compiled runtime files, not the source workspace.
// Match release packaging: source-only prepack/postpack hooks must not
// rebuild it or remove its prepared assets. Consumer install hooks stay enabled.
await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot, "--ignore-scripts"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 16 * 1024 * 1024 });
} else {
await runCommand("corepack", ["pnpm", "--dir", workspacePackage.dir, "pack", "--pack-destination", stagingRoot], { cwd: checkoutPath, env: buildEnv({ PAPERCLIP_RELEASE_REUSE_UI_DIST: "1" }), maxBuffer: 32 * 1024 * 1024 });
}