Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
writeJsonError,
writeJsonSuccess,
} from "./shell/output";
import { canonicalizeWindowsPathKey } from "./shell/path-env";
import { disposePromptState } from "./shell/prompt";
import {
type CliRuntime,
Expand All @@ -37,6 +38,10 @@ export interface RunCliOptions extends Partial<CliRuntime> {
}

export async function runCli(options: RunCliOptions = {}): Promise<number> {
// 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;
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/shell/path-env.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
106 changes: 106 additions & 0 deletions packages/cli/tests/path-env.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
85 changes: 85 additions & 0 deletions packages/cli/tests/path-env.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
Loading