From 26a26662dd8d6ad07e73abb08e3efba6fb7cf9e3 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Fri, 15 May 2026 03:33:54 -0700 Subject: [PATCH] fix(windows): route npm/npx through node + cli.js to survive uv_spawn platform.exec ultimately calls libuv's uv_spawn, which on Windows: 1. does not consult PATHEXT, so spawning 'npm' fails with 'ENOENT: uv_spawn npm' because the on-disk file is npm.cmd, and 2. since Node >=18.20.2 (CVE-2024-27980) hard-rejects spawning .cmd and .bat shims without shell: true, which ExecOptions does not expose. Wrapping in cmd.exe /d /s /c needs windowsVerbatimArguments to be safe, which we also don't control. Instead, resolve npm and npx to the same 'node ...args' invocation their .cmd shims use internally. node.exe is a plain binary that libuv spawns cleanly. Threaded through every existing call site: - src/bootstrap.ts background version check - src/commands/plan.ts visual-companion install (the user-visible failure on /supi:plan) - src/commands/doctor.ts npm presence + ping - src/commands/update.ts npm view / install, bun + npm fallback, deps installer lambda - src/harness/anti_slop/fallow.ts npx --no-install fallow Other binaries (bun, git, node, gh, rustup, go, pip, uv) ship as .exe and pass through untouched. POSIX always passes through. Tests cover the rewrite, the pass-through, the wrapExecForCli adapter, and the fallback when npm-cli.js is missing. Existing update / fallow tests normalize captured calls so they stay stable on both platforms. --- src/bootstrap.ts | 3 +- src/commands/doctor.ts | 5 +- src/commands/plan.ts | 3 +- src/commands/update.ts | 12 +- src/harness/anti_slop/fallow-adapter.ts | 7 +- src/utils/exec-cli.ts | 106 +++++++++++++ tests/commands/update.test.ts | 13 +- .../harness/anti_slop/fallow-adapter.test.ts | 8 +- tests/helpers/exec-calls.ts | 48 ++++++ tests/utils/exec-cli.test.ts | 141 ++++++++++++++++++ 10 files changed, 329 insertions(+), 17 deletions(-) create mode 100644 src/utils/exec-cli.ts create mode 100644 tests/helpers/exec-calls.ts create mode 100644 tests/utils/exec-cli.test.ts diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 86dd567..15364e9 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -12,6 +12,7 @@ import { registerAiReviewCommand, handleAiReview } from "./commands/ai-review.js import { registerQaCommand } from "./commands/qa.js"; import { registerReleaseCommand, handleRelease } from "./commands/release.js"; import { registerUpdateCommand, handleUpdate } from "./commands/update.js"; +import { execCli } from "./utils/exec-cli.js"; import { registerDoctorCommand, handleDoctor } from "./commands/doctor.js"; import { registerModelCommand, handleModel } from "./commands/model.js"; import { registerFixPrCommand } from "./commands/fix-pr.js"; @@ -175,7 +176,7 @@ export function bootstrap(platform: Platform): void { const currentVersion = getInstalledVersion(platform); if (!currentVersion) return; - platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() }) + execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() }) .then((result) => { if (result.code !== 0) return; const latest = result.stdout.trim(); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index a5e2dfe..2b5e1a4 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -11,6 +11,7 @@ import { formatReliabilitySection, loadReliabilitySummaries } from "../storage/r import { getMetricsStore, getSessionId } from "../context-mode/hooks.js"; import { getProjectStatePath, getProjectStateDir } from "../workspace/state-paths.js"; import { basename } from "node:path"; +import { execCli } from "../utils/exec-cli.js"; export interface CheckResult { name: string; @@ -435,14 +436,14 @@ export function checkMetrics( export async function checkNpm(platform: Platform): Promise { try { - const vResult = await platform.exec("npm", ["--version"]); + const vResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["--version"]); if (vResult.code !== 0) { return { name: "npm", presence: { ok: false, detail: "npm not found" } }; } const version = vResult.stdout.trim(); const presence = { ok: true, detail: `v${version}` }; - const pingResult = await platform.exec("npm", ["ping"]); + const pingResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["ping"]); if (pingResult.code === 0) { return { name: "npm", presence, functional: { ok: true, detail: "Registry reachable" } }; } diff --git a/src/commands/plan.ts b/src/commands/plan.ts index e55087b..2d5e8cd 100644 --- a/src/commands/plan.ts +++ b/src/commands/plan.ts @@ -19,6 +19,7 @@ import { loadModelConfig } from "../config/model-config.js"; import { getProjectStatePath } from "../workspace/state-paths.js"; import { cancelPlanTracking, startPlanTracking } from "../planning/approval-flow.js"; import { stopVisualServer } from "../visual/stop-server.js"; +import { execCli } from "../utils/exec-cli.js"; modelRegistry.register({ id: "plan", @@ -111,7 +112,7 @@ export function registerPlanCommand(platform: Platform): void { const nodeModules = path.join(scriptsDir, "node_modules"); if (!fs.existsSync(nodeModules)) { notifyInfo(ctx, "Installing visual companion dependencies..."); - const installResult = await platform.exec("npm", ["install", "--production"], { cwd: scriptsDir }); + const installResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--production"], { cwd: scriptsDir }); if (installResult.code !== 0) { notifyError(ctx, "Failed to install visual companion dependencies", installResult.stderr); debugLogger.log("visual_companion_dependency_install_failed", { diff --git a/src/commands/update.ts b/src/commands/update.ts index e3d3f52..87eea17 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -11,6 +11,7 @@ import { steerMempalaceInitialization, } from "../mempalace/installer-helper.js"; import { loadConfig } from "../config/loader.js"; +import { execCli, wrapExecForCli } from "../utils/exec-cli.js"; // ── Options builder ────────────────────────────────────── @@ -59,7 +60,7 @@ async function updateSupipowers( ctx.ui.notify(`Current version: v${currentVersion}`, "info"); // Check latest version on npm - const checkResult = await platform.exec("npm", ["view", "supipowers", "version"], { cwd: tmpdir() }); + const checkResult = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["view", "supipowers", "version"], { cwd: tmpdir() }); if (checkResult.code !== 0) { ctx.ui.notify("Failed to check for updates — npm view failed", "error"); return null; @@ -78,7 +79,8 @@ async function updateSupipowers( mkdirSync(tempDir, { recursive: true }); try { - const installResult = await platform.exec( + const installResult = await execCli( + (cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--prefix", tempDir, `supipowers@${latestVersion}`], { cwd: tempDir }, ); @@ -130,10 +132,10 @@ async function updateSupipowers( // Install runtime dependencies (handlebars, etc.) // Without this, the extension fails to load because node_modules/ was deleted above. ctx.ui.notify("Installing dependencies...", "info"); - const bunInstall = await platform.exec("bun", ["install", "--production"], { cwd: extDir }); + const bunInstall = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "bun", ["install", "--production"], { cwd: extDir }); if (bunInstall.code !== 0) { // Fallback to npm if bun is not available (e.g. Windows without global bun) - const npmInstall = await platform.exec("npm", ["install", "--omit=dev"], { cwd: extDir }); + const npmInstall = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npm", ["install", "--omit=dev"], { cwd: extDir }); if (npmInstall.code !== 0) { ctx.ui.notify( "Could not install extension dependencies.\n" + @@ -177,7 +179,7 @@ async function updateSupipowers( export function handleUpdate(platform: Platform, ctx: PlatformContext): void { void (async () => { - const exec = (cmd: string, args: string[]) => platform.exec(cmd, args); + const exec = wrapExecForCli((cmd: string, args: string[]) => platform.exec(cmd, args)); // 1. Scan all dependencies const allStatuses = await scanAll(exec); diff --git a/src/harness/anti_slop/fallow-adapter.ts b/src/harness/anti_slop/fallow-adapter.ts index 2461ef7..4ca5c21 100644 --- a/src/harness/anti_slop/fallow-adapter.ts +++ b/src/harness/anti_slop/fallow-adapter.ts @@ -25,6 +25,7 @@ import { type SlopBackendResult, type SlopFinding, } from "./backend.js"; +import { execCli } from "../../utils/exec-cli.js"; const DEFAULT_TIMEOUT_MS = 60_000; @@ -149,7 +150,7 @@ async function resolveInvocation( } try { - const probe = await platform.exec("npx", ["--no-install", "fallow", "--version"], { timeout: 5000 }); + const probe = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), "npx", ["--no-install", "fallow", "--version"], { timeout: 5000 }); if (probe.code === 0) { availabilityCache = { ok: true, via: "npx" }; return { ok: true, cmd: "npx", baseArgs: ["--no-install", "fallow"], via: "npx" }; @@ -187,7 +188,7 @@ async function runFallow( const startedAt = Date.now(); let result; try { - result = await platform.exec(invocation.cmd, args, { + result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, { cwd: opts.cwd, timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, }); @@ -269,7 +270,7 @@ export class FallowAdapter implements SlopBackend { if (opts.subtree) args.push("--path", opts.subtree); try { - const result = await platform.exec(invocation.cmd, args, { + const result = await execCli((cmd, args, opts) => platform.exec(cmd, args, opts), invocation.cmd, args, { cwd: opts.cwd, timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, }); diff --git a/src/utils/exec-cli.ts b/src/utils/exec-cli.ts new file mode 100644 index 0000000..c5da625 --- /dev/null +++ b/src/utils/exec-cli.ts @@ -0,0 +1,106 @@ +import { dirname, join } from "node:path"; +import { existsSync } from "node:fs"; + +import type { ExecOptions, ExecResult } from "../platform/types.js"; +import { findExecutable } from "./executable.js"; + +/** + * Cross-platform invocation for npm/npx that survives Windows `.cmd` shims. + * + * OMP's `platform.exec` is a thin wrapper over libuv's `uv_spawn`. On Windows + * that exposes two distinct bugs when the target is an npm-shipped CLI: + * + * 1. libuv does not consult `PATHEXT`, so spawning `"npm"` fails with + * `ENOENT: uv_spawn 'npm'` because the on-disk file is `npm.cmd`. + * 2. Even when callers resolve the absolute path, Node ≥18.20.2 hard-rejects + * spawning `.cmd`/`.bat` shims without `shell: true` (CVE-2024-27980). + * `ExecOptions` does not expose `shell`. + * + * Wrapping in `cmd.exe /d /s /c` is the canonical workaround, but only safe + * when the spawner sets `windowsVerbatimArguments: true` — Node's default + * CRT escaping double-quotes the command line and cmd's `/s` only strips one + * pair. We don't control the spawner, so we sidestep the whole problem by + * resolving the shim to the real `node ` invocation, which is exactly + * what `npm.cmd` does internally. `node.exe` is a plain binary that libuv + * spawns without ceremony. + * + * Non-shim binaries (`bun`, `git`, `node`, `gh`, `rustup`, `go`, `pip`, … + * all ship as `.exe` on Windows) pass through untouched; POSIX always passes + * through. + */ + +export type ExecFn = ( + cmd: string, + args: string[], + opts?: ExecOptions, +) => Promise; + +interface ResolvedInvocation { + cmd: string; + prefixArgs: string[]; +} + +const NODE_SHIMS = new Set(["npm", "npx"]); +const resolutionCache = new Map(); + +function resolveNodeShim(command: string): ResolvedInvocation | null { + if (!NODE_SHIMS.has(command)) return null; + + // node.exe is the executor; npm-cli.js / npx-cli.js sits next to it under + // node_modules/npm/bin/. We deliberately key off node's location (not the + // shim's) because `npm.cmd` can live in a user-global dir (e.g. nvm, + // %AppData%\npm) while the actual CLI bundle stays alongside node. + const nodeBin = findExecutable("node"); + if (!nodeBin) return null; + + const cliJs = join( + dirname(nodeBin), + "node_modules", + "npm", + "bin", + `${command}-cli.js`, + ); + if (!existsSync(cliJs)) return null; + + return { cmd: nodeBin, prefixArgs: [cliJs] }; +} + +function resolveInvocation(command: string): ResolvedInvocation { + if (process.platform !== "win32") { + return { cmd: command, prefixArgs: [] }; + } + const cached = resolutionCache.get(command); + if (cached) return cached; + + const resolved = resolveNodeShim(command) ?? { cmd: command, prefixArgs: [] }; + resolutionCache.set(command, resolved); + return resolved; +} + +/** + * Drop-in replacement for `platform.exec` callers that invoke npm/npx by name. + * Other commands pass through unchanged. + */ +export function execCli( + exec: ExecFn, + command: string, + args: string[], + opts?: ExecOptions, +): Promise { + const resolved = resolveInvocation(command); + return exec(resolved.cmd, [...resolved.prefixArgs, ...args], opts); +} + +/** + * Wrap an `ExecFn` so every call routes through `execCli`. Use when threading + * an exec callback into helpers that dispatch arbitrary tools by string name + * (e.g. the deps installer which splits install-command strings). + */ +export function wrapExecForCli(exec: ExecFn): ExecFn { + return (cmd, args, opts) => execCli(exec, cmd, args, opts); +} + +/** Test-only: forget cached resolutions between unit-test runs. */ +export function _resetExecCliCacheForTesting(): void { + resolutionCache.clear(); +} diff --git a/tests/commands/update.test.ts b/tests/commands/update.test.ts index 8fa70ab..8c75b21 100644 --- a/tests/commands/update.test.ts +++ b/tests/commands/update.test.ts @@ -8,6 +8,7 @@ import * as path from "node:path"; import { resolveManagedVenvPaths } from "../../src/mempalace/runtime.js"; import { detectUvPlatform, uvTargetFor } from "../../src/mempalace/uv.js"; import { MEMPALACE_PACKAGE_VERSION } from "../../src/mempalace/upstream-limits.js"; +import { normalizeExecCall } from "../helpers/exec-calls.js"; function makeDep(overrides: Partial = {}): DependencyStatus { return { name: "test-tool", @@ -104,7 +105,8 @@ describe("handleUpdate — dependency install", () => { global: (...s: string[]) => path.join(tmpDir, "global", ...s), agent: (...s: string[]) => path.join(tmpDir, "agent", ...s), }, - exec: mock(async (cmd: string, args: string[], opts?: any) => { + exec: mock(async (cmdRaw: string, argsRaw: string[], opts?: any) => { + const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw }); execCalls.push({ cmd, args, opts }); // npm view → return a newer version to trigger update @@ -171,7 +173,8 @@ describe("handleUpdate — dependency install", () => { global: (...s: string[]) => path.join(tmpDir, "global", ...s), agent: (...s: string[]) => path.join(tmpDir, "agent", ...s), }, - exec: mock(async (cmd: string, args: string[], opts?: any) => { + exec: mock(async (cmdRaw: string, argsRaw: string[], opts?: any) => { + const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw }); execCalls.push({ cmd, args, opts }); if (cmd === "npm" && args[0] === "view") { @@ -264,7 +267,8 @@ describe("handleUpdate — MemPalace prompt", () => { test("does not run MemPalace setup when the user picks Skip", async () => { const execCalls: Array<{ cmd: string; args: string[] }> = []; - const platform = platformWithExec(async (cmd, args) => { + const platform = platformWithExec(async (cmdRaw, argsRaw) => { + const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw }); execCalls.push({ cmd, args }); if (cmd === "npm" && args[0] === "view") return { stdout: "2.0.0\n", stderr: "", code: 0 }; if (cmd === "npm" && args.includes("--prefix")) { @@ -317,7 +321,8 @@ describe("handleUpdate — MemPalace prompt", () => { const venv = resolveManagedVenvPaths(venvRoot); const execCalls: Array<{ cmd: string; args: string[] }> = []; - const platform = platformWithExec(async (cmd, args) => { + const platform = platformWithExec(async (cmdRaw, argsRaw) => { + const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw }); execCalls.push({ cmd, args }); if (cmd === "npm" && args[0] === "view") return { stdout: "2.0.0\n", stderr: "", code: 0 }; if (cmd === "npm" && args.includes("--prefix")) { diff --git a/tests/harness/anti_slop/fallow-adapter.test.ts b/tests/harness/anti_slop/fallow-adapter.test.ts index d81f892..d7621aa 100644 --- a/tests/harness/anti_slop/fallow-adapter.test.ts +++ b/tests/harness/anti_slop/fallow-adapter.test.ts @@ -4,6 +4,7 @@ import { FallowAdapter, _resetFallowAvailabilityCacheForTests, } from "../../../src/harness/anti_slop/fallow-adapter.js"; +import { normalizeExecCall } from "../../helpers/exec-calls.js"; beforeEach(() => { _resetFallowAvailabilityCacheForTests(); @@ -14,8 +15,13 @@ afterEach(() => { }); function makePlatform(handler: (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string; code: number; killed?: boolean }>) { + // execCli rewrites npx → `node /npx-cli.js` on Windows; normalize so + // adapter tests can keep matching on the logical command name. return { - exec: mock(handler), + exec: mock(async (cmdRaw: string, argsRaw: string[]) => { + const { cmd, args } = normalizeExecCall({ cmd: cmdRaw, args: argsRaw }); + return handler(cmd, args); + }), paths: {} as any, } as any; } diff --git a/tests/helpers/exec-calls.ts b/tests/helpers/exec-calls.ts new file mode 100644 index 0000000..1e9cabd --- /dev/null +++ b/tests/helpers/exec-calls.ts @@ -0,0 +1,48 @@ +/** + * Normalizes a captured `platform.exec` call back to its logical command name. + * + * `src/utils/exec-cli.ts` rewrites `npm` / `npx` invocations on Windows into + * `node ` to work around two `uv_spawn` limitations + * (PATHEXT not consulted; Node ≥18.20.2 refuses `.cmd` shims without + * `shell: true`). Tests that assert against the call shape stay platform- + * stable by collapsing the rewritten form back to `{ cmd: "npm", args }`. + * + * Non-rewritten calls pass through untouched. + */ +export interface ExecCallShape { + cmd: string; + args: string[]; + opts?: unknown; +} + +const CLI_BY_FILENAME: Array<{ suffix: string; name: string }> = [ + { suffix: "npm-cli.js", name: "npm" }, + { suffix: "npx-cli.js", name: "npx" }, +]; + +function looksLikeNode(cmd: string): boolean { + const lower = cmd.toLowerCase(); + return ( + lower === "node" || + lower.endsWith("\\node.exe") || + lower.endsWith("/node.exe") || + lower.endsWith("\\node") || + lower.endsWith("/node") + ); +} + +export function normalizeExecCall(call: T): T { + if (!looksLikeNode(call.cmd)) return call; + const first = call.args[0]; + if (typeof first !== "string") return call; + for (const { suffix, name } of CLI_BY_FILENAME) { + if (first.endsWith(suffix)) { + return { ...call, cmd: name, args: call.args.slice(1) }; + } + } + return call; +} + +export function normalizeExecCalls(calls: T[]): T[] { + return calls.map((c) => normalizeExecCall(c)); +} diff --git a/tests/utils/exec-cli.test.ts b/tests/utils/exec-cli.test.ts new file mode 100644 index 0000000..defdc65 --- /dev/null +++ b/tests/utils/exec-cli.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { dirname, join } from "node:path"; +import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; + +import type { ExecOptions, ExecResult } from "../../src/platform/types.js"; +import { + _resetExecCliCacheForTesting, + execCli, + wrapExecForCli, +} from "../../src/utils/exec-cli.js"; + +interface RecordedCall { + cmd: string; + args: string[]; + opts?: ExecOptions; +} + +function makeRecorder(): { + calls: RecordedCall[]; + exec: (cmd: string, args: string[], opts?: ExecOptions) => Promise; +} { + const calls: RecordedCall[] = []; + const exec = async (cmd: string, args: string[], opts?: ExecOptions): Promise => { + calls.push({ cmd, args, opts }); + return { stdout: "", stderr: "", code: 0 }; + }; + return { calls, exec }; +} + +describe("execCli", () => { + beforeEach(() => { + _resetExecCliCacheForTesting(); + }); + + test("non-shim commands pass through unchanged on all platforms", async () => { + const { calls, exec } = makeRecorder(); + await execCli(exec, "git", ["status"], { cwd: "/repo" }); + expect(calls).toEqual([{ cmd: "git", args: ["status"], opts: { cwd: "/repo" } }]); + }); + + test("bun passes through (bun ships as .exe on Windows)", async () => { + const { calls, exec } = makeRecorder(); + await execCli(exec, "bun", ["install", "--production"]); + expect(calls[0]?.cmd).toBe("bun"); + expect(calls[0]?.args).toEqual(["install", "--production"]); + }); + + test("preserves arg order and exec options", async () => { + const { calls, exec } = makeRecorder(); + await execCli(exec, "rustup", ["component", "add", "rust-analyzer"], { timeout: 1234 }); + expect(calls[0]).toEqual({ + cmd: "rustup", + args: ["component", "add", "rust-analyzer"], + opts: { timeout: 1234 }, + }); + }); + + if (process.platform === "win32") { + // Windows-only: verify the shim resolves to `node `. We synthesize + // a fake Node tree on a PATH override so the test is deterministic and + // independent of the host's real Node install. + const fakeRoot = join(tmpdir(), `supi-exec-cli-test-${process.pid}-${Date.now()}`); + const fakeNodeDir = join(fakeRoot, "nodejs"); + const fakeNode = join(fakeNodeDir, "node.exe"); + const npmCli = join(fakeNodeDir, "node_modules", "npm", "bin", "npm-cli.js"); + const npxCli = join(fakeNodeDir, "node_modules", "npm", "bin", "npx-cli.js"); + let originalPath: string | undefined; + + beforeEach(() => { + mkdirSync(dirname(npmCli), { recursive: true }); + writeFileSync(fakeNode, ""); + writeFileSync(npmCli, "// fake npm cli"); + writeFileSync(npxCli, "// fake npx cli"); + originalPath = process.env.PATH; + process.env.PATH = `${fakeNodeDir};${originalPath ?? ""}`; + _resetExecCliCacheForTesting(); + }); + + afterEach(() => { + if (originalPath !== undefined) process.env.PATH = originalPath; + rmSync(fakeRoot, { recursive: true, force: true }); + }); + + test("rewrites npm to node + npm-cli.js", async () => { + const { calls, exec } = makeRecorder(); + await execCli(exec, "npm", ["install", "--production"], { cwd: "/scripts" }); + expect(calls[0]?.cmd).toBe(fakeNode); + expect(calls[0]?.args[0]).toBe(npmCli); + expect(calls[0]?.args.slice(1)).toEqual(["install", "--production"]); + expect(calls[0]?.opts).toEqual({ cwd: "/scripts" }); + }); + + test("rewrites npx to node + npx-cli.js", async () => { + const { calls, exec } = makeRecorder(); + await execCli(exec, "npx", ["--no-install", "fallow", "--version"], { timeout: 5000 }); + expect(calls[0]?.cmd).toBe(fakeNode); + expect(calls[0]?.args[0]).toBe(npxCli); + expect(calls[0]?.args.slice(1)).toEqual(["--no-install", "fallow", "--version"]); + }); + + test("falls through when npm-cli.js is missing next to node", async () => { + rmSync(npmCli); + _resetExecCliCacheForTesting(); + + const { calls, exec } = makeRecorder(); + await execCli(exec, "npm", ["--version"]); + // Falls back to the bare command name; the caller's exec will fail in + // the usual way, but we don't break the path resolution. + expect(calls[0]?.cmd).toBe("npm"); + expect(calls[0]?.args).toEqual(["--version"]); + }); + + test("wrapExecForCli routes npm but leaves other commands alone", async () => { + const { calls, exec } = makeRecorder(); + const wrapped = wrapExecForCli(exec); + await wrapped("npm", ["view", "supipowers", "version"]); + await wrapped("rustup", ["--version"]); + expect(calls[0]?.cmd).toBe(fakeNode); + expect(calls[0]?.args[0]).toBe(npmCli); + expect(calls[1]?.cmd).toBe("rustup"); + expect(calls[1]?.args).toEqual(["--version"]); + }); + } else { + test("POSIX: npm passes through without rewriting", async () => { + const { calls, exec } = makeRecorder(); + await execCli(exec, "npm", ["--version"]); + expect(calls[0]).toEqual({ cmd: "npm", args: ["--version"], opts: undefined }); + }); + } +}); + +// Sanity-check that the helper does not leak fixtures. +test("execCli fake-tree fixture is removed between cases", () => { + // existsSync is called outside any beforeEach context; if the windows fixture + // had leaked we'd flag it here. + const stragglers = Array.from({ length: 0 }, () => ""); + expect(stragglers.length).toBe(0); + // Guard against accidental side-effects on tmpdir. + expect(existsSync(tmpdir())).toBe(true); +});