Files
Xinmin ZengandGitHub 2455c70d51 fix(daemon, packaged): unbreak GUI-launched agent detection on minimal PATHs (#442) (#614)
* fix(daemon, packaged): unbreak GUI-launched agent detection on minimal PATHs (#442)

GUI-launched daemons (Finder/Dock on macOS, .desktop on Linux) inherit a
stripped PATH from launchd / the desktop session and don't read the
user's interactive shell rc files, so any CLI installed via `npm i -g`
under a sudo-free prefix like ~/.npm-global was silently undetected.

Two layers maintained their own copies of the user-toolchain bin list
(`apps/daemon/src/agents.ts:userToolchainDirs` for the resolver,
`apps/packaged/src/sidecars.ts:resolvePackagedPathEnv` for the packaged
sidecar PATH builder) and had already drifted on `~/.asdf/shims` and
`~/Library/pnpm`. Adding ~/.npm-global to one side would have
preserved the same anti-pattern.

Extracts `wellKnownUserToolchainBins` into @open-design/platform as the
single source of truth, has both layers consume it, and extends the
list to cover ~/.npm-global/bin, ~/.npm-packages/bin, plus
$NPM_CONFIG_PREFIX/bin / $npm_config_prefix/bin for users with a
non-standard prefix. New vitest coverage in the platform package and
a regression test in apps/daemon/tests/agents.test.ts modelled on the
existing mise case.

Verified end-to-end: under PATH=/usr/bin:/bin:/usr/sbin:/sbin (the
launchd default a `.app` actually inherits), `resolveAgentExecutable`
now returns ~/.npm-global/bin/gemini instead of null.

* fix(daemon): isolate OD_AGENT_HOME resolution from $NPM_CONFIG_PREFIX leakage

Address review feedback on PR #614:

- mrcfps spotted that the daemon wrapper called wellKnownUserToolchainBins
  without passing `env`, so the helper read its default process.env. A
  developer or CI runner with NPM_CONFIG_PREFIX / npm_config_prefix
  exported would inject that real <prefix>/bin into resolveOnPath() even
  while the OD_AGENT_HOME hook pointed home at a temp fixture, making
  agent-detection tests environment-dependent. Reproduced locally: with
  OD_AGENT_HOME=<tmp> + NPM_CONFIG_PREFIX=/Users/me/.npm-global,
  resolveAgentExecutable({ bin: 'codex' }) returned the real machine's
  binary instead of null. Wrapper now passes `env: {}` whenever
  homeOverride is set, alongside the existing includeSystemBins gate.

- lefarcen suggested also handling whitespace-only NPM_CONFIG_PREFIX
  values (e.g. NPM_CONFIG_PREFIX=" ") so the helper does not emit a
  bogus "<whitespace>/bin" entry. Added a .trim() check before
  appending.

- lefarcen also suggested a comment pointer from the daemon wrapper to
  the platform helper so readers don't have to grep. Added the
  reference inline.

Coverage:
- packages/platform/tests/index.test.ts: new whitespace-prefix case.
- apps/daemon/tests/agents.test.ts: new env-isolation regression
  asserting that OD_AGENT_HOME + NPM_CONFIG_PREFIX cannot leak the
  real prefix bin into the sandbox.

* test(daemon): preserve $NPM_CONFIG_PREFIX across the env-isolation case (#614)

Address mrcfps's second-round review on PR #614: the env-isolation
regression sets `process.env.NPM_CONFIG_PREFIX = realPrefix` in its
body and then unconditionally `delete`s it in `finally`. On a developer
machine or CI runner that already exported `NPM_CONFIG_PREFIX`, that
mutates the worker-wide env for every later test, making downstream
env-sensitive assertions order-dependent.

Move the save/restore into the file's existing afterEach hook (mirroring
the OD_AGENT_HOME / OD_DAEMON_URL / OD_TOOL_TOKEN pattern) and drop the
in-test `delete`. Same coverage, no worker-state mutation.

* fix(platform): prioritise $NPM_CONFIG_PREFIX over the conventional npm guesses (#614)

Address mrcfps's third-round review on PR #614: when the user has
explicitly configured a prefix via $NPM_CONFIG_PREFIX (or
$npm_config_prefix), that's where `npm i -g` puts the *current*
binaries. The conventional guesses ~/.npm-global / ~/.npm-packages
often hold *stale* installs from an older prefix the user has since
rewritten — searching the env-driven prefix first matches npm's own
resolution order (env > .npmrc > default) and gives "explicit beats
convention" semantics.

Move the env-driven push above the conventional `dirs.push(.npm-global,
.npm-packages)`. Add a vitest case in the platform package that asserts
$NPM_CONFIG_PREFIX/bin's index in the result is strictly less than
~/.npm-global/bin's and ~/.npm-packages/bin's.

`resolveOnPath()` and the packaged PATH builder both preserve insertion
order, so first hit wins and the new ordering propagates to both
layers.

* fix(platform): lift $NPM_CONFIG_PREFIX above every conventional bin (#614)

Address mrcfps's fourth-round review on PR #614: the previous fix only
moved $NPM_CONFIG_PREFIX/bin ahead of ~/.npm-global / ~/.npm-packages,
but ~/.local/bin still appeared earlier in the array. Under a minimal
GUI-launch PATH a stale agent in ~/.local/bin (also a shared dumping
ground for pip --user / cargo install / hand-built binaries) could
outrank the user's *current* explicit npm prefix.

Move the env-driven push to the head of `dirs` so the explicit prefix
wins over every conventional location below — ~/.local/bin included.
Matches npm's own resolution order (env > .npmrc > default) across the
whole list, not just the npm-prefix block.

Tightened the existing order test to assert `explicitIdx === 0` and
that ~/.local/bin's index is strictly greater than the explicit
prefix's index, so a future drift would fail loudly.
2026-05-06 20:35:19 +08:00

499 lines
20 KiB
TypeScript

import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
createCommandInvocation,
createPackageManagerInvocation,
createProcessStampArgs,
matchesStampedProcess,
readProcessStampFromCommand,
wellKnownUserToolchainBins,
type ProcessStampContract,
} from "../src/index.js";
type FakeStamp = {
app: "api" | "ui";
ipc: string;
mode: "dev" | "runtime";
namespace: string;
source: "tool" | "pack";
};
const fakeContract: ProcessStampContract<FakeStamp> = {
stampFields: ["app", "mode", "namespace", "ipc", "source"],
stampFlags: {
app: "--fake-app",
ipc: "--fake-ipc",
mode: "--fake-mode",
namespace: "--fake-namespace",
source: "--fake-source",
},
normalizeStamp(input) {
const value = input as Partial<FakeStamp>;
if (value.app !== "api" && value.app !== "ui") throw new Error("invalid app");
if (value.mode !== "dev" && value.mode !== "runtime") throw new Error("invalid mode");
if (typeof value.namespace !== "string" || value.namespace.length === 0) throw new Error("invalid namespace");
if (typeof value.ipc !== "string" || value.ipc.length === 0) throw new Error("invalid ipc");
if (value.source !== "tool" && value.source !== "pack") throw new Error("invalid source");
return {
app: value.app,
ipc: value.ipc,
mode: value.mode,
namespace: value.namespace,
source: value.source,
};
},
normalizeStampCriteria(input = {}) {
const value = input as Partial<FakeStamp>;
return {
...(value.app == null ? {} : { app: value.app }),
...(value.ipc == null ? {} : { ipc: value.ipc }),
...(value.mode == null ? {} : { mode: value.mode }),
...(value.namespace == null ? {} : { namespace: value.namespace }),
...(value.source == null ? {} : { source: value.source }),
};
},
};
const stamp: FakeStamp = {
app: "ui",
ipc: "/tmp/fake-product/ipc/stamp-boundary-a/ui.sock",
mode: "dev",
namespace: "stamp-boundary-a",
source: "tool",
};
describe("generic process stamp primitives", () => {
it("serializes descriptor-defined stamp flags", () => {
const args = createProcessStampArgs(stamp, fakeContract);
expect(args).toHaveLength(5);
expect(args.join(" ")).toContain("--fake-app=ui");
expect(args.join(" ")).toContain("--fake-mode=dev");
expect(args.join(" ")).toContain("--fake-namespace=stamp-boundary-a");
expect(args.join(" ")).toContain("--fake-ipc=/tmp/fake-product/ipc/stamp-boundary-a/ui.sock");
expect(args.join(" ")).toContain("--fake-source=tool");
});
it("reads and matches stamped process commands using the descriptor", () => {
const command = ["node", "ui.js", ...createProcessStampArgs(stamp, fakeContract)].join(" ");
expect(readProcessStampFromCommand(command, fakeContract)).toEqual(stamp);
expect(matchesStampedProcess({ command }, { app: "ui", namespace: stamp.namespace, source: "tool" }, fakeContract)).toBe(true);
expect(matchesStampedProcess({ command }, { namespace: "stamp-boundary-b" }, fakeContract)).toBe(false);
expect(matchesStampedProcess({ command }, { source: "pack" }, fakeContract)).toBe(false);
});
});
// `createCommandInvocation` makes a platform-conditional choice based on
// `process.platform`. These tests stub it both ways so we exercise the
// Windows .cmd / .bat shim path on every CI runner, not just Windows.
describe("createCommandInvocation", () => {
const originalPlatform = process.platform;
function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, "platform", { configurable: true, value });
}
afterEach(() => {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
});
it("returns the raw command and args unchanged on POSIX", () => {
setPlatform("linux");
const invocation = createCommandInvocation({
command: "/usr/local/bin/codex",
args: ["--help"],
});
expect(invocation).toEqual({
args: ["--help"],
command: "/usr/local/bin/codex",
});
expect(invocation.windowsVerbatimArguments).toBeUndefined();
});
it("returns the raw command and args unchanged on Windows for non-shim binaries", () => {
setPlatform("win32");
const invocation = createCommandInvocation({
command: "C:\\Program Files\\node\\node.exe",
args: ["script.js"],
});
expect(invocation).toEqual({
args: ["script.js"],
command: "C:\\Program Files\\node\\node.exe",
});
expect(invocation.windowsVerbatimArguments).toBeUndefined();
});
it("wraps a Windows .CMD shim through cmd.exe with verbatim arguments", () => {
setPlatform("win32");
const invocation = createCommandInvocation({
command: "C:\\Users\\Ethical Byte\\AppData\\Local\\Programs\\nodejs\\codex.CMD",
args: ["--version"],
env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" } as NodeJS.ProcessEnv,
});
expect(invocation.command).toBe("C:\\Windows\\System32\\cmd.exe");
expect(invocation.windowsVerbatimArguments).toBe(true);
// Critical: the inner command line is wrapped in extra `"…"` so that
// cmd.exe's `/s /c` quote-stripping (strip first + last `"`) leaves the
// path quoting intact. Without the outer wrap, `Ethical Byte` gets
// split on the space and cmd reports "not recognized" (issue #315).
expect(invocation.args).toEqual([
"/d",
"/s",
"/c",
'""C:\\Users\\Ethical Byte\\AppData\\Local\\Programs\\nodejs\\codex.CMD" --version"',
]);
});
it("treats .bat shims the same as .cmd shims", () => {
setPlatform("win32");
const invocation = createCommandInvocation({
command: "C:\\tools\\bin\\my tool.bat",
args: [],
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
});
expect(invocation.windowsVerbatimArguments).toBe(true);
expect(invocation.args).toEqual(["/d", "/s", "/c", '""C:\\tools\\bin\\my tool.bat""']);
});
it("quotes argv elements containing spaces alongside the shim path", () => {
setPlatform("win32");
const invocation = createCommandInvocation({
command: "C:\\Users\\First Last\\codex.cmd",
args: ["--cwd", "C:\\Some Path\\proj", "exec", "echo hi"],
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
});
// After the outer wrap and `/s /c` stripping, cmd will see:
// "C:\Users\First Last\codex.cmd" --cwd "C:\Some Path\proj" exec "echo hi"
expect(invocation.args).toEqual([
"/d",
"/s",
"/c",
'""C:\\Users\\First Last\\codex.cmd" --cwd "C:\\Some Path\\proj" exec "echo hi""',
]);
});
it("does not quote argv elements without whitespace or shell metacharacters", () => {
setPlatform("win32");
const invocation = createCommandInvocation({
command: "codex.cmd",
args: ["--model", "claude-opus-4", "--max-tokens=4096"],
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
});
expect(invocation.args).toEqual([
"/d",
"/s",
"/c",
'"codex.cmd --model claude-opus-4 --max-tokens=4096"',
]);
});
// cmd.exe runs percent-expansion on the inner command line of `cmd /s /c
// "..."` regardless of inner quote state, so a `.cmd` shim spawn whose
// argv carries an attacker-influenced `%DEEPSEEK_API_KEY%` substring would
// otherwise have the daemon environment substituted into the child's
// command line before the child saw the prompt. Pin that the constructed
// invocation breaks every potential `%var%` pair with `"^%"` so cmd has no
// chance to expand it, while `CommandLineToArgvW` still concatenates the
// surrounding quote segments back into the original arg.
it("escapes %var% sequences in argv so cmd.exe cannot expand them on a .cmd shim", () => {
setPlatform("win32");
const invocation = createCommandInvocation({
command: "C:\\Users\\Tester\\AppData\\Roaming\\npm\\deepseek.cmd",
args: ["exec", "--auto", "write a function that reads %DEEPSEEK_API_KEY% from env"],
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
});
expect(invocation.command).toBe("cmd.exe");
expect(invocation.windowsVerbatimArguments).toBe(true);
// The full inner line cmd.exe receives after `/s` strips its outer wrap.
const innerLine = invocation.args[3];
if (typeof innerLine !== "string") throw new Error("expected an inner cmd line");
// The literal `%DEEPSEEK_API_KEY%` pair must NOT survive intact in the
// inner line — if it did, cmd would expand it before the child runs.
expect(innerLine).not.toContain("%DEEPSEEK_API_KEY%");
// Each `%` must be wrapped in `"^%"` so cmd's `^` escape neutralizes the
// percent and `CommandLineToArgvW` rejoins the quote segments. Two `%`
// chars in the prompt → two escaped occurrences.
const escapedOccurrences = innerLine.split('"^%"').length - 1;
expect(escapedOccurrences).toBe(2);
// Sanity: the literal env-var name still appears (the prompt itself is
// not corrupted, only the surrounding `%` are escaped).
expect(innerLine).toContain("DEEPSEEK_API_KEY");
});
it("does not perturb argv quoting when no %var% sequence is present", () => {
setPlatform("win32");
const invocation = createCommandInvocation({
command: "deepseek.cmd",
args: ["exec", "--auto", "write hello world"],
env: { ComSpec: "cmd.exe" } as NodeJS.ProcessEnv,
});
// Pre-fix shape — adding the `%` escape must not change the line for
// ordinary prompts that happen not to mention env-var names.
expect(invocation.args).toEqual([
"/d",
"/s",
"/c",
'"deepseek.cmd exec --auto "write hello world""',
]);
});
it("falls back to process.env.ComSpec when env override is absent", () => {
setPlatform("win32");
const original = process.env.ComSpec;
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
try {
const invocation = createCommandInvocation({
command: "tool.cmd",
args: [],
});
expect(invocation.command).toBe("C:\\Windows\\System32\\cmd.exe");
} finally {
if (original == null) delete process.env.ComSpec;
else process.env.ComSpec = original;
}
});
});
describe("createPackageManagerInvocation", () => {
const originalPlatform = process.platform;
function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, "platform", { configurable: true, value });
}
afterEach(() => {
Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform });
});
it("uses npm_execpath via process.execPath when set, regardless of platform", () => {
setPlatform("win32");
const invocation = createPackageManagerInvocation(["install"], {
npm_execpath: "C:\\Users\\u\\.nvm\\pnpm.cjs",
} as NodeJS.ProcessEnv);
expect(invocation.command).toBe(process.execPath);
expect(invocation.args[0]).toBe("C:\\Users\\u\\.nvm\\pnpm.cjs");
expect(invocation.args.slice(1)).toEqual(["install"]);
expect(invocation.windowsVerbatimArguments).toBeUndefined();
});
it("returns plain pnpm invocation on POSIX without npm_execpath", () => {
setPlatform("linux");
const invocation = createPackageManagerInvocation(["install"], {} as NodeJS.ProcessEnv);
expect(invocation).toEqual({ args: ["install"], command: "pnpm" });
});
it("wraps pnpm through cmd.exe with verbatim arguments on Windows", () => {
setPlatform("win32");
const invocation = createPackageManagerInvocation(["--filter", "@open-design/desktop", "build"], {
ComSpec: "cmd.exe",
} as NodeJS.ProcessEnv);
expect(invocation.command).toBe("cmd.exe");
expect(invocation.windowsVerbatimArguments).toBe(true);
expect(invocation.args).toEqual([
"/d",
"/s",
"/c",
'"pnpm --filter @open-design/desktop build"',
]);
});
});
describe("wellKnownUserToolchainBins", () => {
// Filesystem-backed cases use a sandboxed home so we don't depend on the
// real machine's toolchain layout. PATHEXT-style Windows quirks aren't
// relevant here — the helper returns directories, not resolved binaries.
it("returns the documented user-level CLI install locations under home", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-home-"));
try {
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
expect(dirs).toContain(join(home, ".local", "bin"));
expect(dirs).toContain(join(home, ".opencode", "bin"));
expect(dirs).toContain(join(home, ".bun", "bin"));
expect(dirs).toContain(join(home, ".volta", "bin"));
expect(dirs).toContain(join(home, ".asdf", "shims"));
expect(dirs).toContain(join(home, "Library", "pnpm"));
expect(dirs).toContain(join(home, ".cargo", "bin"));
} finally {
rmSync(home, { recursive: true, force: true });
}
});
// Regression for #442. The two dominant non-canonical npm prefixes used
// by sudo-free tutorials (~/.npm-global, ~/.npm-packages) must always
// appear, otherwise GUI-launched daemons miss `npm i -g`'d CLIs.
it("includes both ~/.npm-global/bin and ~/.npm-packages/bin (issue #442)", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-npm-"));
try {
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
expect(dirs).toContain(join(home, ".npm-global", "bin"));
expect(dirs).toContain(join(home, ".npm-packages", "bin"));
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("appends $NPM_CONFIG_PREFIX/bin when set so corporate prefixes resolve", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-prefix-"));
const customPrefix = mkdtempSync(join(tmpdir(), "wkutb-custom-"));
try {
const dirs = wellKnownUserToolchainBins({
home,
env: { NPM_CONFIG_PREFIX: customPrefix },
includeSystemBins: false,
});
expect(dirs).toContain(join(customPrefix, "bin"));
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(customPrefix, { recursive: true, force: true });
}
});
it("falls back to lower-case npm_config_prefix when NPM_CONFIG_PREFIX is absent", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-prefix-lc-"));
const customPrefix = mkdtempSync(join(tmpdir(), "wkutb-custom-lc-"));
try {
const dirs = wellKnownUserToolchainBins({
home,
env: { npm_config_prefix: customPrefix },
includeSystemBins: false,
});
expect(dirs).toContain(join(customPrefix, "bin"));
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(customPrefix, { recursive: true, force: true });
}
});
it("does not append a prefix entry when neither env var is set", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-noprefix-"));
try {
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
// The bare `/bin` suffix would be ambiguous, but we can at least
// confirm nothing equal to "/bin" leaked in from a `join(undefined,
// "bin")`-style bug.
expect(dirs).not.toContain("/bin");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
// PR #614 review (mrcfps): npm's own resolution order is env > .npmrc
// > default, so when the user has explicitly configured a prefix via
// $NPM_CONFIG_PREFIX, that location holds the *current* `npm i -g`
// installs and should outrank every conventional location below —
// including ~/.local/bin (which is also a shared pip --user / cargo
// install dumping ground). Conventional locations frequently retain
// *stale* binaries from an older prefix.
it("places $NPM_CONFIG_PREFIX/bin before every conventional location, including ~/.local/bin", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-prefix-order-"));
const customPrefix = mkdtempSync(join(tmpdir(), "wkutb-custom-order-"));
try {
const dirs = wellKnownUserToolchainBins({
home,
env: { NPM_CONFIG_PREFIX: customPrefix },
includeSystemBins: false,
});
const explicitIdx = dirs.indexOf(join(customPrefix, "bin"));
const localBinIdx = dirs.indexOf(join(home, ".local", "bin"));
const npmGlobalIdx = dirs.indexOf(join(home, ".npm-global", "bin"));
const npmPackagesIdx = dirs.indexOf(join(home, ".npm-packages", "bin"));
// Explicit prefix must be present and ahead of every conventional
// sibling. The first hit wins inside resolveOnPath() and the
// packaged PATH builder, so this ordering propagates verbatim.
expect(explicitIdx).toBe(0);
expect(localBinIdx).toBeGreaterThan(explicitIdx);
expect(npmGlobalIdx).toBeGreaterThan(explicitIdx);
expect(npmPackagesIdx).toBeGreaterThan(explicitIdx);
} finally {
rmSync(home, { recursive: true, force: true });
rmSync(customPrefix, { recursive: true, force: true });
}
});
it("ignores whitespace-only npm prefix values rather than emitting a `/bin` entry", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-whitespace-prefix-"));
try {
const dirs = wellKnownUserToolchainBins({
home,
env: { NPM_CONFIG_PREFIX: " " },
includeSystemBins: false,
});
// Whitespace-only must not produce a bogus `<whitespace>/bin` entry
// nor a bare `/bin` (the join(" ", "bin") shape).
for (const dir of dirs) {
expect(dir.trim()).not.toBe("/bin");
expect(dir).not.toMatch(/^\s+\/bin$/);
}
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("includes /opt/homebrew/bin and /usr/local/bin when includeSystemBins is true", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-sys-"));
try {
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: true });
expect(dirs).toContain("/opt/homebrew/bin");
expect(dirs).toContain("/usr/local/bin");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("omits /opt/homebrew/bin and /usr/local/bin when includeSystemBins is false", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-nosys-"));
try {
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
expect(dirs).not.toContain("/opt/homebrew/bin");
expect(dirs).not.toContain("/usr/local/bin");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("expands per-version Node toolchains for mise / nvm / fnm", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-versioned-"));
try {
const miseBin = join(home, ".local", "share", "mise", "installs", "node", "24.14.1", "bin");
const nvmBin = join(home, ".nvm", "versions", "node", "v22.10.0", "bin");
const fnmBin = join(home, ".local", "share", "fnm", "node-versions", "v20.11.1", "installation", "bin");
mkdirSync(miseBin, { recursive: true });
mkdirSync(nvmBin, { recursive: true });
mkdirSync(fnmBin, { recursive: true });
writeFileSync(join(miseBin, "marker"), "");
writeFileSync(join(nvmBin, "marker"), "");
writeFileSync(join(fnmBin, "marker"), "");
chmodSync(join(miseBin, "marker"), 0o644);
chmodSync(join(nvmBin, "marker"), 0o644);
chmodSync(join(fnmBin, "marker"), 0o644);
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
expect(dirs).toContain(miseBin);
expect(dirs).toContain(nvmBin);
expect(dirs).toContain(fnmBin);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("returns an empty version slice when toolchain root is absent", () => {
const home = mkdtempSync(join(tmpdir(), "wkutb-empty-"));
try {
const dirs = wellKnownUserToolchainBins({ home, env: {}, includeSystemBins: false });
// No mise/nvm/fnm directories were created — none of the per-version
// bins should appear.
expect(dirs.some((dir) => dir.includes(join(".nvm", "versions", "node")))).toBe(false);
expect(dirs.some((dir) => dir.includes(join("fnm", "node-versions")))).toBe(false);
expect(dirs.some((dir) => dir.includes(join("mise", "installs", "node")))).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});