import { spawn, execSync } from "child_process"; import os from "os"; /** * @param {string} command * * @returns {string} */ function resolveCommandPath(command) { // command will be "npm", "yarn", etc. // Use 'command -v' to find the full path const fullPath = execSync(`command -v ${command}`, { encoding: "utf8", }).trim(); if (!fullPath) { throw new Error(`Command not found: ${command}`); } return fullPath; } /** * @param {string} command * @param {string[]} args * @param {import("child_process").SpawnOptions} options * * @returns {Promise<{status: number, stdout: string, stderr: string}>} */ export async function safeSpawn(command, args, options = {}) { // The command is always one of our supported package managers. // It should always be alphanumeric or _ or - // Reject any command names with suspicious characters if (!/^[a-zA-Z0-9_-]+$/.test(command)) { throw new Error(`Invalid command name: ${command}`); } return new Promise((resolve, reject) => { // Windows requires shell: true because .bat and .cmd files are not executable // without a terminal. On Unix/macOS, we resolve the full path first, then use // array args (safer, no escaping needed). // See: https://nodejs.org/api/child_process.html#child_processspawncommand-args-options let child; if (os.platform() === "win32") { child = spawn(command, args, { ...options, shell: true }); } else { const fullPath = resolveCommandPath(command); child = spawn(fullPath, args, options); } // When stdio is piped, we need to collect the output let stdout = ""; let stderr = ""; child.stdout?.on("data", (data) => { stdout += data.toString(); }); child.stderr?.on("data", (data) => { stderr += data.toString(); }); child.on("close", (code) => { // Code is null if it terminated by a signal. This should never // happen in our code. If this happens, return 1 error code. code = code ?? 1; resolve({ status: code, stdout: stdout, stderr: stderr, }); }); child.on("error", (error) => { reject(error); }); }); }