fix(install): build git checkouts hermetically against NODE_ENV=production

installGitPayload's in-checkout pnpm install inherited ambient
NODE_ENV=production, so pnpm omitted devDependencies (esbuild) and
build-npm.sh failed with ERR_MODULE_NOT_FOUND. Strip NODE_ENV from the
env of every build/pack command run inside the checkout so source
installs succeed on hosts that export NODE_ENV=production globally
(PAP-15205).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
committed 2026-07-27 15:32:36 +00:00
1 parent 8c0ee1fc8c
commit 88408b2884
2 files changed
+42 -10

No files matched your search

+27 -3
View File
@@ -3,6 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
type CommandRunner,
installCommand,
installGitPayload,
resolveGitHubRef,
@@ -104,9 +105,8 @@ describe("managed install commands", () => {
expect(runCommand.mock.calls[0]?.[0]).toBe(process.execPath);
});
it("installs a GitHub branch through codeload and reuses the resolved SHA", async () => {
const sha = "c".repeat(40);
const runCommand = vi.fn(async (file: string, args: string[]) => {
const createGitCheckoutRunCommand = (sha: string) =>
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: "" }; }
if (file === "tar") {
@@ -150,6 +150,10 @@ describe("managed install commands", () => {
if (file === process.execPath) return { stdout: "0.3.1\n", stderr: "" };
throw new Error(`Unexpected command: ${file} ${args.join(" ")}`);
});
it("installs a GitHub branch through codeload and reuses the resolved SHA", async () => {
const sha = "c".repeat(40);
const runCommand = createGitCheckoutRunCommand(sha);
await installCommand({ ref: "master", repo: "HenkDz/paperclip", yes: true }, { runCommand });
await installCommand({ ref: "master", repo: "HenkDz/paperclip", yes: true }, { runCommand });
const manifest = readInstallManifest(resolveInstallStorePaths());
@@ -164,6 +168,26 @@ describe("managed install commands", () => {
expect(installCall?.[1].filter((arg) => arg.endsWith(".tgz"))).toHaveLength(4);
});
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);
const runCommand = createGitCheckoutRunCommand(sha);
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths())).resolves.toMatchObject({ version: "0.3.1", reused: false });
const buildCalls = runCommand.mock.calls.filter(([file, args]) =>
file === "bash" ||
file === "corepack" ||
(file === "npm" && args[0] === "pack") ||
(file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")));
expect(buildCalls).toHaveLength(8);
for (const call of buildCalls) {
const env = call[2]?.env;
expect(env, `${call[0]} ${call[1].join(" ")} must run with an explicit env`).toBeDefined();
expect(env, `${call[0]} ${call[1].join(" ")} must not inherit NODE_ENV`).not.toHaveProperty("NODE_ENV");
}
const uiPackCall = buildCalls.find(([file, , options]) => file === "corepack" && options?.env?.PAPERCLIP_RELEASE_REUSE_UI_DIST === "1");
expect(uiPackCall).toBeDefined();
});
it("resolves the complete server workspace dependency closure in dependency order", () => {
const checkout = path.join(root, "checkout");
const packages = [
+15 -7
View File
@@ -213,6 +213,14 @@ export async function installNpmPayload(
}
}
function gitBuildEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
const env = { ...process.env, ...extra };
// Source builds need devDependencies (esbuild, typescript); ambient NODE_ENV=production
// makes pnpm/npm omit them, so the checkout build must not inherit it.
delete env.NODE_ENV;
return env;
}
export async function installGitPayload(repo: string, sha: string, runCommand: CommandRunner, paths = resolveInstallStorePaths()): Promise<{ payloadPath: string; reused: boolean; version: string }> {
const identifier = sha.slice(0, 12);
const payloadPath = payloadPathFor(paths, "git", identifier);
@@ -239,9 +247,9 @@ export async function installGitPayload(repo: string, sha: string, runCommand: C
try {
await runCommand("curl", ["--fail", "--silent", "--show-error", "--location", "--output", archivePath, `https://codeload.github.com/${repo}/tar.gz/${sha}`], { maxBuffer: 4 * 1024 * 1024 });
await runCommand("tar", ["-xzf", archivePath, "--strip-components=1", "-C", checkoutPath], { maxBuffer: 4 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "install", "--frozen-lockfile"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 });
await runCommand("bash", ["scripts/build-npm.sh", "--skip-checks", "--skip-typecheck"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "-r", "--filter", "@paperclipai/server...", "--if-present", "run", "build"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "install", "--frozen-lockfile"], { cwd: checkoutPath, env: gitBuildEnv(), maxBuffer: 32 * 1024 * 1024 });
await runCommand("bash", ["scripts/build-npm.sh", "--skip-checks", "--skip-typecheck"], { cwd: checkoutPath, env: gitBuildEnv(), maxBuffer: 32 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "-r", "--filter", "@paperclipai/server...", "--if-present", "run", "build"], { cwd: checkoutPath, env: gitBuildEnv(), maxBuffer: 32 * 1024 * 1024 });
const metadata = JSON.parse(fs.readFileSync(path.join(checkoutPath, "cli", "package.json"), "utf8")) as { version: string };
const workspacePackages = resolveGitInstallWorkspacePackages(checkoutPath);
for (const [index, workspacePackage] of workspacePackages.entries()) {
@@ -250,13 +258,13 @@ export async function installGitPayload(repo: string, sha: string, runCommand: C
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, maxBuffer: 32 * 1024 * 1024 });
await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot], { cwd: checkoutPath, maxBuffer: 16 * 1024 * 1024 });
await runCommand(process.execPath, [path.join(checkoutPath, "scripts", "prepare-bundled-package.mjs"), packageDir, stagedPackage], { cwd: checkoutPath, env: gitBuildEnv(), maxBuffer: 32 * 1024 * 1024 });
await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot], { cwd: checkoutPath, env: gitBuildEnv(), maxBuffer: 16 * 1024 * 1024 });
} else {
await runCommand("corepack", ["pnpm", "--dir", workspacePackage.dir, "pack", "--pack-destination", stagingRoot], { cwd: checkoutPath, env: { ...process.env, PAPERCLIP_RELEASE_REUSE_UI_DIST: "1" }, maxBuffer: 32 * 1024 * 1024 });
await runCommand("corepack", ["pnpm", "--dir", workspacePackage.dir, "pack", "--pack-destination", stagingRoot], { cwd: checkoutPath, env: gitBuildEnv({ PAPERCLIP_RELEASE_REUSE_UI_DIST: "1" }), maxBuffer: 32 * 1024 * 1024 });
}
}
await runCommand("npm", ["pack", "--pack-destination", stagingRoot], { cwd: path.join(checkoutPath, "cli"), maxBuffer: 16 * 1024 * 1024 });
await runCommand("npm", ["pack", "--pack-destination", stagingRoot], { cwd: path.join(checkoutPath, "cli"), env: gitBuildEnv(), maxBuffer: 16 * 1024 * 1024 });
const tarballs = fs.readdirSync(stagingRoot).filter((entry) => entry.endsWith(".tgz"));
const cliTarball = tarballs.find((entry) => entry === `paperclipai-${metadata.version}.tgz`);
const workspaceTarballs = tarballs.filter((entry) => entry !== cliTarball);