import { spawn, spawnSync } from "node:child_process"; import process from "node:process"; export function commandExists(command) { const locator = process.platform === "win32" ? "where" : "which"; const result = spawnSync(locator, [command], { stdio: "ignore", shell: false }); return result.status === 0; } // On Windows, some tools (corepack, pnpm, npm, ...) are installed as `.cmd`/`.bat` // shims. Those are not real PE executables, so libuv/CreateProcess cannot launch // them directly: `child_process.spawn` fails with ENOENT even though the same // command works in an interactive shell. Real executables (dotnet.exe, docker.exe, // node.exe, python.exe) do not have this problem and must NOT be routed through a // shell, because shell-quoting a path that contains spaces (e.g. "C:\Program // Files\...") breaks it. Detect which case applies per command instead of // guessing globally. function requiresWindowsShell(command) { if (process.platform !== "win32") { return false; } if (/[\\/]/.test(command)) { // Already a path; trust its extension. return /\.(cmd|bat)$/i.test(command); } const located = spawnSync("where", [command], { encoding: "utf8", shell: false }); if (located.status !== 0) { return false; } // `where` can list several matches on Windows (e.g. an extension-less shebang // launcher script alongside a `.cmd` shim for the same tool). Node can only // ever run the `.cmd`/`.bat` variant through a shell, so treat the command as // shell-requiring if *any* match needs one, not just the first line. const matches = located.stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return matches.some((match) => /\.(cmd|bat)$/i.test(match)); } function quoteForWindowsShell(value) { if (value === "") { return '""'; } if (!/[\s"^&|<>()%]/.test(value)) { return value; } return `"${value.replace(/"/g, '\\"')}"`; } export async function run(command, args = [], options = {}) { const printable = [command, ...args].join(" "); if (!options.quiet) { process.stdout.write(`\n> ${printable}\n`); } const useShell = requiresWindowsShell(command); const spawnCommand = useShell ? [command, ...args].map(quoteForWindowsShell).join(" ") : command; const spawnArgs = useShell ? [] : args; return await new Promise((resolve, reject) => { const child = spawn(spawnCommand, spawnArgs, { cwd: options.cwd ?? process.cwd(), env: { ...process.env, ...options.env }, stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", shell: useShell }); let stdout = ""; let stderr = ""; if (options.capture) { child.stdout?.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); } child.on("error", reject); child.on("close", (code, signal) => { if (code === 0) { resolve({ code, stdout, stderr }); return; } const suffix = signal ? ` (signal ${signal})` : ""; const error = new Error(`Command failed (${code}${suffix}): ${printable}`); error.code = code; error.stdout = stdout; error.stderr = stderr; reject(error); }); }); } export async function capture(command, args = [], options = {}) { const result = await run(command, args, { ...options, capture: true, quiet: true }); return result.stdout.trim(); }