107 lines
3.4 KiB
JavaScript
107 lines
3.4 KiB
JavaScript
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();
|
|
}
|