/** * Build Stream Executor * * Executes build commands with streaming output and fuel-gauge progress indicator. * Interactive (TTY) mode: FuelGauge animation with scrolling preview. * Non-interactive mode: raw streaming to stdout/stderr, no FuelGauge instantiated. */ import { spawn } from 'node:child_process'; import { FuelGauge } from '../cli/fuel-gauge'; export interface BuildStreamOptions { command: string; args: string[]; cwd: string; env?: Record; stdin?: string; onOutput?: (chunk: string) => void; /** Transform each output line before display. Return null to suppress. Raw output is always captured for logs. */ filterOutput?: (line: string) => string | null; title?: string; noInteractive?: boolean; } export interface BuildStreamResult { success: boolean; exitCode: number; output: string; error?: string; backgrounded?: boolean; } function spawnChild(options: BuildStreamOptions) { const { command, args, cwd, env, stdin } = options; const child = spawn(command, args, { cwd, env: { ...process.env, ...env }, stdio: [stdin ? 'pipe' : 'ignore', 'pipe', 'pipe'], shell: true, }); if (stdin && child.stdin) { child.stdin.write(stdin); child.stdin.end(); } return child; } /** * Non-interactive path: no FuelGauge, raw streaming to stdout/stderr. * onOutput is still called so callers can emit structured markers. */ function executeStreaming(options: BuildStreamOptions): Promise { const { onOutput, filterOutput } = options; return new Promise((resolve) => { const outputLines: string[] = []; const errorLines: string[] = []; const child = spawnChild(options); const sigintHandler = () => { if (!child.killed) child.kill('SIGINT'); }; process.on('SIGINT', sigintHandler); if (child.stdout) { child.stdout.on('data', (data: Buffer) => { const text = data.toString(); outputLines.push(text); if (filterOutput) { for (const line of text.split('\n')) { if (!line.trim()) continue; const display = filterOutput(line); if (display !== null) process.stdout.write(`${display}\n`); } } else { process.stdout.write(text); } onOutput?.(text); }); } if (child.stderr) { child.stderr.on('data', (data: Buffer) => { const text = data.toString(); errorLines.push(text); process.stderr.write(text); onOutput?.(text); }); } child.on('close', (exitCode) => { process.off('SIGINT', sigintHandler); const output = outputLines.join('') + errorLines.join(''); if (exitCode === 0) { resolve({ success: true, exitCode: 0, output }); } else { resolve({ success: false, exitCode: exitCode ?? 1, output, error: `Build exited with code ${exitCode}`, }); } }); child.on('error', (error) => { process.off('SIGINT', sigintHandler); resolve({ success: false, exitCode: 1, output: outputLines.join(''), error: error.message }); }); }); } /** * Interactive path: FuelGauge animation with Cylon-style progress bar. */ function executeWithGauge(options: BuildStreamOptions): Promise { const { onOutput, filterOutput, title = 'Building module' } = options; return new Promise((resolve) => { const outputLines: string[] = []; const errorLines: string[] = []; let backgrounded = false; const gauge = new FuelGauge(title, { onBackground: () => { backgrounded = true; process.off('SIGINT', sigintHandler); resolve({ success: true, exitCode: 0, output: '', backgrounded: true }); }, }); gauge.start(); const child = spawnChild(options); const sigintHandler = () => { if (!child.killed) child.kill('SIGINT'); }; process.on('SIGINT', sigintHandler); if (child.stdout) { child.stdout.on('data', (data: Buffer) => { if (backgrounded) return; const text = data.toString(); outputLines.push(text); for (const line of text.split('\n')) { if (!line.trim()) continue; const display = filterOutput ? filterOutput(line) : line; if (display !== null) gauge.addOutput(display); } onOutput?.(text); }); } if (child.stderr) { child.stderr.on('data', (data: Buffer) => { if (backgrounded) return; const text = data.toString(); errorLines.push(text); for (const line of text.split('\n').filter((l: string) => l.trim())) { gauge.addOutput(line); } onOutput?.(text); }); } child.on('close', (exitCode) => { if (backgrounded) return; process.off('SIGINT', sigintHandler); const output = outputLines.join('') + errorLines.join(''); if (exitCode === 0) { gauge.stop(true); resolve({ success: true, exitCode: 0, output }); } else { gauge.stop(false); resolve({ success: false, exitCode: exitCode ?? 1, output, error: `Build exited with code ${exitCode}`, }); } }); child.on('error', (error) => { if (backgrounded) return; process.off('SIGINT', sigintHandler); gauge.stop(false); resolve({ success: false, exitCode: 1, output: outputLines.join(''), error: error.message }); }); }); } export function executeBuildWithProgress(options: BuildStreamOptions): Promise { const nonInteractive = !process.stdout.isTTY || !!options.noInteractive; return nonInteractive ? executeStreaming(options) : executeWithGauge(options); }