/** * Process spawning — always explicit argument arrays, never shell strings. */ import { spawn } from 'child_process'; /** * Spawn a process and stream its output to stdout/stderr. * @param {string} cmd * @param {string[]} args * @param {{ dryRun?: boolean }} opts * @returns {Promise} */ export async function run(cmd, args, { dryRun = false } = {}) { console.log(`$ ${[cmd, ...args].join(' ')}`); if (dryRun) return; return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: 'inherit' }); child.on('error', reject); child.on('close', code => { if (code === 0) resolve(); else reject(new Error(`${cmd} exited with code ${code}`)); }); }); } /** * Spawn a process and capture stdout as a string. * stderr is inherited (shown to user). Never used in dry-run context. * @param {string} cmd * @param {string[]} args * @param {{ allowedExitCodes?: number[] }} opts * @returns {Promise} */ export async function capture(cmd, args, { allowedExitCodes = [0] } = {}) { console.log(`$ ${[cmd, ...args].join(' ')}`); return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ['inherit', 'pipe', 'inherit'] }); const chunks = []; child.stdout.on('data', chunk => chunks.push(chunk)); child.on('error', reject); child.on('close', code => { if (allowedExitCodes.includes(code)) resolve(Buffer.concat(chunks).toString('utf8')); else reject(new Error(`${cmd} exited with code ${code}`)); }); }); } // rsync exit codes that are not errors const RSYNC_OK_CODES = [ 0, // success 24, // partial transfer: source files vanished mid-run (acceptable) ]; const RSYNC_ERROR_CODES = { 23: 'partial transfer due to error', }; /** * Run rsync with exit code awareness. * @param {string[]} args * @param {{ dryRun?: boolean, capture?: boolean }} opts * @returns {Promise} */ export async function rsync(args, { dryRun = false, capture: doCapture = false } = {}) { console.log(`$ rsync ${args.join(' ')}`); if (dryRun) return doCapture ? '' : undefined; return new Promise((resolve, reject) => { const stdio = doCapture ? ['inherit', 'pipe', 'inherit'] : 'inherit'; const child = spawn('rsync', args, { stdio }); const chunks = []; if (doCapture) child.stdout.on('data', chunk => chunks.push(chunk)); child.on('error', reject); child.on('close', code => { if (RSYNC_OK_CODES.includes(code)) { resolve(doCapture ? Buffer.concat(chunks).toString('utf8') : undefined); } else { const reason = RSYNC_ERROR_CODES[code] ?? `unknown error`; reject(new Error(`rsync exited with code ${code}: ${reason}`)); } }); }); }