diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 0d1dce4917..1c0e029a16 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- macOS executable discovery now honors explicit `PATH` and `cwd` lookup overrides instead of silently searching the process environment. + ## [0.12.7] - 2026-07-31 ## [0.12.6] - 2026-07-31 diff --git a/packages/utils/src/which.ts b/packages/utils/src/which.ts index e1facff596..160e0f2ce9 100644 --- a/packages/utils/src/which.ts +++ b/packages/utils/src/which.ts @@ -183,9 +183,10 @@ export interface WhichOptions extends Bun.WhichOptions { // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH. // Uses cached directory listings instead of per-command existsSync or xcrun subprocesses. -function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null { - const regular = Bun.which(command); +function darwinWhich(command: string, options?: Bun.WhichOptions): string | null { + const regular = Bun.which(command, options); if (regular) return regular; + if (options?.PATH !== undefined) return null; if (isXcodeBin(command)) { return getMacosToolPaths().get(command) ?? null; } @@ -198,10 +199,10 @@ export const whichFresh = os.platform() === "darwin" ? darwinWhich : Bun.which; // Derive stable cache key from command and lookup options function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey { if (!options) return command; - if (!options.cwd && !options.PATH) return command; + if (options.cwd === undefined && options.PATH === undefined) return command; let h = Bun.hash(command); - if (options.cwd) h = Bun.hash(options.cwd, h); - if (options.PATH) h = Bun.hash(options.PATH, h); + if (options.cwd !== undefined) h = Bun.hash(`cwd:${options.cwd}`, h); + if (options.PATH !== undefined) h = Bun.hash(`PATH:${options.PATH}`, h); return h; } diff --git a/packages/utils/test/which.test.ts b/packages/utils/test/which.test.ts new file mode 100644 index 0000000000..2730812a47 --- /dev/null +++ b/packages/utils/test/which.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { TempDir } from "../src/temp"; +import { $which, WhichCachePolicy } from "../src/which"; + +describe.skipIf(process.platform === "win32")("$which lookup options", () => { + it("honors explicit PATH and cwd overrides without cache collisions", async () => { + const tempDir = await TempDir.create(); + try { + const command = "gjc-which-option-test"; + const executable = tempDir.join(command); + await Bun.write(executable, "#!/bin/sh\nexit 0\n"); + await fs.chmod(executable, 0o755); + + expect($which(command, { PATH: tempDir.path(), cache: WhichCachePolicy.Fresh })).toBe(executable); + expect($which(command, { PATH: "", cwd: tempDir.path(), cache: WhichCachePolicy.Cached })).toBeNull(); + expect( + $which(`.${path.sep}${command}`, { + PATH: "", + cwd: tempDir.path(), + cache: WhichCachePolicy.Bypass, + }), + ).toBe(executable); + expect($which("clang", { PATH: tempDir.path(), cache: WhichCachePolicy.Bypass })).toBeNull(); + } finally { + await tempDir.remove(); + } + }); +});