diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts index bfc2461239a..4c194cdf340 100644 --- a/cli/src/__tests__/install-command.test.ts +++ b/cli/src/__tests__/install-command.test.ts @@ -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[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 = [ diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 68376cb3118..8be743ca1bc 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -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);