diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 04dec58..b33a219 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -23,6 +23,7 @@ import { writeJsonError, writeJsonSuccess, } from "./shell/output"; +import { canonicalizeWindowsPathKey } from "./shell/path-env"; import { disposePromptState } from "./shell/prompt"; import { type CliRuntime, @@ -37,6 +38,10 @@ export interface RunCliOptions extends Partial { } export async function runCli(options: RunCliOptions = {}): Promise { + // Normalize the Windows `Path` key before any command spawns a subprocess + // off process.env (see canonicalizeWindowsPathKey). + canonicalizeWindowsPathKey(process.env); + const runtime = resolveRuntime(options); const program = createProgram(runtime); process.exitCode = 0; diff --git a/packages/cli/src/shell/path-env.ts b/packages/cli/src/shell/path-env.ts new file mode 100644 index 0000000..9a1e313 --- /dev/null +++ b/packages/cli/src/shell/path-env.ts @@ -0,0 +1,40 @@ +/** + * Collapses case-variant spellings of `PATH` into the canonical `PATH` key, in + * place. No-op outside Windows. + * + * Windows stores the variable as `Path` and resolves env lookups + * case-insensitively, so `process.env.PATH` reads it fine — but spreading + * `process.env` into a plain object drops that case-insensitivity. The compute + * SDK does exactly that to prepend `node_modules/.bin` for a local build, so + * its `PATH` read misses the inherited `Path`, truncating it until the spawned + * build can no longer resolve `bun`/`next`. Normalizing up front keeps the + * spread correct; the rename is invisible because Windows ignores the casing. + */ +export function canonicalizeWindowsPathKey( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== "win32") { + return; + } + + const variants = Object.keys(env).filter( + (key) => key.toUpperCase() === "PATH" && key !== "PATH", + ); + if (variants.length === 0) { + return; + } + + // `env.PATH` resolves the canonical key and, on the real process.env, `Path` + // too; sort the remaining variants so the fallback never depends on key + // insertion order. + const value = env.PATH ?? env[[...variants].sort()[0]]; + for (const variant of variants) { + delete env[variant]; + } + // The deletes above also clear the case-insensitive `PATH` on the real + // process.env, so write the canonical key back. + if (value !== undefined) { + env.PATH = value; + } +} diff --git a/packages/cli/tests/path-env.integration.test.ts b/packages/cli/tests/path-env.integration.test.ts new file mode 100644 index 0000000..1ff2e1c --- /dev/null +++ b/packages/cli/tests/path-env.integration.test.ts @@ -0,0 +1,106 @@ +import { spawn } from "node:child_process"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { canonicalizeWindowsPathKey } from "../src/shell/path-env"; + +// Guards the whole chain the fix protects: process.env -> spread -> PATH +// rewrite -> spawned child. The compute SDK builds its local-build subprocess +// this way, so on Windows the truncated PATH left the spawned build unable to +// find `bun`; here a real child must still resolve a binary on the inherited +// path. + +const isWindows = process.platform === "win32"; + +// Mirrors the compute SDK's `buildCommandEnv`: spread the inherited env, then +// rebuild PATH with bin dirs prepended — the step that silently truncated PATH +// when the inherited key was the Windows-native `Path`. +function buildCommandEnvLikeSdk( + baseEnv: NodeJS.ProcessEnv, + binDirs: string[], +): NodeJS.ProcessEnv { + const spread = { ...baseEnv }; + return { + ...spread, + PATH: [...binDirs, spread.PATH].filter(Boolean).join(path.delimiter), + }; +} + +function runCommandInShell( + command: string, + env: NodeJS.ProcessEnv, + cwd: string, +): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(command, [], { cwd, env, shell: true }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => resolve({ code, stdout, stderr })); + }); +} + +describe("local build env resolves binaries (integration)", () => { + let workdir: string; + let binDir: string; + const binaryName = "faux-bun"; + + beforeEach(async () => { + workdir = await mkdtemp(path.join(os.tmpdir(), "prisma-path-env-")); + binDir = path.join(workdir, "tools"); + await mkdir(binDir, { recursive: true }); + + if (isWindows) { + // A .cmd shim, as a real installer drops on Windows. + await writeFile( + path.join(binDir, `${binaryName}.cmd`), + "@echo faux-bun-ran\r\n", + ); + } else { + const script = path.join(binDir, binaryName); + await writeFile(script, "#!/bin/sh\necho faux-bun-ran\n"); + await chmod(script, 0o755); + } + }); + + afterEach(async () => { + await rm(workdir, { recursive: true, force: true }); + }); + + it("spawns a child that finds a binary on the inherited path after the SDK-style rewrite", async () => { + // Put the binary dir on PATH under the OS-native key: `Path` on Windows + // (where the bug lives), canonical `PATH` elsewhere. + const baseEnv: NodeJS.ProcessEnv = { ...process.env }; + const pathKey = isWindows ? "Path" : "PATH"; + const existingPath = process.env.PATH ?? ""; + delete baseEnv.PATH; + delete baseEnv.Path; + baseEnv[pathKey] = [binDir, existingPath] + .filter(Boolean) + .join(path.delimiter); + + canonicalizeWindowsPathKey(baseEnv); + + // Prepend an (empty) node_modules/.bin as the SDK does, forcing the PATH + // rewrite that dropped inherited entries in the reported failure. + const childEnv = buildCommandEnvLikeSdk(baseEnv, [ + path.join(workdir, "node_modules", ".bin"), + ]); + + const result = await runCommandInShell(binaryName, childEnv, workdir); + + expect(result.stdout.trim()).toBe("faux-bun-ran"); + expect(result.code).toBe(0); + }); +}); diff --git a/packages/cli/tests/path-env.test.ts b/packages/cli/tests/path-env.test.ts new file mode 100644 index 0000000..fd3119f --- /dev/null +++ b/packages/cli/tests/path-env.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; + +import { canonicalizeWindowsPathKey } from "../src/shell/path-env"; + +describe("canonicalizeWindowsPathKey", () => { + it("renames the Windows registry-cased Path key to PATH", () => { + const env: NodeJS.ProcessEnv = { + Path: "C:\\Windows\\System32;C:\\Users\\flo\\.bun\\bin", + ComSpec: "C:\\Windows\\System32\\cmd.exe", + }; + + canonicalizeWindowsPathKey(env, "win32"); + + expect(Object.keys(env).sort()).toEqual(["ComSpec", "PATH"]); + expect(env.PATH).toBe("C:\\Windows\\System32;C:\\Users\\flo\\.bun\\bin"); + }); + + it("keeps a spread of the env readable and rewritable via env.PATH", () => { + // The reported failure mode: the compute SDK spreads process.env into a + // plain object and rebuilds PATH from `baseEnv.PATH`. With the Windows + // `Path` casing that read is undefined, so the rebuilt PATH loses every + // inherited entry and the spawned `bun run build` cannot find bun. + const env: NodeJS.ProcessEnv = { Path: "C:\\Users\\flo\\.bun\\bin" }; + + canonicalizeWindowsPathKey(env, "win32"); + const spread = { ...env }; + + expect(spread.PATH).toBe("C:\\Users\\flo\\.bun\\bin"); + expect( + Object.keys(spread).filter((key) => key.toUpperCase() === "PATH"), + ).toEqual(["PATH"]); + }); + + it("collapses multiple case variants without dropping the value", () => { + const env: NodeJS.ProcessEnv = { + path: "ignored", + PATH: "C:\\kept", + }; + + canonicalizeWindowsPathKey(env, "win32"); + + expect(env).toEqual({ PATH: "C:\\kept" }); + }); + + it("picks the same variant regardless of insertion order", () => { + // Several non-canonical spellings and no `PATH`: the sorted fallback must + // pick deterministically rather than following Object.keys order. "PaTH" + // sorts before "path" (uppercase precedes lowercase), so it wins both ways. + const first: NodeJS.ProcessEnv = { path: "C:\\other", PaTH: "C:\\kept" }; + const second: NodeJS.ProcessEnv = { PaTH: "C:\\kept", path: "C:\\other" }; + + canonicalizeWindowsPathKey(first, "win32"); + canonicalizeWindowsPathKey(second, "win32"); + + expect(first).toEqual({ PATH: "C:\\kept" }); + expect(second).toEqual({ PATH: "C:\\kept" }); + }); + + it("leaves a canonical PATH untouched", () => { + const env: NodeJS.ProcessEnv = { PATH: "C:\\Windows\\System32" }; + + canonicalizeWindowsPathKey(env, "win32"); + + expect(env).toEqual({ PATH: "C:\\Windows\\System32" }); + }); + + it("does nothing when no path variable exists", () => { + const env: NodeJS.ProcessEnv = { HOME: "C:\\Users\\flo" }; + + canonicalizeWindowsPathKey(env, "win32"); + + expect(env).toEqual({ HOME: "C:\\Users\\flo" }); + }); + + it("is a no-op outside Windows, where casing is significant", () => { + const env: NodeJS.ProcessEnv = { + Path: "/opt/custom", + PATH: "/usr/bin:/bin", + }; + + canonicalizeWindowsPathKey(env, "linux"); + + expect(env).toEqual({ Path: "/opt/custom", PATH: "/usr/bin:/bin" }); + }); +});