import { execFile, spawn } from "node:child_process"; import path from "node:path"; import { promisify } from "node:util"; import { danger, shouldLogVerbose } from "../globals.js"; import { logDebug, logError } from "../logger.js"; import { resolveCommandStdio } from "./spawn-utils.js"; const execFileAsync = promisify(execFile); /** * Resolves a command for Windows compatibility. * On Windows, non-.exe commands (like npm, pnpm) require their .cmd extension. */ function resolveCommand(command: string): string { if (process.platform !== "win32") { return command; } const basename = path.basename(command).toLowerCase(); // Skip if already has an extension (.cmd, .exe, .bat, etc.) const ext = path.extname(basename); if (ext) { return command; } // Common npm-related commands that need .cmd extension on Windows const cmdCommands = ["npm", "pnpm", "yarn", "npx"]; if (cmdCommands.includes(basename)) { return `${command}.cmd`; } return command; } // Simple promise-wrapped execFile with optional verbosity logging. export async function runExec( command: string, args: string[], opts: number | { timeoutMs?: number; maxBuffer?: number } = 10_000, ): Promise<{ stdout: string; stderr: string }> { const options = typeof opts === "number" ? { timeout: opts, encoding: "utf8" as const } : { timeout: opts.timeoutMs, maxBuffer: opts.maxBuffer, encoding: "utf8" as const, }; try { const { stdout, stderr } = await execFileAsync(resolveCommand(command), args, options); if (shouldLogVerbose()) { if (stdout.trim()) { logDebug(stdout.trim()); } if (stderr.trim()) { logError(stderr.trim()); } } return { stdout, stderr }; } catch (err) { if (shouldLogVerbose()) { logError(danger(`Command failed: ${command} ${args.join(" ")}`)); } throw err; } } export type SpawnResult = { stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null; killed: boolean; }; export type CommandOptions = { timeoutMs: number; cwd?: string; input?: string; env?: NodeJS.ProcessEnv; windowsVerbatimArguments?: boolean; }; export async function runCommandWithTimeout( argv: string[], optionsOrTimeout: number | CommandOptions, ): Promise { const options: CommandOptions = typeof optionsOrTimeout === "number" ? { timeoutMs: optionsOrTimeout } : optionsOrTimeout; const { timeoutMs, cwd, input, env } = options; const { windowsVerbatimArguments } = options; const hasInput = input !== undefined; const shouldSuppressNpmFund = (() => { const cmd = path.basename(argv[0] ?? ""); if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") { return true; } if (cmd === "node" || cmd === "node.exe") { const script = argv[1] ?? ""; return script.includes("npm-cli.js"); } return false; })(); const resolvedEnv = env ? { ...process.env, ...env } : { ...process.env }; if (shouldSuppressNpmFund) { if (resolvedEnv.NPM_CONFIG_FUND == null) { resolvedEnv.NPM_CONFIG_FUND = "false"; } if (resolvedEnv.npm_config_fund == null) { resolvedEnv.npm_config_fund = "false"; } } const stdio = resolveCommandStdio({ hasInput, preferInherit: true }); const child = spawn(resolveCommand(argv[0]), argv.slice(1), { stdio, cwd, env: resolvedEnv, windowsVerbatimArguments, }); // Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed. return await new Promise((resolve, reject) => { let stdout = ""; let stderr = ""; let settled = false; const timer = setTimeout(() => { if (typeof child.kill === "function") { child.kill("SIGKILL"); } }, timeoutMs); if (hasInput && child.stdin) { child.stdin.write(input ?? ""); child.stdin.end(); } child.stdout?.on("data", (d) => { stdout += d.toString(); }); child.stderr?.on("data", (d) => { stderr += d.toString(); }); child.on("error", (err) => { if (settled) { return; } settled = true; clearTimeout(timer); reject(err); }); child.on("close", (code, signal) => { if (settled) { return; } settled = true; clearTimeout(timer); resolve({ stdout, stderr, code, signal, killed: child.killed }); }); }); }