diff --git a/.changeset/modular-utilities.md b/.changeset/modular-utilities.md new file mode 100644 index 0000000..cfcb467 --- /dev/null +++ b/.changeset/modular-utilities.md @@ -0,0 +1,20 @@ +--- +'command-stream': patch +--- + +Reorganize codebase with modular utilities for better maintainability + +- Extract trace/logging utilities to $.trace.mjs +- Extract shell detection to $.shell.mjs +- Extract stream utilities to $.stream-utils.mjs and $.stream-emitter.mjs +- Extract shell quoting to $.quote.mjs +- Extract result creation to $.result.mjs +- Extract ANSI utilities to $.ansi.mjs +- Extract global state management to $.state.mjs +- Extract shell settings to $.shell-settings.mjs +- Extract virtual command registration to $.virtual-commands.mjs +- Add commands/index.mjs for module exports +- Update $.utils.mjs to use shared trace module + +All new modules follow the 1500-line limit guideline. The Rust code +structure already follows best practices with tests in separate files. diff --git a/eslint.config.js b/eslint.config.js index 45be7aa..b912f20 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -115,7 +115,7 @@ export default [ ], 'max-params': ['warn', 6], // Maximum function parameters - slightly more lenient than strict 5 'max-statements': ['warn', 60], // Maximum statements per function - reasonable limit for orchestration functions - 'max-lines': ['warn', 1500], // Maximum lines per file - set to warn for existing large files + 'max-lines': ['error', 1500], // Maximum lines per file - enforced for all source files }, }, { @@ -193,6 +193,28 @@ export default [ }, }, }, + { + // ProcessRunner modular architecture uses attachment pattern + // where methods are attached to prototypes within wrapper functions. + // These wrapper functions are larger than typical functions but contain + // method definitions themselves, not complex logic. + files: [ + 'js/src/$.process-runner-execution.mjs', + 'js/src/$.process-runner-pipeline.mjs', + 'src/$.process-runner-execution.mjs', + 'src/$.process-runner-pipeline.mjs', + ], + rules: { + 'max-lines-per-function': [ + 'warn', + { + max: 450, + skipBlankLines: true, + skipComments: true, + }, + ], + }, + }, { ignores: [ 'node_modules/**', diff --git a/js/src/$.ansi.mjs b/js/src/$.ansi.mjs new file mode 100644 index 0000000..55288f7 --- /dev/null +++ b/js/src/$.ansi.mjs @@ -0,0 +1,147 @@ +// ANSI control character utilities for command-stream +// Handles stripping and processing of ANSI escape codes + +import { trace } from './$.trace.mjs'; + +/** + * ANSI control character utilities + */ +export const AnsiUtils = { + /** + * Strip ANSI escape codes from text + * @param {string} text - Text to process + * @returns {string} Text without ANSI codes + */ + stripAnsi(text) { + if (typeof text !== 'string') { + return text; + } + return text.replace(/\x1b\[[0-9;]*[mGKHFJ]/g, ''); + }, + + /** + * Strip control characters from text (preserving newlines, carriage returns, tabs) + * @param {string} text - Text to process + * @returns {string} Text without control characters + */ + stripControlChars(text) { + if (typeof text !== 'string') { + return text; + } + // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09) + return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); + }, + + /** + * Strip both ANSI codes and control characters + * @param {string} text - Text to process + * @returns {string} Cleaned text + */ + stripAll(text) { + if (typeof text !== 'string') { + return text; + } + // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09) + return text.replace( + /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1b\[[0-9;]*[mGKHFJ]/g, + '' + ); + }, + + /** + * Clean data for processing (handles both Buffer and string) + * @param {Buffer|string} data - Data to clean + * @returns {Buffer|string} Cleaned data + */ + cleanForProcessing(data) { + if (Buffer.isBuffer(data)) { + return Buffer.from(this.stripAll(data.toString('utf8'))); + } + return this.stripAll(data); + }, +}; + +// Global ANSI configuration +let globalAnsiConfig = { + preserveAnsi: true, + preserveControlChars: true, +}; + +/** + * Configure global ANSI handling + * @param {object} options - Configuration options + * @param {boolean} options.preserveAnsi - Whether to preserve ANSI codes + * @param {boolean} options.preserveControlChars - Whether to preserve control chars + * @returns {object} Current configuration + */ +export function configureAnsi(options = {}) { + trace( + 'AnsiUtils', + () => `configureAnsi() called | ${JSON.stringify({ options }, null, 2)}` + ); + globalAnsiConfig = { ...globalAnsiConfig, ...options }; + trace( + 'AnsiUtils', + () => `New ANSI config | ${JSON.stringify({ globalAnsiConfig }, null, 2)}` + ); + return globalAnsiConfig; +} + +/** + * Get current ANSI configuration + * @returns {object} Current configuration + */ +export function getAnsiConfig() { + trace( + 'AnsiUtils', + () => + `getAnsiConfig() returning | ${JSON.stringify({ globalAnsiConfig }, null, 2)}` + ); + return { ...globalAnsiConfig }; +} + +/** + * Reset ANSI configuration to defaults + */ +export function resetAnsiConfig() { + globalAnsiConfig = { + preserveAnsi: true, + preserveControlChars: true, + }; + trace('AnsiUtils', () => 'ANSI config reset to defaults'); +} + +/** + * Process output data according to current ANSI configuration + * @param {Buffer|string} data - Data to process + * @param {object} options - Override options + * @returns {Buffer|string} Processed data + */ +export function processOutput(data, options = {}) { + trace( + 'AnsiUtils', + () => + `processOutput() called | ${JSON.stringify( + { + dataType: typeof data, + dataLength: Buffer.isBuffer(data) ? data.length : data?.length, + options, + }, + null, + 2 + )}` + ); + const config = { ...globalAnsiConfig, ...options }; + if (!config.preserveAnsi && !config.preserveControlChars) { + return AnsiUtils.cleanForProcessing(data); + } else if (!config.preserveAnsi) { + return Buffer.isBuffer(data) + ? Buffer.from(AnsiUtils.stripAnsi(data.toString('utf8'))) + : AnsiUtils.stripAnsi(data); + } else if (!config.preserveControlChars) { + return Buffer.isBuffer(data) + ? Buffer.from(AnsiUtils.stripControlChars(data.toString('utf8'))) + : AnsiUtils.stripControlChars(data); + } + return data; +} diff --git a/js/src/$.mjs b/js/src/$.mjs old mode 100755 new mode 100644 index 445264d..5ea61f3 --- a/js/src/$.mjs +++ b/js/src/$.mjs @@ -1,6279 +1,50 @@ -// Enhanced $ shell utilities with streaming, async iteration, and EventEmitter support -// Usage patterns: -// 1. Classic await: const result = await $`command` -// 2. Async iteration: for await (const chunk of $`command`.stream()) { ... } -// 3. EventEmitter: $`command`.on('data', chunk => ...).on('end', result => ...) -// 4. Stream access: $`command`.stdout, $`command`.stderr - -import cp from 'child_process'; -import path from 'path'; -import fs from 'fs'; -import { parseShellCommand, needsRealShell } from './shell-parser.mjs'; - -const isBun = typeof globalThis.Bun !== 'undefined'; - -// Trace function for verbose logging -// Can be controlled via COMMAND_STREAM_VERBOSE or COMMAND_STREAM_TRACE env vars -// Can be disabled per-command via trace: false option -// CI environment no longer auto-enables tracing -function trace(category, messageOrFunc, runner = null) { - // Check if runner explicitly disabled tracing - if (runner && runner.options && runner.options.trace === false) { - return; - } - - // Check global trace setting (evaluated dynamically for runtime changes) - const TRACE_ENV = process.env.COMMAND_STREAM_TRACE; - const VERBOSE_ENV = process.env.COMMAND_STREAM_VERBOSE === 'true'; - - // COMMAND_STREAM_TRACE=false explicitly disables tracing even if COMMAND_STREAM_VERBOSE=true - // COMMAND_STREAM_TRACE=true explicitly enables tracing - // Otherwise, use COMMAND_STREAM_VERBOSE - const VERBOSE = - TRACE_ENV === 'false' ? false : TRACE_ENV === 'true' ? true : VERBOSE_ENV; - - if (!VERBOSE) { - return; - } - - const message = - typeof messageOrFunc === 'function' ? messageOrFunc() : messageOrFunc; - const timestamp = new Date().toISOString(); - console.error(`[TRACE ${timestamp}] [${category}] ${message}`); -} - -// Shell detection cache -let cachedShell = null; - -// Save initial working directory for restoration -const initialWorkingDirectory = process.cwd(); - -/** - * Find an available shell by checking multiple options in order - * Returns the shell command and arguments to use - */ -function findAvailableShell() { - if (cachedShell) { - trace('ShellDetection', () => `Using cached shell: ${cachedShell.cmd}`); - return cachedShell; - } - - const isWindows = process.platform === 'win32'; - - // Windows-specific shells - const windowsShells = [ - // Git Bash is the most Unix-compatible option on Windows - // Check common installation paths - { - cmd: 'C:\\Program Files\\Git\\bin\\bash.exe', - args: ['-c'], - checkPath: true, - }, - { - cmd: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', - args: ['-c'], - checkPath: true, - }, - { - cmd: 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', - args: ['-c'], - checkPath: true, - }, - // Git Bash via PATH (if added to PATH by user) - { cmd: 'bash.exe', args: ['-c'], checkPath: false }, - // WSL bash as fallback - { cmd: 'wsl.exe', args: ['bash', '-c'], checkPath: false }, - // PowerShell as last resort (different syntax for commands) - { cmd: 'powershell.exe', args: ['-Command'], checkPath: false }, - { cmd: 'pwsh.exe', args: ['-Command'], checkPath: false }, - // cmd.exe as final fallback - { cmd: 'cmd.exe', args: ['/c'], checkPath: false }, - ]; - - // Unix-specific shells - const unixShells = [ - // Try absolute paths first (most reliable) - { cmd: '/bin/sh', args: ['-l', '-c'], checkPath: true }, - { cmd: '/usr/bin/sh', args: ['-l', '-c'], checkPath: true }, - { cmd: '/bin/bash', args: ['-l', '-c'], checkPath: true }, - { cmd: '/usr/bin/bash', args: ['-l', '-c'], checkPath: true }, - { cmd: '/bin/zsh', args: ['-l', '-c'], checkPath: true }, - { cmd: '/usr/bin/zsh', args: ['-l', '-c'], checkPath: true }, - // macOS specific paths - { cmd: '/usr/local/bin/bash', args: ['-l', '-c'], checkPath: true }, - { cmd: '/usr/local/bin/zsh', args: ['-l', '-c'], checkPath: true }, - // Linux brew paths - { - cmd: '/home/linuxbrew/.linuxbrew/bin/bash', - args: ['-l', '-c'], - checkPath: true, - }, - { - cmd: '/home/linuxbrew/.linuxbrew/bin/zsh', - args: ['-l', '-c'], - checkPath: true, - }, - // Try shells in PATH as fallback (which might not work in all environments) - // Using separate -l and -c flags for better compatibility - { cmd: 'sh', args: ['-l', '-c'], checkPath: false }, - { cmd: 'bash', args: ['-l', '-c'], checkPath: false }, - { cmd: 'zsh', args: ['-l', '-c'], checkPath: false }, - ]; - - // Select shells based on platform - const shellsToTry = isWindows ? windowsShells : unixShells; - - for (const shell of shellsToTry) { - try { - if (shell.checkPath) { - // Check if the absolute path exists - if (fs.existsSync(shell.cmd)) { - trace( - 'ShellDetection', - () => `Found shell at absolute path: ${shell.cmd}` - ); - cachedShell = { cmd: shell.cmd, args: shell.args }; - return cachedShell; - } - } else { - // On Windows, use 'where' instead of 'which' - const whichCmd = isWindows ? 'where' : 'which'; - const result = cp.spawnSync(whichCmd, [shell.cmd], { - encoding: 'utf-8', - // On Windows, we need shell: true for 'where' to work - shell: isWindows, - }); - if (result.status === 0 && result.stdout) { - const shellPath = result.stdout.trim().split('\n')[0]; // Take first result - trace( - 'ShellDetection', - () => `Found shell in PATH: ${shell.cmd} => ${shellPath}` - ); - cachedShell = { cmd: shell.cmd, args: shell.args }; - return cachedShell; - } - } - } catch (e) { - // Continue to next shell option - trace( - 'ShellDetection', - () => `Failed to check shell ${shell.cmd}: ${e.message}` - ); - } - } - - // Final fallback based on platform - if (isWindows) { - trace( - 'ShellDetection', - () => 'WARNING: No shell found, using cmd.exe as fallback on Windows' - ); - cachedShell = { cmd: 'cmd.exe', args: ['/c'] }; - } else { - trace( - 'ShellDetection', - () => 'WARNING: No shell found, using /bin/sh as fallback' - ); - cachedShell = { cmd: '/bin/sh', args: ['-l', '-c'] }; - } - return cachedShell; -} - -// Track parent stream state for graceful shutdown -let parentStreamsMonitored = false; -const activeProcessRunners = new Set(); - -// Track if SIGINT handler has been installed -let sigintHandlerInstalled = false; -let sigintHandler = null; // Store reference to remove it later - -function installSignalHandlers() { - // Check if our handler is actually installed (not just the flag) - // This is more robust against test cleanup that manually removes listeners - const currentListeners = process.listeners('SIGINT'); - const hasOurHandler = currentListeners.some((l) => { - const str = l.toString(); - return ( - str.includes('activeProcessRunners') && - str.includes('ProcessRunner') && - str.includes('activeChildren') - ); - }); - - if (sigintHandlerInstalled && hasOurHandler) { - trace('SignalHandler', () => 'SIGINT handler already installed, skipping'); - return; - } - - // Reset flag if handler was removed externally - if (sigintHandlerInstalled && !hasOurHandler) { - trace( - 'SignalHandler', - () => 'SIGINT handler flag was set but handler missing, resetting' - ); - sigintHandlerInstalled = false; - sigintHandler = null; - } - - trace( - 'SignalHandler', - () => - `Installing SIGINT handler | ${JSON.stringify({ activeRunners: activeProcessRunners.size })}` - ); - sigintHandlerInstalled = true; - - // Forward SIGINT to all active child processes - // The parent process continues running - it's up to the parent to decide what to do - sigintHandler = () => { - // Check for other handlers immediately at the start, before doing any processing - const currentListeners = process.listeners('SIGINT'); - const hasOtherHandlers = currentListeners.length > 1; - - trace( - 'ProcessRunner', - () => `SIGINT handler triggered - checking active processes` - ); - - // Count active processes (both child processes and virtual commands) - const activeChildren = []; - for (const runner of activeProcessRunners) { - if (!runner.finished) { - // Real child process - if (runner.child && runner.child.pid) { - activeChildren.push(runner); - trace( - 'ProcessRunner', - () => - `Found active child: PID ${runner.child.pid}, command: ${runner.spec?.command || 'unknown'}` - ); - } - // Virtual command (no child process but still active) - else if (!runner.child) { - activeChildren.push(runner); - trace( - 'ProcessRunner', - () => - `Found active virtual command: ${runner.spec?.command || 'unknown'}` - ); - } - } - } - - trace( - 'ProcessRunner', - () => - `Parent received SIGINT | ${JSON.stringify( - { - activeChildrenCount: activeChildren.length, - hasOtherHandlers, - platform: process.platform, - pid: process.pid, - ppid: process.ppid, - activeCommands: activeChildren.map((r) => ({ - hasChild: !!r.child, - childPid: r.child?.pid, - hasVirtualGenerator: !!r._virtualGenerator, - finished: r.finished, - command: r.spec?.command?.slice(0, 30), - })), - }, - null, - 2 - )}` - ); - - // Only handle SIGINT if we have active child processes - // Otherwise, let other handlers or default behavior handle it - if (activeChildren.length === 0) { - trace( - 'ProcessRunner', - () => - `No active children - skipping SIGINT forwarding, letting other handlers handle it` - ); - return; // Let other handlers or default behavior handle it - } - - trace( - 'ProcessRunner', - () => - `Beginning SIGINT forwarding to ${activeChildren.length} active processes` - ); - - // Forward signal to all active processes (child processes and virtual commands) - for (const runner of activeChildren) { - try { - if (runner.child && runner.child.pid) { - // Real child process - send SIGINT to it - trace( - 'ProcessRunner', - () => - `Sending SIGINT to child process | ${JSON.stringify( - { - pid: runner.child.pid, - killed: runner.child.killed, - runtime: isBun ? 'Bun' : 'Node.js', - command: runner.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - if (isBun) { - runner.child.kill('SIGINT'); - trace( - 'ProcessRunner', - () => `Bun: SIGINT sent to PID ${runner.child.pid}` - ); - } else { - // Send to process group if detached, otherwise to process directly - try { - process.kill(-runner.child.pid, 'SIGINT'); - trace( - 'ProcessRunner', - () => - `Node.js: SIGINT sent to process group -${runner.child.pid}` - ); - } catch (err) { - trace( - 'ProcessRunner', - () => - `Node.js: Process group kill failed, trying direct: ${err.message}` - ); - process.kill(runner.child.pid, 'SIGINT'); - trace( - 'ProcessRunner', - () => `Node.js: SIGINT sent directly to PID ${runner.child.pid}` - ); - } - } - } else { - // Virtual command - cancel it using the runner's kill method - trace( - 'ProcessRunner', - () => - `Cancelling virtual command | ${JSON.stringify( - { - hasChild: !!runner.child, - hasVirtualGenerator: !!runner._virtualGenerator, - finished: runner.finished, - cancelled: runner._cancelled, - command: runner.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - runner.kill('SIGINT'); - trace('ProcessRunner', () => `Virtual command kill() called`); - } - } catch (err) { - trace( - 'ProcessRunner', - () => - `Error in SIGINT handler for runner | ${JSON.stringify( - { - error: err.message, - stack: err.stack?.slice(0, 300), - hasPid: !!(runner.child && runner.child.pid), - pid: runner.child?.pid, - command: runner.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - } - } - - // We've forwarded SIGINT to all active processes/commands - // Use the hasOtherHandlers flag we calculated at the start (before any processing) - trace( - 'ProcessRunner', - () => - `SIGINT forwarded to ${activeChildren.length} active processes, other handlers: ${hasOtherHandlers}` - ); - - if (!hasOtherHandlers) { - // No other handlers - we should exit like a proper shell - trace( - 'ProcessRunner', - () => `No other SIGINT handlers, exiting with code 130` - ); - // Ensure stdout/stderr are flushed before exiting - if (process.stdout && typeof process.stdout.write === 'function') { - process.stdout.write('', () => { - process.exit(130); // 128 + 2 (SIGINT) - }); - } else { - process.exit(130); // 128 + 2 (SIGINT) - } - } else { - // Other handlers exist - let them handle the exit completely - // Do NOT call process.exit() ourselves when other handlers are present - trace( - 'ProcessRunner', - () => - `Other SIGINT handlers present, letting them handle the exit completely` - ); - } - }; - - process.on('SIGINT', sigintHandler); -} - -function uninstallSignalHandlers() { - if (!sigintHandlerInstalled || !sigintHandler) { - trace( - 'SignalHandler', - () => 'SIGINT handler not installed or missing, skipping removal' - ); - return; - } - - trace( - 'SignalHandler', - () => - `Removing SIGINT handler | ${JSON.stringify({ activeRunners: activeProcessRunners.size })}` - ); - process.removeListener('SIGINT', sigintHandler); - sigintHandlerInstalled = false; - sigintHandler = null; -} - -// Force cleanup of all command-stream SIGINT handlers and state - for testing -function forceCleanupAll() { - // Remove all command-stream SIGINT handlers - const sigintListeners = process.listeners('SIGINT'); - const commandStreamListeners = sigintListeners.filter((l) => { - const str = l.toString(); - return ( - str.includes('activeProcessRunners') || - str.includes('ProcessRunner') || - str.includes('activeChildren') - ); - }); - - commandStreamListeners.forEach((listener) => { - process.removeListener('SIGINT', listener); - }); - - // Clear activeProcessRunners - activeProcessRunners.clear(); - - // Reset signal handler flags - sigintHandlerInstalled = false; - sigintHandler = null; - - trace( - 'SignalHandler', - () => - `Force cleanup completed - removed ${commandStreamListeners.length} handlers` - ); -} - -// Complete global state reset for testing - clears all library state -function resetGlobalState() { - // CRITICAL: Restore working directory first before anything else - // This MUST succeed or tests will fail with spawn errors - try { - // Try to get current directory - this might fail if we're in a deleted directory - let currentDir; - try { - currentDir = process.cwd(); - } catch (e) { - // Can't even get cwd, we're in a deleted directory - currentDir = null; - } - - // Always try to restore to initial directory - if (!currentDir || currentDir !== initialWorkingDirectory) { - // Check if initial directory still exists - if (fs.existsSync(initialWorkingDirectory)) { - process.chdir(initialWorkingDirectory); - trace( - 'GlobalState', - () => - `Restored working directory from ${currentDir} to ${initialWorkingDirectory}` - ); - } else { - // Initial directory is gone, use fallback - const fallback = process.env.HOME || '/workspace/command-stream' || '/'; - if (fs.existsSync(fallback)) { - process.chdir(fallback); - trace( - 'GlobalState', - () => `Initial directory gone, changed to fallback: ${fallback}` - ); - } else { - // Last resort - try root - process.chdir('/'); - trace('GlobalState', () => `Emergency fallback to root directory`); - } - } - } - } catch (e) { - trace( - 'GlobalState', - () => `Critical error restoring working directory: ${e.message}` - ); - // This is critical - we MUST have a valid working directory - try { - // Try home directory - if (process.env.HOME && fs.existsSync(process.env.HOME)) { - process.chdir(process.env.HOME); - } else { - // Last resort - root - process.chdir('/'); - } - } catch (e2) { - console.error('FATAL: Cannot set any working directory!', e2); - } - } - - // First, properly clean up all active ProcessRunners - for (const runner of activeProcessRunners) { - if (runner) { - try { - // If the runner was never started, clean it up - if (!runner.started) { - trace( - 'resetGlobalState', - () => - `Cleaning up unstarted ProcessRunner: ${runner.spec?.command?.slice(0, 50)}` - ); - // Call the cleanup method to properly release resources - if (runner._cleanup) { - runner._cleanup(); - } - } else if (runner.kill) { - // For started runners, kill them - runner.kill(); - } - } catch (e) { - // Ignore errors - trace('resetGlobalState', () => `Error during cleanup: ${e.message}`); - } - } - } - - // Call existing cleanup - forceCleanupAll(); - - // Clear shell cache to force re-detection with our fixed logic - cachedShell = null; - - // Reset parent stream monitoring - parentStreamsMonitored = false; - - // Reset shell settings to defaults - globalShellSettings = { - xtrace: false, - errexit: false, - pipefail: false, - verbose: false, - noglob: false, - allexport: false, - }; - - // Don't clear virtual commands - they should persist across tests - // Just make sure they're enabled - virtualCommandsEnabled = true; - - // Reset ANSI config to defaults - globalAnsiConfig = { - forceColor: false, - noColor: false, - }; - - // Make sure built-in virtual commands are registered - if (virtualCommands.size === 0) { - // Re-import to re-register commands (synchronously if possible) - trace('GlobalState', () => 'Re-registering virtual commands'); - import('./commands/index.mjs') - .then(() => { - trace( - 'GlobalState', - () => `Virtual commands re-registered, count: ${virtualCommands.size}` - ); - }) - .catch((e) => { - trace( - 'GlobalState', - () => `Error re-registering virtual commands: ${e.message}` - ); - }); - } - - trace('GlobalState', () => 'Global state reset completed'); -} - -function monitorParentStreams() { - if (parentStreamsMonitored) { - trace('StreamMonitor', () => 'Parent streams already monitored, skipping'); - return; - } - trace('StreamMonitor', () => 'Setting up parent stream monitoring'); - parentStreamsMonitored = true; - - const checkParentStream = (stream, name) => { - if (stream && typeof stream.on === 'function') { - stream.on('close', () => { - trace( - 'ProcessRunner', - () => - `Parent ${name} closed - triggering graceful shutdown | ${JSON.stringify({ activeProcesses: activeProcessRunners.size }, null, 2)}` - ); - for (const runner of activeProcessRunners) { - runner._handleParentStreamClosure(); - } - }); - } - }; - - checkParentStream(process.stdout, 'stdout'); - checkParentStream(process.stderr, 'stderr'); -} - -function safeWrite(stream, data, processRunner = null) { - monitorParentStreams(); - - if (!StreamUtils.isStreamWritable(stream)) { - trace( - 'ProcessRunner', - () => - `safeWrite skipped - stream not writable | ${JSON.stringify( - { - hasStream: !!stream, - writable: stream?.writable, - destroyed: stream?.destroyed, - closed: stream?.closed, - }, - null, - 2 - )}` - ); - - if ( - processRunner && - (stream === process.stdout || stream === process.stderr) - ) { - processRunner._handleParentStreamClosure(); - } - - return false; - } - - try { - return stream.write(data); - } catch (error) { - trace( - 'ProcessRunner', - () => - `safeWrite error | ${JSON.stringify( - { - error: error.message, - code: error.code, - writable: stream.writable, - destroyed: stream.destroyed, - }, - null, - 2 - )}` - ); - - if ( - error.code === 'EPIPE' && - processRunner && - (stream === process.stdout || stream === process.stderr) - ) { - processRunner._handleParentStreamClosure(); - } - - return false; - } -} - -// Stream utility functions for safe operations and error handling -const StreamUtils = { - /** - * Check if a stream is safe to write to - */ - isStreamWritable(stream) { - return stream && stream.writable && !stream.destroyed && !stream.closed; - }, - - /** - * Add standardized error handler to stdin streams - */ - addStdinErrorHandler(stream, contextName = 'stdin', onNonEpipeError = null) { - if (stream && typeof stream.on === 'function') { - stream.on('error', (error) => { - const handled = this.handleStreamError( - error, - `${contextName} error event`, - false - ); - if (!handled && onNonEpipeError) { - onNonEpipeError(error); - } - }); - } - }, - - /** - * Safely write to a stream with comprehensive error handling - */ - safeStreamWrite(stream, data, contextName = 'stream') { - if (!this.isStreamWritable(stream)) { - trace( - 'ProcessRunner', - () => - `${contextName} write skipped - not writable | ${JSON.stringify( - { - hasStream: !!stream, - writable: stream?.writable, - destroyed: stream?.destroyed, - closed: stream?.closed, - }, - null, - 2 - )}` - ); - return false; - } - - try { - const result = stream.write(data); - trace( - 'ProcessRunner', - () => - `${contextName} write successful | ${JSON.stringify( - { - dataLength: data?.length || 0, - }, - null, - 2 - )}` - ); - return result; - } catch (error) { - if (error.code !== 'EPIPE') { - trace( - 'ProcessRunner', - () => - `${contextName} write error | ${JSON.stringify( - { - error: error.message, - code: error.code, - isEPIPE: false, - }, - null, - 2 - )}` - ); - throw error; // Re-throw non-EPIPE errors - } else { - trace( - 'ProcessRunner', - () => - `${contextName} EPIPE error (ignored) | ${JSON.stringify( - { - error: error.message, - code: error.code, - isEPIPE: true, - }, - null, - 2 - )}` - ); - } - return false; - } - }, - - /** - * Safely end a stream with error handling - */ - safeStreamEnd(stream, contextName = 'stream') { - if (!this.isStreamWritable(stream) || typeof stream.end !== 'function') { - trace( - 'ProcessRunner', - () => - `${contextName} end skipped - not available | ${JSON.stringify( - { - hasStream: !!stream, - hasEnd: stream && typeof stream.end === 'function', - writable: stream?.writable, - }, - null, - 2 - )}` - ); - return false; - } - - try { - stream.end(); - trace('ProcessRunner', () => `${contextName} ended successfully`); - return true; - } catch (error) { - if (error.code !== 'EPIPE') { - trace( - 'ProcessRunner', - () => - `${contextName} end error | ${JSON.stringify( - { - error: error.message, - code: error.code, - }, - null, - 2 - )}` - ); - } else { - trace( - 'ProcessRunner', - () => - `${contextName} EPIPE on end (ignored) | ${JSON.stringify( - { - error: error.message, - code: error.code, - }, - null, - 2 - )}` - ); - } - return false; - } - }, - - /** - * Setup comprehensive stdin handling (error handler + safe operations) - */ - setupStdinHandling(stream, contextName = 'stdin') { - this.addStdinErrorHandler(stream, contextName); - - return { - write: (data) => this.safeStreamWrite(stream, data, contextName), - end: () => this.safeStreamEnd(stream, contextName), - isWritable: () => this.isStreamWritable(stream), - }; - }, - - /** - * Handle stream errors with consistent EPIPE behavior - */ - handleStreamError(error, contextName, shouldThrow = true) { - if (error.code !== 'EPIPE') { - trace( - 'ProcessRunner', - () => - `${contextName} error | ${JSON.stringify( - { - error: error.message, - code: error.code, - isEPIPE: false, - }, - null, - 2 - )}` - ); - if (shouldThrow) { - throw error; - } - return false; - } else { - trace( - 'ProcessRunner', - () => - `${contextName} EPIPE error (ignored) | ${JSON.stringify( - { - error: error.message, - code: error.code, - isEPIPE: true, - }, - null, - 2 - )}` - ); - return true; // EPIPE handled gracefully - } - }, - - /** - * Detect if stream supports Bun-style writing - */ - isBunStream(stream) { - return isBun && stream && typeof stream.getWriter === 'function'; - }, - - /** - * Detect if stream supports Node.js-style writing - */ - isNodeStream(stream) { - return stream && typeof stream.write === 'function'; - }, - - /** - * Write to either Bun or Node.js style stream - */ - async writeToStream(stream, data, contextName = 'stream') { - if (this.isBunStream(stream)) { - try { - const writer = stream.getWriter(); - await writer.write(data); - writer.releaseLock(); - return true; - } catch (error) { - return this.handleStreamError( - error, - `${contextName} Bun writer`, - false - ); - } - } else if (this.isNodeStream(stream)) { - try { - stream.write(data); - return true; - } catch (error) { - return this.handleStreamError( - error, - `${contextName} Node writer`, - false - ); - } - } - return false; - }, -}; - -let globalShellSettings = { - errexit: false, // set -e equivalent: exit on error - verbose: false, // set -v equivalent: print commands - xtrace: false, // set -x equivalent: trace execution - pipefail: false, // set -o pipefail equivalent: pipe failure detection - nounset: false, // set -u equivalent: error on undefined variables -}; - -function createResult({ code, stdout = '', stderr = '', stdin = '' }) { - return { - code, - stdout, - stderr, - stdin, - async text() { - return stdout; - }, - }; -} - -const virtualCommands = new Map(); - -let virtualCommandsEnabled = true; - -// EventEmitter-like implementation -class StreamEmitter { - constructor() { - this.listeners = new Map(); - } - - on(event, listener) { - trace( - 'StreamEmitter', - () => - `on() called | ${JSON.stringify({ - event, - hasExistingListeners: this.listeners.has(event), - listenerCount: this.listeners.get(event)?.length || 0, - })}` - ); - - if (!this.listeners.has(event)) { - this.listeners.set(event, []); - } - this.listeners.get(event).push(listener); - - // No auto-start - explicit start() or await will start the process - - return this; - } - - once(event, listener) { - trace('StreamEmitter', () => `once() called for event: ${event}`); - const onceWrapper = (...args) => { - this.off(event, onceWrapper); - listener(...args); - }; - return this.on(event, onceWrapper); - } - - emit(event, ...args) { - const eventListeners = this.listeners.get(event); - trace( - 'StreamEmitter', - () => - `Emitting event | ${JSON.stringify({ - event, - hasListeners: !!eventListeners, - listenerCount: eventListeners?.length || 0, - })}` - ); - if (eventListeners) { - // Create a copy to avoid issues if listeners modify the array - const listenersToCall = [...eventListeners]; - for (const listener of listenersToCall) { - listener(...args); - } - } - return this; - } - - off(event, listener) { - trace( - 'StreamEmitter', - () => - `off() called | ${JSON.stringify({ - event, - hasListeners: !!this.listeners.get(event), - listenerCount: this.listeners.get(event)?.length || 0, - })}` - ); - - const eventListeners = this.listeners.get(event); - if (eventListeners) { - const index = eventListeners.indexOf(listener); - if (index !== -1) { - eventListeners.splice(index, 1); - trace('StreamEmitter', () => `Removed listener at index ${index}`); - } - } - return this; - } -} - -function quote(value) { - if (value == null) { - return "''"; - } - if (Array.isArray(value)) { - return value.map(quote).join(' '); - } - if (typeof value !== 'string') { - value = String(value); - } - if (value === '') { - return "''"; - } - - // If the value is already properly quoted and doesn't need further escaping, - // check if we can use it as-is or with simpler quoting - if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { - // If it's already single-quoted and doesn't contain unescaped single quotes in the middle, - // we can potentially use it as-is - const inner = value.slice(1, -1); - if (!inner.includes("'")) { - // The inner content has no single quotes, so the original quoting is fine - return value; - } - } - - if (value.startsWith('"') && value.endsWith('"') && value.length > 2) { - // If it's already double-quoted, wrap it in single quotes to preserve it - return `'${value}'`; - } - - // Check if the string needs quoting at all - // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus - // This regex matches strings that DON'T need quoting - const safePattern = /^[a-zA-Z0-9_\-./=,+@:]+$/; - - if (safePattern.test(value)) { - // The string is safe and doesn't need quoting - return value; - } - - // Default behavior: wrap in single quotes and escape any internal single quotes - // This handles spaces, special shell characters, etc. - return `'${value.replace(/'/g, "'\\''")}'`; -} - -function buildShellCommand(strings, values) { - trace( - 'Utils', - () => - `buildShellCommand ENTER | ${JSON.stringify( - { - stringsLength: strings.length, - valuesLength: values.length, - }, - null, - 2 - )}` - ); - - // Special case: if we have a single value with empty surrounding strings, - // and the value looks like a complete shell command, treat it as raw - if ( - values.length === 1 && - strings.length === 2 && - strings[0] === '' && - strings[1] === '' && - typeof values[0] === 'string' - ) { - const commandStr = values[0]; - // Check if this looks like a complete shell command (contains spaces and shell-safe characters) - const commandPattern = /^[a-zA-Z0-9_\-./=,+@:\s"'`$(){}<>|&;*?[\]~\\]+$/; - if (commandPattern.test(commandStr) && commandStr.trim().length > 0) { - trace( - 'Utils', - () => - `BRANCH: buildShellCommand => COMPLETE_COMMAND | ${JSON.stringify({ command: commandStr }, null, 2)}` - ); - return commandStr; - } - } - - let out = ''; - for (let i = 0; i < strings.length; i++) { - out += strings[i]; - if (i < values.length) { - const v = values[i]; - if ( - v && - typeof v === 'object' && - Object.prototype.hasOwnProperty.call(v, 'raw') - ) { - trace( - 'Utils', - () => - `BRANCH: buildShellCommand => RAW_VALUE | ${JSON.stringify({ value: String(v.raw) }, null, 2)}` - ); - out += String(v.raw); - } else { - const quoted = quote(v); - trace( - 'Utils', - () => - `BRANCH: buildShellCommand => QUOTED_VALUE | ${JSON.stringify({ original: v, quoted }, null, 2)}` - ); - out += quoted; - } - } - } - - trace( - 'Utils', - () => - `buildShellCommand EXIT | ${JSON.stringify({ command: out }, null, 2)}` - ); - return out; -} - -function asBuffer(chunk) { - if (Buffer.isBuffer(chunk)) { - trace('Utils', () => `asBuffer: Already a buffer, length: ${chunk.length}`); - return chunk; - } - if (typeof chunk === 'string') { - trace( - 'Utils', - () => `asBuffer: Converting string to buffer, length: ${chunk.length}` - ); - return Buffer.from(chunk); - } - trace('Utils', () => 'asBuffer: Converting unknown type to buffer'); - return Buffer.from(chunk); -} - -async function pumpReadable(readable, onChunk) { - if (!readable) { - trace('Utils', () => 'pumpReadable: No readable stream provided'); - return; - } - trace('Utils', () => 'pumpReadable: Starting to pump readable stream'); - for await (const chunk of readable) { - await onChunk(asBuffer(chunk)); - } - trace('Utils', () => 'pumpReadable: Finished pumping readable stream'); -} - -// Enhanced process runner with streaming capabilities -class ProcessRunner extends StreamEmitter { - constructor(spec, options = {}) { - super(); - - trace( - 'ProcessRunner', - () => - `constructor ENTER | ${JSON.stringify( - { - spec: - typeof spec === 'object' - ? { ...spec, command: spec.command?.slice(0, 100) } - : spec, - options, - }, - null, - 2 - )}` - ); - - this.spec = spec; - this.options = { - mirror: true, - capture: true, - stdin: 'inherit', - cwd: undefined, - env: undefined, - interactive: false, // Explicitly request TTY forwarding for interactive commands - shellOperators: true, // Enable shell operator parsing by default - ...options, - }; - - this.outChunks = this.options.capture ? [] : null; - this.errChunks = this.options.capture ? [] : null; - this.inChunks = - this.options.capture && this.options.stdin === 'inherit' - ? [] - : this.options.capture && - (typeof this.options.stdin === 'string' || - Buffer.isBuffer(this.options.stdin)) - ? [Buffer.from(this.options.stdin)] - : []; - - this.result = null; - this.child = null; - this.started = false; - this.finished = false; - - // Promise for awaiting final result - this.promise = null; - - this._mode = null; // 'async' or 'sync' - - this._cancelled = false; - this._cancellationSignal = null; // Track which signal caused cancellation - this._virtualGenerator = null; - this._abortController = new AbortController(); - - activeProcessRunners.add(this); - - // Ensure parent stream monitoring is set up for all ProcessRunners - monitorParentStreams(); - - trace( - 'ProcessRunner', - () => - `Added to activeProcessRunners | ${JSON.stringify( - { - command: this.spec?.command || 'unknown', - totalActive: activeProcessRunners.size, - }, - null, - 2 - )}` - ); - installSignalHandlers(); - - this.finished = false; - } - - // Stream property getters for child process streams (null for virtual commands) - get stdout() { - trace( - 'ProcessRunner', - () => - `stdout getter accessed | ${JSON.stringify( - { - hasChild: !!this.child, - hasStdout: !!(this.child && this.child.stdout), - }, - null, - 2 - )}` - ); - return this.child ? this.child.stdout : null; - } - - get stderr() { - trace( - 'ProcessRunner', - () => - `stderr getter accessed | ${JSON.stringify( - { - hasChild: !!this.child, - hasStderr: !!(this.child && this.child.stderr), - }, - null, - 2 - )}` - ); - return this.child ? this.child.stderr : null; - } - - get stdin() { - trace( - 'ProcessRunner', - () => - `stdin getter accessed | ${JSON.stringify( - { - hasChild: !!this.child, - hasStdin: !!(this.child && this.child.stdin), - }, - null, - 2 - )}` - ); - return this.child ? this.child.stdin : null; - } - - // Issue #33: New streaming interfaces - _autoStartIfNeeded(reason) { - if (!this.started && !this.finished) { - trace('ProcessRunner', () => `Auto-starting process due to ${reason}`); - this.start({ - mode: 'async', - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); - } - } - - get streams() { - const self = this; - return { - get stdin() { - trace( - 'ProcessRunner.streams', - () => - `stdin access | ${JSON.stringify( - { - hasChild: !!self.child, - hasStdin: !!(self.child && self.child.stdin), - started: self.started, - finished: self.finished, - hasPromise: !!self.promise, - command: self.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - self._autoStartIfNeeded('streams.stdin access'); - - // Streams are available immediately after spawn, or null if not piped - // Return the stream directly if available, otherwise ensure process starts - if (self.child && self.child.stdin) { - trace( - 'ProcessRunner.streams', - () => 'stdin: returning existing stream' - ); - return self.child.stdin; - } - if (self.finished) { - trace( - 'ProcessRunner.streams', - () => 'stdin: process finished, returning null' - ); - return null; - } - - // For virtual commands, there's no child process - // Exception: virtual commands with stdin: "pipe" will fallback to real commands - const isVirtualCommand = - self._virtualGenerator || - (self.spec && - self.spec.command && - virtualCommands.has(self.spec.command.split(' ')[0])); - const willFallbackToReal = - isVirtualCommand && self.options.stdin === 'pipe'; - - if (isVirtualCommand && !willFallbackToReal) { - trace( - 'ProcessRunner.streams', - () => 'stdin: virtual command, returning null' - ); - return null; - } - - // If not started, start it and wait for child to be created (not for completion!) - if (!self.started) { - trace( - 'ProcessRunner.streams', - () => 'stdin: not started, starting and waiting for child' - ); - // Start the process - self._startAsync(); - // Wait for child to be created using async iteration - return new Promise((resolve) => { - const checkForChild = () => { - if (self.child && self.child.stdin) { - resolve(self.child.stdin); - } else if (self.finished || self._virtualGenerator) { - resolve(null); - } else { - // Use setImmediate to check again in next event loop iteration - setImmediate(checkForChild); - } - }; - setImmediate(checkForChild); - }); - } - - // Process is starting - wait for child to appear - if (self.promise && !self.child) { - trace( - 'ProcessRunner.streams', - () => 'stdin: process starting, waiting for child' - ); - return new Promise((resolve) => { - const checkForChild = () => { - if (self.child && self.child.stdin) { - resolve(self.child.stdin); - } else if (self.finished || self._virtualGenerator) { - resolve(null); - } else { - setImmediate(checkForChild); - } - }; - setImmediate(checkForChild); - }); - } - - trace( - 'ProcessRunner.streams', - () => 'stdin: returning null (no conditions met)' - ); - return null; - }, - get stdout() { - trace( - 'ProcessRunner.streams', - () => - `stdout access | ${JSON.stringify( - { - hasChild: !!self.child, - hasStdout: !!(self.child && self.child.stdout), - started: self.started, - finished: self.finished, - hasPromise: !!self.promise, - command: self.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - self._autoStartIfNeeded('streams.stdout access'); - - if (self.child && self.child.stdout) { - trace( - 'ProcessRunner.streams', - () => 'stdout: returning existing stream' - ); - return self.child.stdout; - } - if (self.finished) { - trace( - 'ProcessRunner.streams', - () => 'stdout: process finished, returning null' - ); - return null; - } - - // For virtual commands, there's no child process - if ( - self._virtualGenerator || - (self.spec && - self.spec.command && - virtualCommands.has(self.spec.command.split(' ')[0])) - ) { - trace( - 'ProcessRunner.streams', - () => 'stdout: virtual command, returning null' - ); - return null; - } - - if (!self.started) { - trace( - 'ProcessRunner.streams', - () => 'stdout: not started, starting and waiting for child' - ); - self._startAsync(); - return new Promise((resolve) => { - const checkForChild = () => { - if (self.child && self.child.stdout) { - resolve(self.child.stdout); - } else if (self.finished || self._virtualGenerator) { - resolve(null); - } else { - setImmediate(checkForChild); - } - }; - setImmediate(checkForChild); - }); - } - - if (self.promise && !self.child) { - trace( - 'ProcessRunner.streams', - () => 'stdout: process starting, waiting for child' - ); - return new Promise((resolve) => { - const checkForChild = () => { - if (self.child && self.child.stdout) { - resolve(self.child.stdout); - } else if (self.finished || self._virtualGenerator) { - resolve(null); - } else { - setImmediate(checkForChild); - } - }; - setImmediate(checkForChild); - }); - } - - trace( - 'ProcessRunner.streams', - () => 'stdout: returning null (no conditions met)' - ); - return null; - }, - get stderr() { - trace( - 'ProcessRunner.streams', - () => - `stderr access | ${JSON.stringify( - { - hasChild: !!self.child, - hasStderr: !!(self.child && self.child.stderr), - started: self.started, - finished: self.finished, - hasPromise: !!self.promise, - command: self.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - self._autoStartIfNeeded('streams.stderr access'); - - if (self.child && self.child.stderr) { - trace( - 'ProcessRunner.streams', - () => 'stderr: returning existing stream' - ); - return self.child.stderr; - } - if (self.finished) { - trace( - 'ProcessRunner.streams', - () => 'stderr: process finished, returning null' - ); - return null; - } - - // For virtual commands, there's no child process - if ( - self._virtualGenerator || - (self.spec && - self.spec.command && - virtualCommands.has(self.spec.command.split(' ')[0])) - ) { - trace( - 'ProcessRunner.streams', - () => 'stderr: virtual command, returning null' - ); - return null; - } - - if (!self.started) { - trace( - 'ProcessRunner.streams', - () => 'stderr: not started, starting and waiting for child' - ); - self._startAsync(); - return new Promise((resolve) => { - const checkForChild = () => { - if (self.child && self.child.stderr) { - resolve(self.child.stderr); - } else if (self.finished || self._virtualGenerator) { - resolve(null); - } else { - setImmediate(checkForChild); - } - }; - setImmediate(checkForChild); - }); - } - - if (self.promise && !self.child) { - trace( - 'ProcessRunner.streams', - () => 'stderr: process starting, waiting for child' - ); - return new Promise((resolve) => { - const checkForChild = () => { - if (self.child && self.child.stderr) { - resolve(self.child.stderr); - } else if (self.finished || self._virtualGenerator) { - resolve(null); - } else { - setImmediate(checkForChild); - } - }; - setImmediate(checkForChild); - }); - } - - trace( - 'ProcessRunner.streams', - () => 'stderr: returning null (no conditions met)' - ); - return null; - }, - }; - } - - get buffers() { - const self = this; - return { - get stdin() { - self._autoStartIfNeeded('buffers.stdin access'); - if (self.finished && self.result) { - return Buffer.from(self.result.stdin || '', 'utf8'); - } - // Return promise if not finished - return self.then - ? self.then((result) => Buffer.from(result.stdin || '', 'utf8')) - : Promise.resolve(Buffer.alloc(0)); - }, - get stdout() { - self._autoStartIfNeeded('buffers.stdout access'); - if (self.finished && self.result) { - return Buffer.from(self.result.stdout || '', 'utf8'); - } - // Return promise if not finished - return self.then - ? self.then((result) => Buffer.from(result.stdout || '', 'utf8')) - : Promise.resolve(Buffer.alloc(0)); - }, - get stderr() { - self._autoStartIfNeeded('buffers.stderr access'); - if (self.finished && self.result) { - return Buffer.from(self.result.stderr || '', 'utf8'); - } - // Return promise if not finished - return self.then - ? self.then((result) => Buffer.from(result.stderr || '', 'utf8')) - : Promise.resolve(Buffer.alloc(0)); - }, - }; - } - - get strings() { - const self = this; - return { - get stdin() { - self._autoStartIfNeeded('strings.stdin access'); - if (self.finished && self.result) { - return self.result.stdin || ''; - } - // Return promise if not finished - return self.then - ? self.then((result) => result.stdin || '') - : Promise.resolve(''); - }, - get stdout() { - self._autoStartIfNeeded('strings.stdout access'); - if (self.finished && self.result) { - return self.result.stdout || ''; - } - // Return promise if not finished - return self.then - ? self.then((result) => result.stdout || '') - : Promise.resolve(''); - }, - get stderr() { - self._autoStartIfNeeded('strings.stderr access'); - if (self.finished && self.result) { - return self.result.stderr || ''; - } - // Return promise if not finished - return self.then - ? self.then((result) => result.stderr || '') - : Promise.resolve(''); - }, - }; - } - - // Centralized method to properly finish a process with correct event emission order - finish(result) { - trace( - 'ProcessRunner', - () => - `finish() called | ${JSON.stringify( - { - alreadyFinished: this.finished, - resultCode: result?.code, - hasStdout: !!result?.stdout, - hasStderr: !!result?.stderr, - command: this.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - // Make finish() idempotent - safe to call multiple times - if (this.finished) { - trace( - 'ProcessRunner', - () => `Already finished, returning existing result` - ); - return this.result || result; - } - - // Store result - this.result = result; - trace('ProcessRunner', () => `Result stored, about to emit events`); - - // Emit completion events BEFORE setting finished to prevent _cleanup() from clearing listeners - this.emit('end', result); - trace('ProcessRunner', () => `'end' event emitted`); - this.emit('exit', result.code); - trace( - 'ProcessRunner', - () => `'exit' event emitted with code ${result.code}` - ); - - // Set finished after events are emitted - this.finished = true; - trace('ProcessRunner', () => `Marked as finished, calling cleanup`); - - // Trigger cleanup now that process is finished - this._cleanup(); - trace('ProcessRunner', () => `Cleanup completed`); - - return result; - } - - _emitProcessedData(type, buf) { - // Don't emit data if we've been cancelled - if (this._cancelled) { - trace( - 'ProcessRunner', - () => 'Skipping data emission - process cancelled' - ); - return; - } - const processedBuf = processOutput(buf, this.options.ansi); - this.emit(type, processedBuf); - this.emit('data', { type, data: processedBuf }); - } - - async _forwardTTYStdin() { - trace( - 'ProcessRunner', - () => - `_forwardTTYStdin ENTER | ${JSON.stringify( - { - isTTY: process.stdin.isTTY, - hasChildStdin: !!this.child?.stdin, - }, - null, - 2 - )}` - ); - - if (!process.stdin.isTTY || !this.child.stdin) { - trace( - 'ProcessRunner', - () => 'TTY forwarding skipped - no TTY or no child stdin' - ); - return; - } - - try { - // Set raw mode to forward keystrokes immediately - if (process.stdin.setRawMode) { - process.stdin.setRawMode(true); - } - process.stdin.resume(); - - // Forward stdin data to child process - const onData = (chunk) => { - // Check for CTRL+C (ASCII code 3) - if (chunk[0] === 3) { - trace( - 'ProcessRunner', - () => 'CTRL+C detected, sending SIGINT to child process' - ); - // Send SIGINT to the child process - if (this.child && this.child.pid) { - try { - if (isBun) { - this.child.kill('SIGINT'); - } else { - // In Node.js, send SIGINT to the process group if detached - // or to the process directly if not - if (this.child.pid > 0) { - try { - // Try process group first if detached - process.kill(-this.child.pid, 'SIGINT'); - } catch (err) { - // Fall back to direct process - process.kill(this.child.pid, 'SIGINT'); - } - } - } - } catch (err) { - trace( - 'ProcessRunner', - () => `Error sending SIGINT: ${err.message}` - ); - } - } - // Don't forward CTRL+C to stdin, just handle the signal - return; - } - - // Forward other input to child stdin - if (this.child.stdin) { - if (isBun && this.child.stdin.write) { - this.child.stdin.write(chunk); - } else if (this.child.stdin.write) { - this.child.stdin.write(chunk); - } - } - }; - - const cleanup = () => { - trace( - 'ProcessRunner', - () => 'TTY stdin cleanup - restoring terminal mode' - ); - process.stdin.removeListener('data', onData); - if (process.stdin.setRawMode) { - process.stdin.setRawMode(false); - } - process.stdin.pause(); - }; - - process.stdin.on('data', onData); - - // Clean up when child process exits - const childExit = isBun - ? this.child.exited - : new Promise((resolve) => { - this.child.once('close', resolve); - this.child.once('exit', resolve); - }); - - childExit.then(cleanup).catch(cleanup); - - return childExit; - } catch (error) { - trace( - 'ProcessRunner', - () => - `TTY stdin forwarding error | ${JSON.stringify({ error: error.message }, null, 2)}` - ); - } - } - - _handleParentStreamClosure() { - if (this.finished || this._cancelled) { - trace( - 'ProcessRunner', - () => - `Parent stream closure ignored | ${JSON.stringify({ - finished: this.finished, - cancelled: this._cancelled, - })}` - ); - return; - } - - trace( - 'ProcessRunner', - () => - `Handling parent stream closure | ${JSON.stringify( - { - started: this.started, - hasChild: !!this.child, - command: this.spec.command?.slice(0, 50) || this.spec.file, - }, - null, - 2 - )}` - ); - - this._cancelled = true; - - // Cancel abort controller for virtual commands - if (this._abortController) { - this._abortController.abort(); - } - - // Gracefully close child process if it exists - if (this.child) { - try { - // Close stdin first to signal completion - if (this.child.stdin && typeof this.child.stdin.end === 'function') { - this.child.stdin.end(); - } else if ( - isBun && - this.child.stdin && - typeof this.child.stdin.getWriter === 'function' - ) { - const writer = this.child.stdin.getWriter(); - writer.close().catch(() => {}); // Ignore close errors - } - - // Use setImmediate for deferred termination instead of setTimeout - setImmediate(() => { - if (this.child && !this.finished) { - trace( - 'ProcessRunner', - () => 'Terminating child process after parent stream closure' - ); - if (typeof this.child.kill === 'function') { - this.child.kill('SIGTERM'); - } - } - }); - } catch (error) { - trace( - 'ProcessRunner', - () => - `Error during graceful shutdown | ${JSON.stringify({ error: error.message }, null, 2)}` - ); - } - } - - this._cleanup(); - } - - _cleanup() { - trace( - 'ProcessRunner', - () => - `_cleanup() called | ${JSON.stringify( - { - wasActiveBeforeCleanup: activeProcessRunners.has(this), - totalActiveBefore: activeProcessRunners.size, - finished: this.finished, - hasChild: !!this.child, - command: this.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - const wasActive = activeProcessRunners.has(this); - activeProcessRunners.delete(this); - - if (wasActive) { - trace( - 'ProcessRunner', - () => - `Removed from activeProcessRunners | ${JSON.stringify( - { - command: this.spec?.command || 'unknown', - totalActiveAfter: activeProcessRunners.size, - remainingCommands: Array.from(activeProcessRunners).map((r) => - r.spec?.command?.slice(0, 30) - ), - }, - null, - 2 - )}` - ); - } else { - trace( - 'ProcessRunner', - () => `Was not in activeProcessRunners (already cleaned up)` - ); - } - - // If this is a pipeline runner, also clean up the source and destination - if (this.spec?.mode === 'pipeline') { - trace('ProcessRunner', () => 'Cleaning up pipeline components'); - if (this.spec.source && typeof this.spec.source._cleanup === 'function') { - this.spec.source._cleanup(); - } - if ( - this.spec.destination && - typeof this.spec.destination._cleanup === 'function' - ) { - this.spec.destination._cleanup(); - } - } - - // If no more active ProcessRunners, remove the SIGINT handler - if (activeProcessRunners.size === 0) { - uninstallSignalHandlers(); - } - - // Clean up event listeners from StreamEmitter - if (this.listeners) { - this.listeners.clear(); - } - - // Clean up abort controller - if (this._abortController) { - trace( - 'ProcessRunner', - () => - `Cleaning up abort controller during cleanup | ${JSON.stringify( - { - wasAborted: this._abortController?.signal?.aborted, - }, - null, - 2 - )}` - ); - try { - this._abortController.abort(); - trace( - 'ProcessRunner', - () => `Abort controller aborted successfully during cleanup` - ); - } catch (e) { - trace( - 'ProcessRunner', - () => `Error aborting controller during cleanup: ${e.message}` - ); - } - this._abortController = null; - trace( - 'ProcessRunner', - () => `Abort controller reference cleared during cleanup` - ); - } else { - trace( - 'ProcessRunner', - () => `No abort controller to clean up during cleanup` - ); - } - - // Clean up child process reference - if (this.child) { - trace( - 'ProcessRunner', - () => - `Cleaning up child process reference | ${JSON.stringify( - { - hasChild: true, - childPid: this.child.pid, - childKilled: this.child.killed, - }, - null, - 2 - )}` - ); - try { - this.child.removeAllListeners?.(); - trace( - 'ProcessRunner', - () => `Child process listeners removed successfully` - ); - } catch (e) { - trace( - 'ProcessRunner', - () => `Error removing child process listeners: ${e.message}` - ); - } - this.child = null; - trace('ProcessRunner', () => `Child process reference cleared`); - } else { - trace('ProcessRunner', () => `No child process reference to clean up`); - } - - // Clean up virtual generator - if (this._virtualGenerator) { - trace( - 'ProcessRunner', - () => - `Cleaning up virtual generator | ${JSON.stringify( - { - hasReturn: !!this._virtualGenerator.return, - }, - null, - 2 - )}` - ); - try { - if (this._virtualGenerator.return) { - this._virtualGenerator.return(); - trace( - 'ProcessRunner', - () => `Virtual generator return() called successfully` - ); - } - } catch (e) { - trace( - 'ProcessRunner', - () => `Error calling virtual generator return(): ${e.message}` - ); - } - this._virtualGenerator = null; - trace('ProcessRunner', () => `Virtual generator reference cleared`); - } else { - trace('ProcessRunner', () => `No virtual generator to clean up`); - } - - trace( - 'ProcessRunner', - () => - `_cleanup() completed | ${JSON.stringify( - { - totalActiveAfter: activeProcessRunners.size, - sigintListenerCount: process.listeners('SIGINT').length, - }, - null, - 2 - )}` - ); - } - - // Unified start method that can work in both async and sync modes - start(options = {}) { - const mode = options.mode || 'async'; - - trace( - 'ProcessRunner', - () => - `start ENTER | ${JSON.stringify( - { - mode, - options, - started: this.started, - hasPromise: !!this.promise, - hasChild: !!this.child, - command: this.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - // Merge new options with existing options before starting - if (Object.keys(options).length > 0 && !this.started) { - trace( - 'ProcessRunner', - () => - `BRANCH: options => MERGE | ${JSON.stringify( - { - oldOptions: this.options, - newOptions: options, - }, - null, - 2 - )}` - ); - - // Create a new options object merging the current ones with the new ones - this.options = { ...this.options, ...options }; - - // Handle external abort signal - if ( - this.options.signal && - typeof this.options.signal.addEventListener === 'function' - ) { - trace( - 'ProcessRunner', - () => - `Setting up external abort signal listener | ${JSON.stringify( - { - hasSignal: !!this.options.signal, - signalAborted: this.options.signal.aborted, - hasInternalController: !!this._abortController, - internalAborted: this._abortController?.signal.aborted, - }, - null, - 2 - )}` - ); - - this.options.signal.addEventListener('abort', () => { - trace( - 'ProcessRunner', - () => - `External abort signal triggered | ${JSON.stringify( - { - externalSignalAborted: this.options.signal.aborted, - hasInternalController: !!this._abortController, - internalAborted: this._abortController?.signal.aborted, - command: this.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - // Kill the process when abort signal is triggered - trace( - 'ProcessRunner', - () => - `External abort signal received - killing process | ${JSON.stringify( - { - hasChild: !!this.child, - childPid: this.child?.pid, - finished: this.finished, - command: this.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - this.kill('SIGTERM'); - trace( - 'ProcessRunner', - () => 'Process kill initiated due to external abort signal' - ); - - if (this._abortController && !this._abortController.signal.aborted) { - trace( - 'ProcessRunner', - () => 'Aborting internal controller due to external signal' - ); - this._abortController.abort(); - trace( - 'ProcessRunner', - () => - `Internal controller aborted | ${JSON.stringify( - { - internalAborted: this._abortController?.signal?.aborted, - }, - null, - 2 - )}` - ); - } else { - trace( - 'ProcessRunner', - () => - `Cannot abort internal controller | ${JSON.stringify( - { - hasInternalController: !!this._abortController, - internalAlreadyAborted: - this._abortController?.signal?.aborted, - }, - null, - 2 - )}` - ); - } - }); - - // If the external signal is already aborted, abort immediately - if (this.options.signal.aborted) { - trace( - 'ProcessRunner', - () => - `External signal already aborted, killing process and aborting internal controller | ${JSON.stringify( - { - hasInternalController: !!this._abortController, - internalAborted: this._abortController?.signal.aborted, - }, - null, - 2 - )}` - ); - - // Kill the process immediately since signal is already aborted - trace( - 'ProcessRunner', - () => - `Signal already aborted - killing process immediately | ${JSON.stringify( - { - hasChild: !!this.child, - childPid: this.child?.pid, - finished: this.finished, - command: this.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - this.kill('SIGTERM'); - trace( - 'ProcessRunner', - () => 'Process kill initiated due to pre-aborted signal' - ); - - if (this._abortController && !this._abortController.signal.aborted) { - this._abortController.abort(); - trace( - 'ProcessRunner', - () => - `Internal controller aborted immediately | ${JSON.stringify( - { - internalAborted: this._abortController?.signal?.aborted, - }, - null, - 2 - )}` - ); - } - } - } else { - trace( - 'ProcessRunner', - () => - `No external signal to handle | ${JSON.stringify( - { - hasSignal: !!this.options.signal, - signalType: typeof this.options.signal, - hasAddEventListener: !!( - this.options.signal && - typeof this.options.signal.addEventListener === 'function' - ), - }, - null, - 2 - )}` - ); - } - - // Reinitialize chunks based on updated capture option - if ('capture' in options) { - trace( - 'ProcessRunner', - () => - `BRANCH: capture => REINIT_CHUNKS | ${JSON.stringify( - { - capture: this.options.capture, - }, - null, - 2 - )}` - ); - - this.outChunks = this.options.capture ? [] : null; - this.errChunks = this.options.capture ? [] : null; - this.inChunks = - this.options.capture && this.options.stdin === 'inherit' - ? [] - : this.options.capture && - (typeof this.options.stdin === 'string' || - Buffer.isBuffer(this.options.stdin)) - ? [Buffer.from(this.options.stdin)] - : []; - } - - trace( - 'ProcessRunner', - () => - `OPTIONS_MERGED | ${JSON.stringify( - { - finalOptions: this.options, - }, - null, - 2 - )}` - ); - } else if (Object.keys(options).length > 0 && this.started) { - trace( - 'ProcessRunner', - () => - `BRANCH: options => IGNORED_ALREADY_STARTED | ${JSON.stringify({}, null, 2)}` - ); - } - - if (mode === 'sync') { - trace( - 'ProcessRunner', - () => `BRANCH: mode => sync | ${JSON.stringify({}, null, 2)}` - ); - return this._startSync(); - } else { - trace( - 'ProcessRunner', - () => `BRANCH: mode => async | ${JSON.stringify({}, null, 2)}` - ); - return this._startAsync(); - } - } - - // Shortcut for sync mode - sync() { - return this.start({ mode: 'sync' }); - } - - // Shortcut for async mode - async() { - return this.start({ mode: 'async' }); - } - - // Alias for start() method - run(options = {}) { - trace( - 'ProcessRunner', - () => `run ENTER | ${JSON.stringify({ options }, null, 2)}` - ); - return this.start(options); - } - - async _startAsync() { - if (this.started) { - return this.promise; - } - if (this.promise) { - return this.promise; - } - - this.promise = this._doStartAsync(); - return this.promise; - } - - async _doStartAsync() { - trace( - 'ProcessRunner', - () => - `_doStartAsync ENTER | ${JSON.stringify( - { - mode: this.spec.mode, - command: this.spec.command?.slice(0, 100), - }, - null, - 2 - )}` - ); - - this.started = true; - this._mode = 'async'; - - // Ensure cleanup happens even if execution fails - try { - const { cwd, env, stdin } = this.options; - - if (this.spec.mode === 'pipeline') { - trace( - 'ProcessRunner', - () => - `BRANCH: spec.mode => pipeline | ${JSON.stringify( - { - hasSource: !!this.spec.source, - hasDestination: !!this.spec.destination, - }, - null, - 2 - )}` - ); - return await this._runProgrammaticPipeline( - this.spec.source, - this.spec.destination - ); - } - - if (this.spec.mode === 'shell') { - trace( - 'ProcessRunner', - () => `BRANCH: spec.mode => shell | ${JSON.stringify({}, null, 2)}` - ); - - // Check if shell operator parsing is enabled and command contains operators - const hasShellOperators = - this.spec.command.includes('&&') || - this.spec.command.includes('||') || - this.spec.command.includes('(') || - this.spec.command.includes(';') || - (this.spec.command.includes('cd ') && - this.spec.command.includes('&&')); - - // Intelligent detection: disable shell operators for streaming patterns - const isStreamingPattern = - this.spec.command.includes('sleep') && - this.spec.command.includes(';') && - (this.spec.command.includes('echo') || - this.spec.command.includes('printf')); - - // Also check if we're in streaming mode (via .stream() method) - const shouldUseShellOperators = - this.options.shellOperators && - hasShellOperators && - !isStreamingPattern && - !this._isStreaming; - - trace( - 'ProcessRunner', - () => - `Shell operator detection | ${JSON.stringify( - { - hasShellOperators, - shellOperatorsEnabled: this.options.shellOperators, - isStreamingPattern, - isStreaming: this._isStreaming, - shouldUseShellOperators, - command: this.spec.command.slice(0, 100), - }, - null, - 2 - )}` - ); - - // Only use enhanced parser when appropriate - if ( - !this.options._bypassVirtual && - shouldUseShellOperators && - !needsRealShell(this.spec.command) - ) { - const enhancedParsed = parseShellCommand(this.spec.command); - if (enhancedParsed && enhancedParsed.type !== 'simple') { - trace( - 'ProcessRunner', - () => - `Using enhanced parser for shell operators | ${JSON.stringify( - { - type: enhancedParsed.type, - command: this.spec.command.slice(0, 50), - }, - null, - 2 - )}` - ); - - if (enhancedParsed.type === 'sequence') { - return await this._runSequence(enhancedParsed); - } else if (enhancedParsed.type === 'subshell') { - return await this._runSubshell(enhancedParsed); - } else if (enhancedParsed.type === 'pipeline') { - return await this._runPipeline(enhancedParsed.commands); - } - } - } - - // Fallback to original simple parser - const parsed = this._parseCommand(this.spec.command); - trace( - 'ProcessRunner', - () => - `Parsed command | ${JSON.stringify( - { - type: parsed?.type, - cmd: parsed?.cmd, - argsCount: parsed?.args?.length, - }, - null, - 2 - )}` - ); - - if (parsed) { - if (parsed.type === 'pipeline') { - trace( - 'ProcessRunner', - () => - `BRANCH: parsed.type => pipeline | ${JSON.stringify( - { - commandCount: parsed.commands?.length, - }, - null, - 2 - )}` - ); - return await this._runPipeline(parsed.commands); - } else if ( - parsed.type === 'simple' && - virtualCommandsEnabled && - virtualCommands.has(parsed.cmd) && - !this.options._bypassVirtual - ) { - // For built-in virtual commands that have real counterparts (like sleep), - // skip the virtual version when custom stdin is provided to ensure proper process handling - const hasCustomStdin = - this.options.stdin && - this.options.stdin !== 'inherit' && - this.options.stdin !== 'ignore'; - - // Only bypass for commands that truly need real process behavior with custom stdin - // Most commands like 'echo' work fine with virtual implementations even with stdin - const commandsThatNeedRealStdin = ['sleep', 'cat']; // Only these really need real processes for stdin - const shouldBypassVirtual = - hasCustomStdin && commandsThatNeedRealStdin.includes(parsed.cmd); - - if (shouldBypassVirtual) { - trace( - 'ProcessRunner', - () => - `Bypassing built-in virtual command due to custom stdin | ${JSON.stringify( - { - cmd: parsed.cmd, - stdin: typeof this.options.stdin, - }, - null, - 2 - )}` - ); - // Fall through to run as real command - } else { - trace( - 'ProcessRunner', - () => - `BRANCH: virtualCommand => ${parsed.cmd} | ${JSON.stringify( - { - isVirtual: true, - args: parsed.args, - }, - null, - 2 - )}` - ); - trace( - 'ProcessRunner', - () => - `Executing virtual command | ${JSON.stringify( - { - cmd: parsed.cmd, - argsLength: parsed.args.length, - command: this.spec.command, - }, - null, - 2 - )}` - ); - return await this._runVirtual( - parsed.cmd, - parsed.args, - this.spec.command - ); - } - } - } - } - - const shell = findAvailableShell(); - const argv = - this.spec.mode === 'shell' - ? [shell.cmd, ...shell.args, this.spec.command] - : [this.spec.file, ...this.spec.args]; - trace( - 'ProcessRunner', - () => - `Constructed argv | ${JSON.stringify( - { - mode: this.spec.mode, - argv, - originalCommand: this.spec.command, - }, - null, - 2 - )}` - ); - - if (globalShellSettings.xtrace) { - const traceCmd = - this.spec.mode === 'shell' ? this.spec.command : argv.join(' '); - console.log(`+ ${traceCmd}`); - trace('ProcessRunner', () => `xtrace output displayed: + ${traceCmd}`); - } - - if (globalShellSettings.verbose) { - const verboseCmd = - this.spec.mode === 'shell' ? this.spec.command : argv.join(' '); - console.log(verboseCmd); - trace('ProcessRunner', () => `verbose output displayed: ${verboseCmd}`); - } - - // Detect if this is an interactive command that needs direct TTY access - // Only activate for interactive commands when we have a real TTY and interactive mode is explicitly requested - const isInteractive = - stdin === 'inherit' && - process.stdin.isTTY === true && - process.stdout.isTTY === true && - process.stderr.isTTY === true && - this.options.interactive === true; - - trace( - 'ProcessRunner', - () => - `Interactive command detection | ${JSON.stringify( - { - isInteractive, - stdinInherit: stdin === 'inherit', - stdinTTY: process.stdin.isTTY, - stdoutTTY: process.stdout.isTTY, - stderrTTY: process.stderr.isTTY, - interactiveOption: this.options.interactive, - }, - null, - 2 - )}` - ); - - const spawnBun = (argv) => { - trace( - 'ProcessRunner', - () => - `spawnBun: Creating process | ${JSON.stringify( - { - command: argv[0], - args: argv.slice(1), - isInteractive, - cwd, - platform: process.platform, - }, - null, - 2 - )}` - ); - - if (isInteractive) { - // For interactive commands, use inherit to provide direct TTY access - trace( - 'ProcessRunner', - () => `spawnBun: Using interactive mode with inherited stdio` - ); - const child = Bun.spawn(argv, { - cwd, - env, - stdin: 'inherit', - stdout: 'inherit', - stderr: 'inherit', - }); - trace( - 'ProcessRunner', - () => - `spawnBun: Interactive process created | ${JSON.stringify( - { - pid: child.pid, - killed: child.killed, - }, - null, - 2 - )}` - ); - return child; - } - // For non-interactive commands, spawn with detached to create process group (for proper signal handling) - // This allows us to send signals to the entire process group, killing shell and all its children - trace( - 'ProcessRunner', - () => - `spawnBun: Using non-interactive mode with pipes and detached=${process.platform !== 'win32'}` - ); - trace( - 'ProcessRunner', - () => - `spawnBun: About to spawn | ${JSON.stringify( - { - argv, - cwd, - shellCmd: argv[0], - shellArgs: argv.slice(1, -1), - command: argv[argv.length - 1]?.slice(0, 50), - }, - null, - 2 - )}` - ); - - const child = Bun.spawn(argv, { - cwd, - env, - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - detached: process.platform !== 'win32', // Create process group on Unix-like systems - }); - trace( - 'ProcessRunner', - () => - `spawnBun: Non-interactive process created | ${JSON.stringify( - { - pid: child.pid, - killed: child.killed, - hasStdout: !!child.stdout, - hasStderr: !!child.stderr, - hasStdin: !!child.stdin, - }, - null, - 2 - )}` - ); - return child; - }; - const spawnNode = async (argv) => { - trace( - 'ProcessRunner', - () => - `spawnNode: Creating process | ${JSON.stringify({ - command: argv[0], - args: argv.slice(1), - isInteractive, - cwd, - platform: process.platform, - })}` - ); - - if (isInteractive) { - // For interactive commands, use inherit to provide direct TTY access - return cp.spawn(argv[0], argv.slice(1), { - cwd, - env, - stdio: 'inherit', - }); - } - // For non-interactive commands, spawn with detached to create process group (for proper signal handling) - // This allows us to send signals to the entire process group - const child = cp.spawn(argv[0], argv.slice(1), { - cwd, - env, - stdio: ['pipe', 'pipe', 'pipe'], - detached: process.platform !== 'win32', // Create process group on Unix-like systems - }); - - trace( - 'ProcessRunner', - () => - `spawnNode: Process created | ${JSON.stringify({ - pid: child.pid, - killed: child.killed, - hasStdout: !!child.stdout, - hasStderr: !!child.stderr, - hasStdin: !!child.stdin, - })}` - ); - - return child; - }; - - const needsExplicitPipe = stdin !== 'inherit' && stdin !== 'ignore'; - const preferNodeForInput = isBun && needsExplicitPipe; - trace( - 'ProcessRunner', - () => - `About to spawn process | ${JSON.stringify( - { - needsExplicitPipe, - preferNodeForInput, - runtime: isBun ? 'Bun' : 'Node', - command: argv[0], - args: argv.slice(1), - }, - null, - 2 - )}` - ); - this.child = preferNodeForInput - ? await spawnNode(argv) - : isBun - ? spawnBun(argv) - : await spawnNode(argv); - - // Add detailed logging for CI debugging - if (this.child) { - trace( - 'ProcessRunner', - () => - `Child process created | ${JSON.stringify( - { - pid: this.child.pid, - detached: this.child.options?.detached, - killed: this.child.killed, - exitCode: this.child.exitCode, - signalCode: this.child.signalCode, - hasStdout: !!this.child.stdout, - hasStderr: !!this.child.stderr, - hasStdin: !!this.child.stdin, - platform: process.platform, - command: this.spec?.command?.slice(0, 100), - }, - null, - 2 - )}` - ); - - // Add event listeners with detailed tracing (only for Node.js child processes) - if (this.child && typeof this.child.on === 'function') { - this.child.on('spawn', () => { - trace( - 'ProcessRunner', - () => - `Child process spawned successfully | ${JSON.stringify( - { - pid: this.child.pid, - command: this.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - }); - - this.child.on('error', (error) => { - trace( - 'ProcessRunner', - () => - `Child process error event | ${JSON.stringify( - { - pid: this.child?.pid, - error: error.message, - code: error.code, - errno: error.errno, - syscall: error.syscall, - command: this.spec?.command?.slice(0, 50), - }, - null, - 2 - )}` - ); - }); - } else { - trace( - 'ProcessRunner', - () => - `Skipping event listeners - child does not support .on() method (likely Bun process)` - ); - } - } else { - trace( - 'ProcessRunner', - () => - `No child process created | ${JSON.stringify( - { - spec: this.spec, - hasVirtualGenerator: !!this._virtualGenerator, - }, - null, - 2 - )}` - ); - } - - // For interactive commands with stdio: 'inherit', stdout/stderr will be null - const childPid = this.child?.pid; // Capture PID once at the start - const outPump = this.child.stdout - ? pumpReadable(this.child.stdout, async (buf) => { - trace( - 'ProcessRunner', - () => - `stdout data received | ${JSON.stringify({ - pid: childPid, - bufferLength: buf.length, - capture: this.options.capture, - mirror: this.options.mirror, - preview: buf.toString().slice(0, 100), - })}` - ); - - if (this.options.capture) { - this.outChunks.push(buf); - } - if (this.options.mirror) { - safeWrite(process.stdout, buf); - } - - // Emit chunk events - this._emitProcessedData('stdout', buf); - }) - : Promise.resolve(); - - const errPump = this.child.stderr - ? pumpReadable(this.child.stderr, async (buf) => { - trace( - 'ProcessRunner', - () => - `stderr data received | ${JSON.stringify({ - pid: childPid, - bufferLength: buf.length, - capture: this.options.capture, - mirror: this.options.mirror, - preview: buf.toString().slice(0, 100), - })}` - ); - - if (this.options.capture) { - this.errChunks.push(buf); - } - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - - // Emit chunk events - this._emitProcessedData('stderr', buf); - }) - : Promise.resolve(); - - let stdinPumpPromise = Promise.resolve(); - trace( - 'ProcessRunner', - () => - `Setting up stdin handling | ${JSON.stringify( - { - stdinType: typeof stdin, - stdin: - stdin === 'inherit' - ? 'inherit' - : stdin === 'ignore' - ? 'ignore' - : typeof stdin === 'string' - ? `string(${stdin.length})` - : 'other', - isInteractive, - hasChildStdin: !!this.child?.stdin, - processTTY: process.stdin.isTTY, - }, - null, - 2 - )}` - ); - - if (stdin === 'inherit') { - if (isInteractive) { - // For interactive commands with stdio: 'inherit', stdin is handled automatically - trace( - 'ProcessRunner', - () => `stdin: Using inherit mode for interactive command` - ); - stdinPumpPromise = Promise.resolve(); - } else { - const isPipedIn = process.stdin && process.stdin.isTTY === false; - trace( - 'ProcessRunner', - () => - `stdin: Non-interactive inherit mode | ${JSON.stringify( - { - isPipedIn, - stdinTTY: process.stdin.isTTY, - }, - null, - 2 - )}` - ); - if (isPipedIn) { - trace( - 'ProcessRunner', - () => `stdin: Pumping piped input to child process` - ); - stdinPumpPromise = this._pumpStdinTo( - this.child, - this.options.capture ? this.inChunks : null - ); - } else { - // For TTY (interactive terminal), forward stdin directly for non-interactive commands - trace( - 'ProcessRunner', - () => `stdin: Forwarding TTY stdin for non-interactive command` - ); - stdinPumpPromise = this._forwardTTYStdin(); - } - } - } else if (stdin === 'ignore') { - trace('ProcessRunner', () => `stdin: Ignoring and closing stdin`); - if (this.child.stdin && typeof this.child.stdin.end === 'function') { - this.child.stdin.end(); - trace( - 'ProcessRunner', - () => `stdin: Child stdin closed successfully` - ); - } - } else if (stdin === 'pipe') { - trace( - 'ProcessRunner', - () => `stdin: Using pipe mode - leaving stdin open for manual control` - ); - // Leave stdin open for manual writing via streams.stdin - stdinPumpPromise = Promise.resolve(); - } else if (typeof stdin === 'string' || Buffer.isBuffer(stdin)) { - const buf = Buffer.isBuffer(stdin) ? stdin : Buffer.from(stdin); - trace( - 'ProcessRunner', - () => - `stdin: Writing buffer to child | ${JSON.stringify( - { - bufferLength: buf.length, - willCapture: this.options.capture && !!this.inChunks, - }, - null, - 2 - )}` - ); - if (this.options.capture && this.inChunks) { - this.inChunks.push(Buffer.from(buf)); - } - stdinPumpPromise = this._writeToStdin(buf); - } else { - trace( - 'ProcessRunner', - () => `stdin: Unhandled stdin type: ${typeof stdin}` - ); - } - - const exited = isBun - ? this.child.exited - : new Promise((resolve) => { - trace( - 'ProcessRunner', - () => - `Setting up child process event listeners for PID ${this.child.pid}` - ); - this.child.on('close', (code, signal) => { - trace( - 'ProcessRunner', - () => - `Child process close event | ${JSON.stringify( - { - pid: this.child.pid, - code, - signal, - killed: this.child.killed, - exitCode: this.child.exitCode, - signalCode: this.child.signalCode, - command: this.command, - }, - null, - 2 - )}` - ); - resolve(code); - }); - this.child.on('exit', (code, signal) => { - trace( - 'ProcessRunner', - () => - `Child process exit event | ${JSON.stringify( - { - pid: this.child.pid, - code, - signal, - killed: this.child.killed, - exitCode: this.child.exitCode, - signalCode: this.child.signalCode, - command: this.command, - }, - null, - 2 - )}` - ); - }); - }); - const code = await exited; - await Promise.all([outPump, errPump, stdinPumpPromise]); - - // Debug: Check the raw exit code - trace( - 'ProcessRunner', - () => - `Raw exit code from child | ${JSON.stringify( - { - code, - codeType: typeof code, - childExitCode: this.child?.exitCode, - isBun, - }, - null, - 2 - )}` - ); - - // When a process is killed, it may not have an exit code - // If cancelled and no exit code, assume it was killed with SIGTERM - let finalExitCode = code; - trace( - 'ProcessRunner', - () => - `Processing exit code | ${JSON.stringify( - { - rawCode: code, - cancelled: this._cancelled, - childKilled: this.child?.killed, - childExitCode: this.child?.exitCode, - childSignalCode: this.child?.signalCode, - }, - null, - 2 - )}` - ); - - if (finalExitCode === undefined || finalExitCode === null) { - if (this._cancelled) { - // Process was killed, use SIGTERM exit code - finalExitCode = 143; // 128 + 15 (SIGTERM) - trace( - 'ProcessRunner', - () => `Process was killed, using SIGTERM exit code 143` - ); - } else { - // Process exited without a code, default to 0 - finalExitCode = 0; - trace( - 'ProcessRunner', - () => `Process exited without code, defaulting to 0` - ); - } - } - - const resultData = { - code: finalExitCode, - stdout: this.options.capture - ? this.outChunks && this.outChunks.length > 0 - ? Buffer.concat(this.outChunks).toString('utf8') - : '' - : undefined, - stderr: this.options.capture - ? this.errChunks && this.errChunks.length > 0 - ? Buffer.concat(this.errChunks).toString('utf8') - : '' - : undefined, - stdin: - this.options.capture && this.inChunks - ? Buffer.concat(this.inChunks).toString('utf8') - : undefined, - child: this.child, - }; - - trace( - 'ProcessRunner', - () => - `Process completed | ${JSON.stringify( - { - command: this.command, - finalExitCode, - captured: this.options.capture, - hasStdout: !!resultData.stdout, - hasStderr: !!resultData.stderr, - stdoutLength: resultData.stdout?.length || 0, - stderrLength: resultData.stderr?.length || 0, - stdoutPreview: resultData.stdout?.slice(0, 100), - stderrPreview: resultData.stderr?.slice(0, 100), - childPid: this.child?.pid, - cancelled: this._cancelled, - cancellationSignal: this._cancellationSignal, - platform: process.platform, - runtime: isBun ? 'Bun' : 'Node.js', - }, - null, - 2 - )}` - ); - - const result = { - ...resultData, - async text() { - return resultData.stdout || ''; - }, - }; - - trace( - 'ProcessRunner', - () => - `About to finish process with result | ${JSON.stringify( - { - exitCode: result.code, - finished: this.finished, - }, - null, - 2 - )}` - ); - - // Finish the process with proper event emission order - this.finish(result); - - trace( - 'ProcessRunner', - () => - `Process finished, result set | ${JSON.stringify( - { - finished: this.finished, - resultCode: this.result?.code, - }, - null, - 2 - )}` - ); - - if (globalShellSettings.errexit && this.result.code !== 0) { - trace( - 'ProcessRunner', - () => - `Errexit mode: throwing error for non-zero exit code | ${JSON.stringify( - { - exitCode: this.result.code, - errexit: globalShellSettings.errexit, - hasStdout: !!this.result.stdout, - hasStderr: !!this.result.stderr, - }, - null, - 2 - )}` - ); - - const error = new Error( - `Command failed with exit code ${this.result.code}` - ); - error.code = this.result.code; - error.stdout = this.result.stdout; - error.stderr = this.result.stderr; - error.result = this.result; - - trace('ProcessRunner', () => `About to throw errexit error`); - throw error; - } - - trace( - 'ProcessRunner', - () => - `Returning result successfully | ${JSON.stringify( - { - exitCode: this.result.code, - errexit: globalShellSettings.errexit, - }, - null, - 2 - )}` - ); - - return this.result; - } catch (error) { - trace( - 'ProcessRunner', - () => - `Caught error in _doStartAsync | ${JSON.stringify( - { - errorMessage: error.message, - errorCode: error.code, - isCommandError: error.isCommandError, - hasResult: !!error.result, - command: this.spec?.command?.slice(0, 100), - }, - null, - 2 - )}` - ); - - // Ensure cleanup happens even if execution fails - trace( - 'ProcessRunner', - () => `_doStartAsync caught error: ${error.message}` - ); - - if (!this.finished) { - // Create a result from the error - const errorResult = createResult({ - code: error.code ?? 1, - stdout: error.stdout ?? '', - stderr: error.stderr ?? error.message ?? '', - stdin: '', - }); - - // Finish to trigger cleanup - this.finish(errorResult); - } - - // Re-throw the error after cleanup - throw error; - } - } - - async _pumpStdinTo(child, captureChunks) { - trace( - 'ProcessRunner', - () => - `_pumpStdinTo ENTER | ${JSON.stringify( - { - hasChildStdin: !!child?.stdin, - willCapture: !!captureChunks, - isBun, - }, - null, - 2 - )}` - ); - - if (!child.stdin) { - trace('ProcessRunner', () => 'No child stdin to pump to'); - return; - } - const bunWriter = - isBun && child.stdin && typeof child.stdin.getWriter === 'function' - ? child.stdin.getWriter() - : null; - for await (const chunk of process.stdin) { - const buf = asBuffer(chunk); - captureChunks && captureChunks.push(buf); - if (bunWriter) { - await bunWriter.write(buf); - } else if (typeof child.stdin.write === 'function') { - // Use StreamUtils for consistent stdin handling - StreamUtils.addStdinErrorHandler(child.stdin, 'child stdin buffer'); - StreamUtils.safeStreamWrite(child.stdin, buf, 'child stdin buffer'); - } else if (isBun && typeof Bun.write === 'function') { - await Bun.write(child.stdin, buf); - } - } - if (bunWriter) { - await bunWriter.close(); - } else if (typeof child.stdin.end === 'function') { - child.stdin.end(); - } - } - - async _writeToStdin(buf) { - trace( - 'ProcessRunner', - () => - `_writeToStdin ENTER | ${JSON.stringify( - { - bufferLength: buf?.length || 0, - hasChildStdin: !!this.child?.stdin, - }, - null, - 2 - )}` - ); - - const bytes = - buf instanceof Uint8Array - ? buf - : new Uint8Array(buf.buffer, buf.byteOffset ?? 0, buf.byteLength); - if (await StreamUtils.writeToStream(this.child.stdin, bytes, 'stdin')) { - // Successfully wrote to stream - if (StreamUtils.isBunStream(this.child.stdin)) { - // Stream was already closed by writeToStream utility - } else if (StreamUtils.isNodeStream(this.child.stdin)) { - try { - this.child.stdin.end(); - } catch {} - } - } else if (isBun && typeof Bun.write === 'function') { - await Bun.write(this.child.stdin, buf); - } - } - - _parseCommand(command) { - trace( - 'ProcessRunner', - () => - `_parseCommand ENTER | ${JSON.stringify( - { - commandLength: command?.length || 0, - preview: command?.slice(0, 50), - }, - null, - 2 - )}` - ); - - const trimmed = command.trim(); - if (!trimmed) { - trace('ProcessRunner', () => 'Empty command after trimming'); - return null; - } - - if (trimmed.includes('|')) { - return this._parsePipeline(trimmed); - } - - // Simple command parsing - const parts = trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; - if (parts.length === 0) { - return null; - } - - const cmd = parts[0]; - const args = parts.slice(1).map((arg) => { - // Keep track of whether the arg was quoted - if ( - (arg.startsWith('"') && arg.endsWith('"')) || - (arg.startsWith("'") && arg.endsWith("'")) - ) { - return { value: arg.slice(1, -1), quoted: true, quoteChar: arg[0] }; - } - return { value: arg, quoted: false }; - }); - - return { cmd, args, type: 'simple' }; - } - - _parsePipeline(command) { - trace( - 'ProcessRunner', - () => - `_parsePipeline ENTER | ${JSON.stringify( - { - commandLength: command?.length || 0, - hasPipe: command?.includes('|'), - }, - null, - 2 - )}` - ); - - // Split by pipe, respecting quotes - const segments = []; - let current = ''; - let inQuotes = false; - let quoteChar = ''; - - for (let i = 0; i < command.length; i++) { - const char = command[i]; - - if (!inQuotes && (char === '"' || char === "'")) { - inQuotes = true; - quoteChar = char; - current += char; - } else if (inQuotes && char === quoteChar) { - inQuotes = false; - quoteChar = ''; - current += char; - } else if (!inQuotes && char === '|') { - segments.push(current.trim()); - current = ''; - } else { - current += char; - } - } - - if (current.trim()) { - segments.push(current.trim()); - } - - const commands = segments - .map((segment) => { - const parts = segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; - if (parts.length === 0) { - return null; - } - - const cmd = parts[0]; - const args = parts.slice(1).map((arg) => { - // Keep track of whether the arg was quoted - if ( - (arg.startsWith('"') && arg.endsWith('"')) || - (arg.startsWith("'") && arg.endsWith("'")) - ) { - return { value: arg.slice(1, -1), quoted: true, quoteChar: arg[0] }; - } - return { value: arg, quoted: false }; - }); - - return { cmd, args }; - }) - .filter(Boolean); - - return { type: 'pipeline', commands }; - } - - async _runVirtual(cmd, args, originalCommand = null) { - trace( - 'ProcessRunner', - () => - `_runVirtual ENTER | ${JSON.stringify({ cmd, args, originalCommand }, null, 2)}` - ); - - const handler = virtualCommands.get(cmd); - if (!handler) { - trace( - 'ProcessRunner', - () => `Virtual command not found | ${JSON.stringify({ cmd }, null, 2)}` - ); - throw new Error(`Virtual command not found: ${cmd}`); - } - - trace( - 'ProcessRunner', - () => - `Found virtual command handler | ${JSON.stringify( - { - cmd, - isGenerator: handler.constructor.name === 'AsyncGeneratorFunction', - }, - null, - 2 - )}` - ); - - try { - // Prepare stdin - let stdinData = ''; - - // Special handling for streaming mode (stdin: "pipe") - if (this.options.stdin === 'pipe') { - // For streaming interfaces, virtual commands should fallback to real commands - // because virtual commands don't support true streaming - trace( - 'ProcessRunner', - () => - `Virtual command fallback for streaming | ${JSON.stringify({ cmd }, null, 2)}` - ); - - // Create a new ProcessRunner for the real command with properly merged options - // Preserve main options but use appropriate stdin for the real command - const modifiedOptions = { - ...this.options, - stdin: 'pipe', // Keep pipe but ensure it doesn't trigger virtual command fallback - _bypassVirtual: true, // Flag to prevent virtual command recursion - }; - const realRunner = new ProcessRunner( - { mode: 'shell', command: originalCommand || cmd }, - modifiedOptions - ); - return await realRunner._doStartAsync(); - } else if (this.options.stdin && typeof this.options.stdin === 'string') { - stdinData = this.options.stdin; - } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) { - stdinData = this.options.stdin.toString('utf8'); - } - - // Extract actual values for virtual command - const argValues = args.map((arg) => - arg.value !== undefined ? arg.value : arg - ); - - // Shell tracing for virtual commands - if (globalShellSettings.xtrace) { - console.log(`+ ${originalCommand || `${cmd} ${argValues.join(' ')}`}`); - } - if (globalShellSettings.verbose) { - console.log(`${originalCommand || `${cmd} ${argValues.join(' ')}`}`); - } - - let result; - - if (handler.constructor.name === 'AsyncGeneratorFunction') { - const chunks = []; - - const commandOptions = { - // Commonly used options at top level for convenience - cwd: this.options.cwd, - env: this.options.env, - // All original options (built-in + custom) in options object - options: this.options, - isCancelled: () => this._cancelled, - }; - - trace( - 'ProcessRunner', - () => - `_runVirtual signal details | ${JSON.stringify( - { - cmd, - hasAbortController: !!this._abortController, - signalAborted: this._abortController?.signal?.aborted, - optionsSignalExists: !!this.options.signal, - optionsSignalAborted: this.options.signal?.aborted, - }, - null, - 2 - )}` - ); - - const generator = handler({ - args: argValues, - stdin: stdinData, - abortSignal: this._abortController?.signal, - ...commandOptions, - }); - this._virtualGenerator = generator; - - const cancelPromise = new Promise((resolve) => { - this._cancelResolve = resolve; - }); - - try { - const iterator = generator[Symbol.asyncIterator](); - let done = false; - - while (!done && !this._cancelled) { - trace( - 'ProcessRunner', - () => - `Virtual command iteration starting | ${JSON.stringify( - { - cancelled: this._cancelled, - streamBreaking: this._streamBreaking, - }, - null, - 2 - )}` - ); - - const result = await Promise.race([ - iterator.next(), - cancelPromise.then(() => ({ done: true, cancelled: true })), - ]); - - trace( - 'ProcessRunner', - () => - `Virtual command iteration result | ${JSON.stringify( - { - hasValue: !!result.value, - done: result.done, - cancelled: result.cancelled || this._cancelled, - }, - null, - 2 - )}` - ); - - if (result.cancelled || this._cancelled) { - trace( - 'ProcessRunner', - () => - `Virtual command cancelled - closing generator | ${JSON.stringify( - { - resultCancelled: result.cancelled, - thisCancelled: this._cancelled, - }, - null, - 2 - )}` - ); - // Cancelled - close the generator - if (iterator.return) { - await iterator.return(); - } - break; - } - - done = result.done; - - if (!done) { - // Check cancellation again before processing the chunk - if (this._cancelled) { - trace( - 'ProcessRunner', - () => 'Skipping chunk processing - cancelled during iteration' - ); - break; - } - - const chunk = result.value; - const buf = Buffer.from(chunk); - - // Check cancelled flag once more before any output - if (this._cancelled || this._streamBreaking) { - trace( - 'ProcessRunner', - () => - `Cancelled or stream breaking before output - skipping | ${JSON.stringify( - { - cancelled: this._cancelled, - streamBreaking: this._streamBreaking, - }, - null, - 2 - )}` - ); - break; - } - - chunks.push(buf); - - // Only output if not cancelled and stream not breaking - if ( - !this._cancelled && - !this._streamBreaking && - this.options.mirror - ) { - trace( - 'ProcessRunner', - () => - `Mirroring virtual command output | ${JSON.stringify( - { - chunkSize: buf.length, - }, - null, - 2 - )}` - ); - safeWrite(process.stdout, buf); - } - - this._emitProcessedData('stdout', buf); - } - } - } finally { - // Clean up - this._virtualGenerator = null; - this._cancelResolve = null; - } - - result = { - code: 0, - stdout: this.options.capture - ? Buffer.concat(chunks).toString('utf8') - : undefined, - stderr: this.options.capture ? '' : undefined, - stdin: this.options.capture ? stdinData : undefined, - }; - } else { - // Regular async function - race with abort signal - const commandOptions = { - // Commonly used options at top level for convenience - cwd: this.options.cwd, - env: this.options.env, - // All original options (built-in + custom) in options object - options: this.options, - isCancelled: () => this._cancelled, - }; - - trace( - 'ProcessRunner', - () => - `_runVirtual signal details (non-generator) | ${JSON.stringify( - { - cmd, - hasAbortController: !!this._abortController, - signalAborted: this._abortController?.signal?.aborted, - optionsSignalExists: !!this.options.signal, - optionsSignalAborted: this.options.signal?.aborted, - }, - null, - 2 - )}` - ); - - const handlerPromise = handler({ - args: argValues, - stdin: stdinData, - abortSignal: this._abortController?.signal, - ...commandOptions, - }); - - // Create an abort promise that rejects when cancelled - const abortPromise = new Promise((_, reject) => { - if (this._abortController && this._abortController.signal.aborted) { - reject(new Error('Command cancelled')); - } - if (this._abortController) { - this._abortController.signal.addEventListener('abort', () => { - reject(new Error('Command cancelled')); - }); - } - }); - - try { - result = await Promise.race([handlerPromise, abortPromise]); - } catch (err) { - if (err.message === 'Command cancelled') { - // Command was cancelled, return appropriate exit code based on signal - const exitCode = this._cancellationSignal === 'SIGINT' ? 130 : 143; // 130 for SIGINT, 143 for SIGTERM - trace( - 'ProcessRunner', - () => - `Virtual command cancelled with signal ${this._cancellationSignal}, exit code: ${exitCode}` - ); - result = { - code: exitCode, - stdout: '', - stderr: '', - }; - } else { - throw err; - } - } - - result = { - ...result, - code: result.code ?? 0, - stdout: this.options.capture ? (result.stdout ?? '') : undefined, - stderr: this.options.capture ? (result.stderr ?? '') : undefined, - stdin: this.options.capture ? stdinData : undefined, - }; - - // Mirror and emit output - if (result.stdout) { - const buf = Buffer.from(result.stdout); - if (this.options.mirror) { - safeWrite(process.stdout, buf); - } - this._emitProcessedData('stdout', buf); - } - - if (result.stderr) { - const buf = Buffer.from(result.stderr); - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - } - } - - // Finish the process with proper event emission order - this.finish(result); - - if (globalShellSettings.errexit && result.code !== 0) { - const error = new Error(`Command failed with exit code ${result.code}`); - error.code = result.code; - error.stdout = result.stdout; - error.stderr = result.stderr; - error.result = result; - throw error; - } - - return result; - } catch (error) { - // Check if this is a cancellation error - let exitCode = error.code ?? 1; - if (this._cancelled && this._cancellationSignal) { - // Use appropriate exit code based on the signal - exitCode = - this._cancellationSignal === 'SIGINT' - ? 130 - : this._cancellationSignal === 'SIGTERM' - ? 143 - : 1; - trace( - 'ProcessRunner', - () => - `Virtual command error during cancellation, using signal-based exit code: ${exitCode}` - ); - } - - const result = { - code: exitCode, - stdout: error.stdout ?? '', - stderr: error.stderr ?? error.message, - stdin: '', - }; - - if (result.stderr) { - const buf = Buffer.from(result.stderr); - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - } - - this.finish(result); - - if (globalShellSettings.errexit) { - error.result = result; - throw error; - } - - return result; - } - } - - async _runStreamingPipelineBun(commands) { - trace( - 'ProcessRunner', - () => - `_runStreamingPipelineBun ENTER | ${JSON.stringify( - { - commandsCount: commands.length, - }, - null, - 2 - )}` - ); - - // For true streaming, we need to handle virtual and real commands differently - - // First, analyze the pipeline to identify virtual vs real commands - const pipelineInfo = commands.map((command) => { - const { cmd, args } = command; - const isVirtual = virtualCommandsEnabled && virtualCommands.has(cmd); - return { ...command, isVirtual }; - }); - - trace( - 'ProcessRunner', - () => - `Pipeline analysis | ${JSON.stringify( - { - virtualCount: pipelineInfo.filter((p) => p.isVirtual).length, - realCount: pipelineInfo.filter((p) => !p.isVirtual).length, - }, - null, - 2 - )}` - ); - - // If pipeline contains virtual commands, use advanced streaming - if (pipelineInfo.some((info) => info.isVirtual)) { - trace( - 'ProcessRunner', - () => - `BRANCH: _runStreamingPipelineBun => MIXED_PIPELINE | ${JSON.stringify({}, null, 2)}` - ); - return this._runMixedStreamingPipeline(commands); - } - - // For pipelines with commands that buffer (like jq), use tee streaming - const needsStreamingWorkaround = commands.some( - (c) => - c.cmd === 'jq' || - c.cmd === 'grep' || - c.cmd === 'sed' || - c.cmd === 'cat' || - c.cmd === 'awk' - ); - if (needsStreamingWorkaround) { - trace( - 'ProcessRunner', - () => - `BRANCH: _runStreamingPipelineBun => TEE_STREAMING | ${JSON.stringify( - { - bufferedCommands: commands - .filter((c) => - ['jq', 'grep', 'sed', 'cat', 'awk'].includes(c.cmd) - ) - .map((c) => c.cmd), - }, - null, - 2 - )}` - ); - return this._runTeeStreamingPipeline(commands); - } - - // All real commands - use native pipe connections - const processes = []; - let allStderr = ''; - - for (let i = 0; i < commands.length; i++) { - const command = commands[i]; - const { cmd, args } = command; - - // Build command string - const commandParts = [cmd]; - for (const arg of args) { - if (arg.value !== undefined) { - if (arg.quoted) { - commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`); - } else if (arg.value.includes(' ')) { - commandParts.push(`"${arg.value}"`); - } else { - commandParts.push(arg.value); - } - } else { - if ( - typeof arg === 'string' && - arg.includes(' ') && - !arg.startsWith('"') && - !arg.startsWith("'") - ) { - commandParts.push(`"${arg}"`); - } else { - commandParts.push(arg); - } - } - } - const commandStr = commandParts.join(' '); - - // Determine stdin for this process - let stdin; - let needsManualStdin = false; - let stdinData; - - if (i === 0) { - // First command - use provided stdin or pipe - if (this.options.stdin && typeof this.options.stdin === 'string') { - stdin = 'pipe'; - needsManualStdin = true; - stdinData = Buffer.from(this.options.stdin); - } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) { - stdin = 'pipe'; - needsManualStdin = true; - stdinData = this.options.stdin; - } else { - stdin = 'ignore'; - } - } else { - // Connect to previous process stdout - stdin = processes[i - 1].stdout; - } - - // Only use sh -c for complex commands that need shell features - const needsShell = - commandStr.includes('*') || - commandStr.includes('$') || - commandStr.includes('>') || - commandStr.includes('<') || - commandStr.includes('&&') || - commandStr.includes('||') || - commandStr.includes(';') || - commandStr.includes('`'); - - const shell = findAvailableShell(); - const spawnArgs = needsShell - ? [shell.cmd, ...shell.args.filter((arg) => arg !== '-l'), commandStr] - : [cmd, ...args.map((a) => (a.value !== undefined ? a.value : a))]; - - const proc = Bun.spawn(spawnArgs, { - cwd: this.options.cwd, - env: this.options.env, - stdin, - stdout: 'pipe', - stderr: 'pipe', - }); - - // Write stdin data if needed for first process - if (needsManualStdin && stdinData && proc.stdin) { - // Use StreamUtils for consistent stdin handling - const stdinHandler = StreamUtils.setupStdinHandling( - proc.stdin, - 'Bun process stdin' - ); - - (async () => { - try { - if (stdinHandler.isWritable()) { - await proc.stdin.write(stdinData); // Bun's FileSink async write - await proc.stdin.end(); - } - } catch (e) { - if (e.code !== 'EPIPE') { - trace( - 'ProcessRunner', - () => - `Error with Bun stdin async operations | ${JSON.stringify({ error: e.message, code: e.code }, null, 2)}` - ); - } - } - })(); - } - - processes.push(proc); - - // Collect stderr from all processes - (async () => { - for await (const chunk of proc.stderr) { - const buf = Buffer.from(chunk); - allStderr += buf.toString(); - // Only emit stderr for the last command - if (i === commands.length - 1) { - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - } - } - })(); - } - - // Stream output from the last process - const lastProc = processes[processes.length - 1]; - let finalOutput = ''; - - // Stream stdout from last process - for await (const chunk of lastProc.stdout) { - const buf = Buffer.from(chunk); - finalOutput += buf.toString(); - if (this.options.mirror) { - safeWrite(process.stdout, buf); - } - this._emitProcessedData('stdout', buf); - } - - // Wait for all processes to complete - const exitCodes = await Promise.all(processes.map((p) => p.exited)); - const lastExitCode = exitCodes[exitCodes.length - 1]; - - if (globalShellSettings.pipefail) { - const failedIndex = exitCodes.findIndex((code) => code !== 0); - if (failedIndex !== -1) { - const error = new Error( - `Pipeline command at index ${failedIndex} failed with exit code ${exitCodes[failedIndex]}` - ); - error.code = exitCodes[failedIndex]; - throw error; - } - } - - const result = createResult({ - code: lastExitCode || 0, - stdout: finalOutput, - stderr: allStderr, - stdin: - this.options.stdin && typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin && Buffer.isBuffer(this.options.stdin) - ? this.options.stdin.toString('utf8') - : '', - }); - - // Finish the process with proper event emission order - this.finish(result); - - if (globalShellSettings.errexit && result.code !== 0) { - const error = new Error(`Pipeline failed with exit code ${result.code}`); - error.code = result.code; - error.stdout = result.stdout; - error.stderr = result.stderr; - error.result = result; - throw error; - } - - return result; - } - - async _runTeeStreamingPipeline(commands) { - trace( - 'ProcessRunner', - () => - `_runTeeStreamingPipeline ENTER | ${JSON.stringify( - { - commandsCount: commands.length, - }, - null, - 2 - )}` - ); - - // Use tee() to split streams for real-time reading - // This works around jq and similar commands that buffer when piped - - const processes = []; - let allStderr = ''; - let currentStream = null; - - for (let i = 0; i < commands.length; i++) { - const command = commands[i]; - const { cmd, args } = command; - - // Build command string - const commandParts = [cmd]; - for (const arg of args) { - if (arg.value !== undefined) { - if (arg.quoted) { - commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`); - } else if (arg.value.includes(' ')) { - commandParts.push(`"${arg.value}"`); - } else { - commandParts.push(arg.value); - } - } else { - if ( - typeof arg === 'string' && - arg.includes(' ') && - !arg.startsWith('"') && - !arg.startsWith("'") - ) { - commandParts.push(`"${arg}"`); - } else { - commandParts.push(arg); - } - } - } - const commandStr = commandParts.join(' '); - - // Determine stdin for this process - let stdin; - let needsManualStdin = false; - let stdinData; - - if (i === 0) { - // First command - use provided stdin or ignore - if (this.options.stdin && typeof this.options.stdin === 'string') { - stdin = 'pipe'; - needsManualStdin = true; - stdinData = Buffer.from(this.options.stdin); - } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) { - stdin = 'pipe'; - needsManualStdin = true; - stdinData = this.options.stdin; - } else { - stdin = 'ignore'; - } - } else { - stdin = currentStream; - } - - const needsShell = - commandStr.includes('*') || - commandStr.includes('$') || - commandStr.includes('>') || - commandStr.includes('<') || - commandStr.includes('&&') || - commandStr.includes('||') || - commandStr.includes(';') || - commandStr.includes('`'); - - const shell = findAvailableShell(); - const spawnArgs = needsShell - ? [shell.cmd, ...shell.args.filter((arg) => arg !== '-l'), commandStr] - : [cmd, ...args.map((a) => (a.value !== undefined ? a.value : a))]; - - const proc = Bun.spawn(spawnArgs, { - cwd: this.options.cwd, - env: this.options.env, - stdin, - stdout: 'pipe', - stderr: 'pipe', - }); - - // Write stdin data if needed for first process - if (needsManualStdin && stdinData && proc.stdin) { - // Use StreamUtils for consistent stdin handling - const stdinHandler = StreamUtils.setupStdinHandling( - proc.stdin, - 'Node process stdin' - ); - - try { - if (stdinHandler.isWritable()) { - await proc.stdin.write(stdinData); // Node async write - await proc.stdin.end(); - } - } catch (e) { - if (e.code !== 'EPIPE') { - trace( - 'ProcessRunner', - () => - `Error with Node stdin async operations | ${JSON.stringify({ error: e.message, code: e.code }, null, 2)}` - ); - } - } - } - - processes.push(proc); - - // For non-last processes, tee the output so we can both pipe and read - if (i < commands.length - 1) { - const [readStream, pipeStream] = proc.stdout.tee(); - currentStream = pipeStream; - - // Read from the tee'd stream to keep it flowing - (async () => { - for await (const chunk of readStream) { - // Just consume to keep flowing - don't emit intermediate output - } - })(); - } else { - currentStream = proc.stdout; - } - - // Collect stderr from all processes - (async () => { - for await (const chunk of proc.stderr) { - const buf = Buffer.from(chunk); - allStderr += buf.toString(); - if (i === commands.length - 1) { - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - } - } - })(); - } - - // Read final output from the last process - const lastProc = processes[processes.length - 1]; - let finalOutput = ''; - - // Always emit from the last process for proper pipeline output - for await (const chunk of lastProc.stdout) { - const buf = Buffer.from(chunk); - finalOutput += buf.toString(); - if (this.options.mirror) { - safeWrite(process.stdout, buf); - } - this._emitProcessedData('stdout', buf); - } - - // Wait for all processes to complete - const exitCodes = await Promise.all(processes.map((p) => p.exited)); - const lastExitCode = exitCodes[exitCodes.length - 1]; - - if (globalShellSettings.pipefail) { - const failedIndex = exitCodes.findIndex((code) => code !== 0); - if (failedIndex !== -1) { - const error = new Error( - `Pipeline command at index ${failedIndex} failed with exit code ${exitCodes[failedIndex]}` - ); - error.code = exitCodes[failedIndex]; - throw error; - } - } - - const result = createResult({ - code: lastExitCode || 0, - stdout: finalOutput, - stderr: allStderr, - stdin: - this.options.stdin && typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin && Buffer.isBuffer(this.options.stdin) - ? this.options.stdin.toString('utf8') - : '', - }); - - // Finish the process with proper event emission order - this.finish(result); - - if (globalShellSettings.errexit && result.code !== 0) { - const error = new Error(`Pipeline failed with exit code ${result.code}`); - error.code = result.code; - error.stdout = result.stdout; - error.stderr = result.stderr; - error.result = result; - throw error; - } - - return result; - } - - async _runMixedStreamingPipeline(commands) { - trace( - 'ProcessRunner', - () => - `_runMixedStreamingPipeline ENTER | ${JSON.stringify( - { - commandsCount: commands.length, - }, - null, - 2 - )}` - ); - - // Each stage reads from previous stage's output stream - - let currentInputStream = null; - let finalOutput = ''; - let allStderr = ''; - - if (this.options.stdin) { - const inputData = - typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin.toString('utf8'); - - currentInputStream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(inputData)); - controller.close(); - }, - }); - } - - for (let i = 0; i < commands.length; i++) { - const command = commands[i]; - const { cmd, args } = command; - const isLastCommand = i === commands.length - 1; - - if (virtualCommandsEnabled && virtualCommands.has(cmd)) { - trace( - 'ProcessRunner', - () => - `BRANCH: _runMixedStreamingPipeline => VIRTUAL_COMMAND | ${JSON.stringify( - { - cmd, - commandIndex: i, - }, - null, - 2 - )}` - ); - const handler = virtualCommands.get(cmd); - const argValues = args.map((arg) => - arg.value !== undefined ? arg.value : arg - ); - - // Read input from stream if available - let inputData = ''; - if (currentInputStream) { - const reader = currentInputStream.getReader(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - inputData += new TextDecoder().decode(value); - } - } finally { - reader.releaseLock(); - } - } - - if (handler.constructor.name === 'AsyncGeneratorFunction') { - const chunks = []; - const self = this; // Capture this context - currentInputStream = new ReadableStream({ - async start(controller) { - const { stdin: _, ...optionsWithoutStdin } = self.options; - for await (const chunk of handler({ - args: argValues, - stdin: inputData, - ...optionsWithoutStdin, - })) { - const data = Buffer.from(chunk); - controller.enqueue(data); - - // Emit for last command - if (isLastCommand) { - chunks.push(data); - if (self.options.mirror) { - safeWrite(process.stdout, data); - } - self.emit('stdout', data); - self.emit('data', { type: 'stdout', data }); - } - } - controller.close(); - - if (isLastCommand) { - finalOutput = Buffer.concat(chunks).toString('utf8'); - } - }, - }); - } else { - // Regular async function - const { stdin: _, ...optionsWithoutStdin } = this.options; - const result = await handler({ - args: argValues, - stdin: inputData, - ...optionsWithoutStdin, - }); - const outputData = result.stdout || ''; - - if (isLastCommand) { - finalOutput = outputData; - const buf = Buffer.from(outputData); - if (this.options.mirror) { - safeWrite(process.stdout, buf); - } - this._emitProcessedData('stdout', buf); - } - - currentInputStream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(outputData)); - controller.close(); - }, - }); - - if (result.stderr) { - allStderr += result.stderr; - } - } - } else { - const commandParts = [cmd]; - for (const arg of args) { - if (arg.value !== undefined) { - if (arg.quoted) { - commandParts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`); - } else if (arg.value.includes(' ')) { - commandParts.push(`"${arg.value}"`); - } else { - commandParts.push(arg.value); - } - } else { - if ( - typeof arg === 'string' && - arg.includes(' ') && - !arg.startsWith('"') && - !arg.startsWith("'") - ) { - commandParts.push(`"${arg}"`); - } else { - commandParts.push(arg); - } - } - } - const commandStr = commandParts.join(' '); - - const shell = findAvailableShell(); - const proc = Bun.spawn( - [shell.cmd, ...shell.args.filter((arg) => arg !== '-l'), commandStr], - { - cwd: this.options.cwd, - env: this.options.env, - stdin: currentInputStream ? 'pipe' : 'ignore', - stdout: 'pipe', - stderr: 'pipe', - } - ); - - // Write input stream to process stdin if needed - if (currentInputStream && proc.stdin) { - const reader = currentInputStream.getReader(); - const writer = proc.stdin.getWriter - ? proc.stdin.getWriter() - : proc.stdin; - - (async () => { - try { - while (true) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (writer.write) { - try { - await writer.write(value); - } catch (error) { - StreamUtils.handleStreamError( - error, - 'stream writer', - false - ); - break; // Stop streaming if write fails - } - } else if (writer.getWriter) { - try { - const w = writer.getWriter(); - await w.write(value); - w.releaseLock(); - } catch (error) { - StreamUtils.handleStreamError( - error, - 'stream writer (getWriter)', - false - ); - break; // Stop streaming if write fails - } - } - } - } finally { - reader.releaseLock(); - if (writer.close) { - await writer.close(); - } else if (writer.end) { - writer.end(); - } - } - })(); - } - - currentInputStream = proc.stdout; - - (async () => { - for await (const chunk of proc.stderr) { - const buf = Buffer.from(chunk); - allStderr += buf.toString(); - if (isLastCommand) { - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - } - } - })(); - - // For last command, stream output - if (isLastCommand) { - const chunks = []; - for await (const chunk of proc.stdout) { - const buf = Buffer.from(chunk); - chunks.push(buf); - if (this.options.mirror) { - safeWrite(process.stdout, buf); - } - this._emitProcessedData('stdout', buf); - } - finalOutput = Buffer.concat(chunks).toString('utf8'); - await proc.exited; - } - } - } - - const result = createResult({ - code: 0, // TODO: Track exit codes properly - stdout: finalOutput, - stderr: allStderr, - stdin: - this.options.stdin && typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin && Buffer.isBuffer(this.options.stdin) - ? this.options.stdin.toString('utf8') - : '', - }); - - // Finish the process with proper event emission order - this.finish(result); - - return result; - } - - async _runPipelineNonStreaming(commands) { - trace( - 'ProcessRunner', - () => - `_runPipelineNonStreaming ENTER | ${JSON.stringify( - { - commandsCount: commands.length, - }, - null, - 2 - )}` - ); - - let currentOutput = ''; - let currentInput = ''; - - if (this.options.stdin && typeof this.options.stdin === 'string') { - currentInput = this.options.stdin; - } else if (this.options.stdin && Buffer.isBuffer(this.options.stdin)) { - currentInput = this.options.stdin.toString('utf8'); - } - - // Execute each command in the pipeline - for (let i = 0; i < commands.length; i++) { - const command = commands[i]; - const { cmd, args } = command; - - if (virtualCommandsEnabled && virtualCommands.has(cmd)) { - trace( - 'ProcessRunner', - () => - `BRANCH: _runPipelineNonStreaming => VIRTUAL_COMMAND | ${JSON.stringify( - { - cmd, - argsCount: args.length, - }, - null, - 2 - )}` - ); - - // Run virtual command with current input - const handler = virtualCommands.get(cmd); - - try { - // Extract actual values for virtual command - const argValues = args.map((arg) => - arg.value !== undefined ? arg.value : arg - ); - - // Shell tracing for virtual commands - if (globalShellSettings.xtrace) { - console.log(`+ ${cmd} ${argValues.join(' ')}`); - } - if (globalShellSettings.verbose) { - console.log(`${cmd} ${argValues.join(' ')}`); - } - - let result; - - if (handler.constructor.name === 'AsyncGeneratorFunction') { - trace( - 'ProcessRunner', - () => - `BRANCH: _runPipelineNonStreaming => ASYNC_GENERATOR | ${JSON.stringify({ cmd }, null, 2)}` - ); - const chunks = []; - for await (const chunk of handler({ - args: argValues, - stdin: currentInput, - ...this.options, - })) { - chunks.push(Buffer.from(chunk)); - } - result = { - code: 0, - stdout: this.options.capture - ? Buffer.concat(chunks).toString('utf8') - : undefined, - stderr: this.options.capture ? '' : undefined, - stdin: this.options.capture ? currentInput : undefined, - }; - } else { - // Regular async function - result = await handler({ - args: argValues, - stdin: currentInput, - ...this.options, - }); - result = { - ...result, - code: result.code ?? 0, - stdout: this.options.capture ? (result.stdout ?? '') : undefined, - stderr: this.options.capture ? (result.stderr ?? '') : undefined, - stdin: this.options.capture ? currentInput : undefined, - }; - } - - // If this isn't the last command, pass stdout as stdin to next command - if (i < commands.length - 1) { - currentInput = result.stdout; - } else { - // This is the last command - emit output and store final result - currentOutput = result.stdout; - - // Mirror and emit output for final command - if (result.stdout) { - const buf = Buffer.from(result.stdout); - if (this.options.mirror) { - safeWrite(process.stdout, buf); - } - this._emitProcessedData('stdout', buf); - } - - if (result.stderr) { - const buf = Buffer.from(result.stderr); - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - } - - const finalResult = createResult({ - code: result.code, - stdout: currentOutput, - stderr: result.stderr, - stdin: - this.options.stdin && typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin && Buffer.isBuffer(this.options.stdin) - ? this.options.stdin.toString('utf8') - : '', - }); - - // Finish the process with proper event emission order - this.finish(finalResult); - - if (globalShellSettings.errexit && finalResult.code !== 0) { - const error = new Error( - `Pipeline failed with exit code ${finalResult.code}` - ); - error.code = finalResult.code; - error.stdout = finalResult.stdout; - error.stderr = finalResult.stderr; - error.result = finalResult; - throw error; - } - - return finalResult; - } - - if (globalShellSettings.errexit && result.code !== 0) { - const error = new Error( - `Pipeline command failed with exit code ${result.code}` - ); - error.code = result.code; - error.stdout = result.stdout; - error.stderr = result.stderr; - error.result = result; - throw error; - } - } catch (error) { - const result = createResult({ - code: error.code ?? 1, - stdout: currentOutput, - stderr: error.stderr ?? error.message, - stdin: - this.options.stdin && typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin && Buffer.isBuffer(this.options.stdin) - ? this.options.stdin.toString('utf8') - : '', - }); - - if (result.stderr) { - const buf = Buffer.from(result.stderr); - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - } - - this.finish(result); - - if (globalShellSettings.errexit) { - throw error; - } - - return result; - } - } else { - // Execute system command in pipeline - try { - // Build command string for this part of the pipeline - const commandParts = [cmd]; - for (const arg of args) { - if (arg.value !== undefined) { - if (arg.quoted) { - // Preserve original quotes - commandParts.push( - `${arg.quoteChar}${arg.value}${arg.quoteChar}` - ); - } else if (arg.value.includes(' ')) { - // Quote if contains spaces - commandParts.push(`"${arg.value}"`); - } else { - commandParts.push(arg.value); - } - } else { - if ( - typeof arg === 'string' && - arg.includes(' ') && - !arg.startsWith('"') && - !arg.startsWith("'") - ) { - commandParts.push(`"${arg}"`); - } else { - commandParts.push(arg); - } - } - } - const commandStr = commandParts.join(' '); - - // Shell tracing for system commands - if (globalShellSettings.xtrace) { - console.log(`+ ${commandStr}`); - } - if (globalShellSettings.verbose) { - console.log(commandStr); - } - - const spawnNodeAsync = async (argv, stdin, isLastCommand = false) => - new Promise((resolve, reject) => { - trace( - 'ProcessRunner', - () => - `spawnNodeAsync: Creating child process | ${JSON.stringify({ - command: argv[0], - args: argv.slice(1), - cwd: this.options.cwd, - isLastCommand, - })}` - ); - - const proc = cp.spawn(argv[0], argv.slice(1), { - cwd: this.options.cwd, - env: this.options.env, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - trace( - 'ProcessRunner', - () => - `spawnNodeAsync: Child process created | ${JSON.stringify({ - pid: proc.pid, - killed: proc.killed, - hasStdout: !!proc.stdout, - hasStderr: !!proc.stderr, - })}` - ); - - let stdout = ''; - let stderr = ''; - let stdoutChunks = 0; - let stderrChunks = 0; - - const procPid = proc.pid; // Capture PID once to avoid null reference - - proc.stdout.on('data', (chunk) => { - const chunkStr = chunk.toString(); - stdout += chunkStr; - stdoutChunks++; - - trace( - 'ProcessRunner', - () => - `spawnNodeAsync: stdout chunk received | ${JSON.stringify({ - pid: procPid, - chunkNumber: stdoutChunks, - chunkLength: chunk.length, - totalStdoutLength: stdout.length, - isLastCommand, - preview: chunkStr.slice(0, 100), - })}` - ); - - // If this is the last command, emit streaming data - if (isLastCommand) { - if (this.options.mirror) { - safeWrite(process.stdout, chunk); - } - this._emitProcessedData('stdout', chunk); - } - }); - - proc.stderr.on('data', (chunk) => { - const chunkStr = chunk.toString(); - stderr += chunkStr; - stderrChunks++; - - trace( - 'ProcessRunner', - () => - `spawnNodeAsync: stderr chunk received | ${JSON.stringify({ - pid: procPid, - chunkNumber: stderrChunks, - chunkLength: chunk.length, - totalStderrLength: stderr.length, - isLastCommand, - preview: chunkStr.slice(0, 100), - })}` - ); - - // If this is the last command, emit streaming data - if (isLastCommand) { - if (this.options.mirror) { - safeWrite(process.stderr, chunk); - } - this._emitProcessedData('stderr', chunk); - } - }); - - proc.on('close', (code) => { - trace( - 'ProcessRunner', - () => - `spawnNodeAsync: Process closed | ${JSON.stringify({ - pid: procPid, - code, - stdoutLength: stdout.length, - stderrLength: stderr.length, - stdoutChunks, - stderrChunks, - })}` - ); - - resolve({ - status: code, - stdout, - stderr, - }); - }); - - proc.on('error', reject); - - // Use StreamUtils for comprehensive stdin handling - if (proc.stdin) { - StreamUtils.addStdinErrorHandler( - proc.stdin, - 'spawnNodeAsync stdin', - reject - ); - } - - if (stdin) { - trace( - 'ProcessRunner', - () => - `Attempting to write stdin to spawnNodeAsync | ${JSON.stringify( - { - hasStdin: !!proc.stdin, - writable: proc.stdin?.writable, - destroyed: proc.stdin?.destroyed, - closed: proc.stdin?.closed, - stdinLength: stdin.length, - }, - null, - 2 - )}` - ); - - StreamUtils.safeStreamWrite( - proc.stdin, - stdin, - 'spawnNodeAsync stdin' - ); - } - - // Safely end the stdin stream - StreamUtils.safeStreamEnd(proc.stdin, 'spawnNodeAsync stdin'); - }); - - // Execute using shell to handle complex commands - const shell = findAvailableShell(); - const argv = [ - shell.cmd, - ...shell.args.filter((arg) => arg !== '-l'), - commandStr, - ]; - const isLastCommand = i === commands.length - 1; - const proc = await spawnNodeAsync(argv, currentInput, isLastCommand); - - const result = { - code: proc.status || 0, - stdout: proc.stdout || '', - stderr: proc.stderr || '', - stdin: currentInput, - }; - - if (globalShellSettings.pipefail && result.code !== 0) { - const error = new Error( - `Pipeline command '${commandStr}' failed with exit code ${result.code}` - ); - error.code = result.code; - error.stdout = result.stdout; - error.stderr = result.stderr; - throw error; - } - - // If this isn't the last command, pass stdout as stdin to next command - if (i < commands.length - 1) { - currentInput = result.stdout; - // Accumulate stderr from all commands - if (result.stderr && this.options.capture) { - this.errChunks = this.errChunks || []; - this.errChunks.push(Buffer.from(result.stderr)); - } - } else { - // This is the last command - store final result (streaming already handled during execution) - currentOutput = result.stdout; - - // Collect all accumulated stderr - let allStderr = ''; - if (this.errChunks && this.errChunks.length > 0) { - allStderr = Buffer.concat(this.errChunks).toString('utf8'); - } - if (result.stderr) { - allStderr += result.stderr; - } - - const finalResult = createResult({ - code: result.code, - stdout: currentOutput, - stderr: allStderr, - stdin: - this.options.stdin && typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin && Buffer.isBuffer(this.options.stdin) - ? this.options.stdin.toString('utf8') - : '', - }); - - // Finish the process with proper event emission order - this.finish(finalResult); - - if (globalShellSettings.errexit && finalResult.code !== 0) { - const error = new Error( - `Pipeline failed with exit code ${finalResult.code}` - ); - error.code = finalResult.code; - error.stdout = finalResult.stdout; - error.stderr = finalResult.stderr; - error.result = finalResult; - throw error; - } - - return finalResult; - } - } catch (error) { - const result = createResult({ - code: error.code ?? 1, - stdout: currentOutput, - stderr: error.stderr ?? error.message, - stdin: - this.options.stdin && typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin && Buffer.isBuffer(this.options.stdin) - ? this.options.stdin.toString('utf8') - : '', - }); - - if (result.stderr) { - const buf = Buffer.from(result.stderr); - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - } - - this.finish(result); - - if (globalShellSettings.errexit) { - throw error; - } - - return result; - } - } - } - } - - async _runPipeline(commands) { - trace( - 'ProcessRunner', - () => - `_runPipeline ENTER | ${JSON.stringify( - { - commandsCount: commands.length, - }, - null, - 2 - )}` - ); - - if (commands.length === 0) { - trace( - 'ProcessRunner', - () => - `BRANCH: _runPipeline => NO_COMMANDS | ${JSON.stringify({}, null, 2)}` - ); - return createResult({ - code: 1, - stdout: '', - stderr: 'No commands in pipeline', - stdin: '', - }); - } - - // For true streaming, we need to connect processes via pipes - if (isBun) { - trace( - 'ProcessRunner', - () => - `BRANCH: _runPipeline => BUN_STREAMING | ${JSON.stringify({}, null, 2)}` - ); - return this._runStreamingPipelineBun(commands); - } - - // For Node.js, fall back to non-streaming implementation for now - trace( - 'ProcessRunner', - () => - `BRANCH: _runPipeline => NODE_NON_STREAMING | ${JSON.stringify({}, null, 2)}` - ); - return this._runPipelineNonStreaming(commands); - } - - // Run programmatic pipeline (.pipe() method) - async _runProgrammaticPipeline(source, destination) { - trace( - 'ProcessRunner', - () => `_runProgrammaticPipeline ENTER | ${JSON.stringify({}, null, 2)}` - ); - - try { - trace('ProcessRunner', () => 'Executing source command'); - const sourceResult = await source; - - if (sourceResult.code !== 0) { - trace( - 'ProcessRunner', - () => - `BRANCH: _runProgrammaticPipeline => SOURCE_FAILED | ${JSON.stringify( - { - code: sourceResult.code, - stderr: sourceResult.stderr, - }, - null, - 2 - )}` - ); - return sourceResult; - } - - const destWithStdin = new ProcessRunner(destination.spec, { - ...destination.options, - stdin: sourceResult.stdout, - }); - - const destResult = await destWithStdin; - - // Debug: Log what destResult looks like - trace( - 'ProcessRunner', - () => - `destResult debug | ${JSON.stringify( - { - code: destResult.code, - codeType: typeof destResult.code, - hasCode: 'code' in destResult, - keys: Object.keys(destResult), - resultType: typeof destResult, - fullResult: JSON.stringify(destResult, null, 2).slice(0, 200), - }, - null, - 2 - )}` - ); - - return createResult({ - code: destResult.code, - stdout: destResult.stdout, - stderr: sourceResult.stderr + destResult.stderr, - stdin: sourceResult.stdin, - }); - } catch (error) { - const result = createResult({ - code: error.code ?? 1, - stdout: '', - stderr: error.message || 'Pipeline execution failed', - stdin: - this.options.stdin && typeof this.options.stdin === 'string' - ? this.options.stdin - : this.options.stdin && Buffer.isBuffer(this.options.stdin) - ? this.options.stdin.toString('utf8') - : '', - }); - - const buf = Buffer.from(result.stderr); - if (this.options.mirror) { - safeWrite(process.stderr, buf); - } - this._emitProcessedData('stderr', buf); - - this.finish(result); - - return result; - } - } - - async _runSequence(sequence) { - trace( - 'ProcessRunner', - () => - `_runSequence ENTER | ${JSON.stringify( - { - commandCount: sequence.commands.length, - operators: sequence.operators, - }, - null, - 2 - )}` - ); - - let lastResult = { code: 0, stdout: '', stderr: '' }; - let combinedStdout = ''; - let combinedStderr = ''; - - for (let i = 0; i < sequence.commands.length; i++) { - const command = sequence.commands[i]; - const operator = i > 0 ? sequence.operators[i - 1] : null; - - trace( - 'ProcessRunner', - () => - `Executing command ${i} | ${JSON.stringify( - { - command: command.type, - operator, - lastCode: lastResult.code, - }, - null, - 2 - )}` - ); - - // Check operator conditions - if (operator === '&&' && lastResult.code !== 0) { - trace( - 'ProcessRunner', - () => `Skipping due to && with exit code ${lastResult.code}` - ); - continue; - } - if (operator === '||' && lastResult.code === 0) { - trace( - 'ProcessRunner', - () => `Skipping due to || with exit code ${lastResult.code}` - ); - continue; - } - - // Execute command based on type - if (command.type === 'subshell') { - lastResult = await this._runSubshell(command); - } else if (command.type === 'pipeline') { - lastResult = await this._runPipeline(command.commands); - } else if (command.type === 'sequence') { - lastResult = await this._runSequence(command); - } else if (command.type === 'simple') { - lastResult = await this._runSimpleCommand(command); - } - - // Accumulate output - combinedStdout += lastResult.stdout; - combinedStderr += lastResult.stderr; - } - - return { - code: lastResult.code, - stdout: combinedStdout, - stderr: combinedStderr, - async text() { - return combinedStdout; - }, - }; - } - - async _runSubshell(subshell) { - trace( - 'ProcessRunner', - () => - `_runSubshell ENTER | ${JSON.stringify( - { - commandType: subshell.command.type, - }, - null, - 2 - )}` - ); - - // Save current directory - const savedCwd = process.cwd(); - - try { - // Execute subshell command - let result; - if (subshell.command.type === 'sequence') { - result = await this._runSequence(subshell.command); - } else if (subshell.command.type === 'pipeline') { - result = await this._runPipeline(subshell.command.commands); - } else if (subshell.command.type === 'simple') { - result = await this._runSimpleCommand(subshell.command); - } else { - result = { code: 0, stdout: '', stderr: '' }; - } - - return result; - } finally { - // Restore directory - check if it still exists first - trace( - 'ProcessRunner', - () => `Restoring cwd from ${process.cwd()} to ${savedCwd}` - ); - const fs = await import('fs'); - if (fs.existsSync(savedCwd)) { - process.chdir(savedCwd); - } else { - // If the saved directory was deleted, try to go to a safe location - const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/'; - trace( - 'ProcessRunner', - () => - `Saved directory ${savedCwd} no longer exists, falling back to ${fallbackDir}` - ); - try { - process.chdir(fallbackDir); - } catch (e) { - // If even fallback fails, just stay where we are - trace( - 'ProcessRunner', - () => `Failed to restore directory: ${e.message}` - ); - } - } - } - } - - async _runSimpleCommand(command) { - trace( - 'ProcessRunner', - () => - `_runSimpleCommand ENTER | ${JSON.stringify( - { - cmd: command.cmd, - argsCount: command.args?.length || 0, - hasRedirects: !!command.redirects, - }, - null, - 2 - )}` - ); - - const { cmd, args, redirects } = command; - - // Check for virtual command - if (virtualCommandsEnabled && virtualCommands.has(cmd)) { - trace('ProcessRunner', () => `Using virtual command: ${cmd}`); - const argValues = args.map((a) => a.value || a); - const result = await this._runVirtual(cmd, argValues); - - // Handle output redirection for virtual commands - if (redirects && redirects.length > 0) { - for (const redirect of redirects) { - if (redirect.type === '>' || redirect.type === '>>') { - const fs = await import('fs'); - if (redirect.type === '>') { - fs.writeFileSync(redirect.target, result.stdout); - } else { - fs.appendFileSync(redirect.target, result.stdout); - } - // Clear stdout since it was redirected - result.stdout = ''; - } - } - } - - return result; - } - - // Build command string for real execution - let commandStr = cmd; - for (const arg of args) { - if (arg.quoted && arg.quoteChar) { - commandStr += ` ${arg.quoteChar}${arg.value}${arg.quoteChar}`; - } else if (arg.value !== undefined) { - commandStr += ` ${arg.value}`; - } else { - commandStr += ` ${arg}`; - } - } - - // Add redirections - if (redirects) { - for (const redirect of redirects) { - commandStr += ` ${redirect.type} ${redirect.target}`; - } - } - - trace('ProcessRunner', () => `Executing real command: ${commandStr}`); - - // Create a new ProcessRunner for the real command - // Use current working directory since cd virtual command may have changed it - const runner = new ProcessRunner( - { mode: 'shell', command: commandStr }, - { ...this.options, cwd: process.cwd(), _bypassVirtual: true } - ); - - return await runner; - } - - async *stream() { - trace( - 'ProcessRunner', - () => - `stream ENTER | ${JSON.stringify( - { - started: this.started, - finished: this.finished, - command: this.spec?.command?.slice(0, 100), - }, - null, - 2 - )}` - ); - - // Mark that we're in streaming mode to bypass shell operator interception - this._isStreaming = true; - - if (!this.started) { - trace( - 'ProcessRunner', - () => 'Auto-starting async process from stream() with streaming mode' - ); - this._startAsync(); // Start but don't await - } - - let buffer = []; - let resolve, reject; - let ended = false; - let cleanedUp = false; - let killed = false; - - const onData = (chunk) => { - // Don't buffer more data if we're being killed - if (!killed) { - buffer.push(chunk); - if (resolve) { - resolve(); - resolve = reject = null; - } - } - }; - - const onEnd = () => { - ended = true; - if (resolve) { - resolve(); - resolve = reject = null; - } - }; - - this.on('data', onData); - this.on('end', onEnd); - - try { - while (!ended || buffer.length > 0) { - // Check if we've been killed and should stop immediately - if (killed) { - trace('ProcessRunner', () => 'Stream killed, stopping iteration'); - break; - } - if (buffer.length > 0) { - const chunk = buffer.shift(); - // Set a flag that we're about to yield - if the consumer breaks, - // we'll know not to process any more data - this._streamYielding = true; - yield chunk; - this._streamYielding = false; - } else if (!ended) { - await new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - } - } - } finally { - cleanedUp = true; - this.off('data', onData); - this.off('end', onEnd); - - // This happens when breaking from a for-await loop - if (!this.finished) { - killed = true; - buffer = []; // Clear any buffered data - this._streamBreaking = true; // Signal that stream is breaking - this.kill(); - } - } - } - - kill(signal = 'SIGTERM') { - trace( - 'ProcessRunner', - () => - `kill ENTER | ${JSON.stringify( - { - signal, - cancelled: this._cancelled, - finished: this.finished, - hasChild: !!this.child, - hasVirtualGenerator: !!this._virtualGenerator, - command: this.spec?.command?.slice(0, 50) || 'unknown', - }, - null, - 2 - )}` - ); - - if (this.finished) { - trace('ProcessRunner', () => 'Already finished, skipping kill'); - return; - } - - // Mark as cancelled for virtual commands and store the signal - trace( - 'ProcessRunner', - () => - `Marking as cancelled | ${JSON.stringify( - { - signal, - previouslyCancelled: this._cancelled, - previousSignal: this._cancellationSignal, - }, - null, - 2 - )}` - ); - this._cancelled = true; - this._cancellationSignal = signal; - - // If this is a pipeline runner, also kill the source and destination - if (this.spec?.mode === 'pipeline') { - trace('ProcessRunner', () => 'Killing pipeline components'); - if (this.spec.source && typeof this.spec.source.kill === 'function') { - this.spec.source.kill(signal); - } - if ( - this.spec.destination && - typeof this.spec.destination.kill === 'function' - ) { - this.spec.destination.kill(signal); - } - } - - if (this._cancelResolve) { - trace('ProcessRunner', () => 'Resolving cancel promise'); - this._cancelResolve(); - trace('ProcessRunner', () => 'Cancel promise resolved'); - } else { - trace('ProcessRunner', () => 'No cancel promise to resolve'); - } - - // Abort any async operations - if (this._abortController) { - trace( - 'ProcessRunner', - () => - `Aborting internal controller | ${JSON.stringify( - { - wasAborted: this._abortController?.signal?.aborted, - }, - null, - 2 - )}` - ); - this._abortController.abort(); - trace( - 'ProcessRunner', - () => - `Internal controller aborted | ${JSON.stringify( - { - nowAborted: this._abortController?.signal?.aborted, - }, - null, - 2 - )}` - ); - } else { - trace('ProcessRunner', () => 'No abort controller to abort'); - } - - // If it's a virtual generator, try to close it - if (this._virtualGenerator) { - trace( - 'ProcessRunner', - () => - `Virtual generator found for cleanup | ${JSON.stringify( - { - hasReturn: typeof this._virtualGenerator.return === 'function', - hasThrow: typeof this._virtualGenerator.throw === 'function', - cancelled: this._cancelled, - signal, - }, - null, - 2 - )}` - ); - - if (this._virtualGenerator.return) { - trace('ProcessRunner', () => 'Closing virtual generator with return()'); - try { - this._virtualGenerator.return(); - trace('ProcessRunner', () => 'Virtual generator closed successfully'); - } catch (err) { - trace( - 'ProcessRunner', - () => - `Error closing generator | ${JSON.stringify( - { - error: err.message, - stack: err.stack?.slice(0, 200), - }, - null, - 2 - )}` - ); - } - } else { - trace( - 'ProcessRunner', - () => 'Virtual generator has no return() method' - ); - } - } else { - trace( - 'ProcessRunner', - () => - `No virtual generator to cleanup | ${JSON.stringify( - { - hasVirtualGenerator: !!this._virtualGenerator, - }, - null, - 2 - )}` - ); - } - - // Kill child process if it exists - if (this.child && !this.finished) { - trace( - 'ProcessRunner', - () => - `BRANCH: hasChild => killing | ${JSON.stringify({ pid: this.child.pid }, null, 2)}` - ); - try { - if (this.child.pid) { - if (isBun) { - trace( - 'ProcessRunner', - () => - `Killing Bun process | ${JSON.stringify({ pid: this.child.pid }, null, 2)}` - ); - - // For Bun, use the same enhanced kill logic as Node.js for CI reliability - const killOperations = []; - - // Try SIGTERM first - try { - process.kill(this.child.pid, 'SIGTERM'); - trace( - 'ProcessRunner', - () => `Sent SIGTERM to Bun process ${this.child.pid}` - ); - killOperations.push('SIGTERM to process'); - } catch (err) { - trace( - 'ProcessRunner', - () => `Error sending SIGTERM to Bun process: ${err.message}` - ); - } - - // Try process group SIGTERM - try { - process.kill(-this.child.pid, 'SIGTERM'); - trace( - 'ProcessRunner', - () => `Sent SIGTERM to Bun process group -${this.child.pid}` - ); - killOperations.push('SIGTERM to group'); - } catch (err) { - trace( - 'ProcessRunner', - () => `Bun process group SIGTERM failed: ${err.message}` - ); - } - - // Immediately follow with SIGKILL for both process and group - try { - process.kill(this.child.pid, 'SIGKILL'); - trace( - 'ProcessRunner', - () => `Sent SIGKILL to Bun process ${this.child.pid}` - ); - killOperations.push('SIGKILL to process'); - } catch (err) { - trace( - 'ProcessRunner', - () => `Error sending SIGKILL to Bun process: ${err.message}` - ); - } - - try { - process.kill(-this.child.pid, 'SIGKILL'); - trace( - 'ProcessRunner', - () => `Sent SIGKILL to Bun process group -${this.child.pid}` - ); - killOperations.push('SIGKILL to group'); - } catch (err) { - trace( - 'ProcessRunner', - () => `Bun process group SIGKILL failed: ${err.message}` - ); - } - - trace( - 'ProcessRunner', - () => - `Bun kill operations attempted: ${killOperations.join(', ')}` - ); - - // Also call the original Bun kill method as backup - try { - this.child.kill(); - trace( - 'ProcessRunner', - () => `Called child.kill() for Bun process ${this.child.pid}` - ); - } catch (err) { - trace( - 'ProcessRunner', - () => `Error calling child.kill(): ${err.message}` - ); - } - - // Force cleanup of child reference - if (this.child) { - this.child.removeAllListeners?.(); - this.child = null; - } - } else { - // In Node.js, use a more robust approach for CI environments - trace( - 'ProcessRunner', - () => - `Killing Node process | ${JSON.stringify({ pid: this.child.pid }, null, 2)}` - ); - - // Use immediate and aggressive termination for CI environments - const killOperations = []; - - // Try SIGTERM to the process directly - try { - process.kill(this.child.pid, 'SIGTERM'); - trace( - 'ProcessRunner', - () => `Sent SIGTERM to process ${this.child.pid}` - ); - killOperations.push('SIGTERM to process'); - } catch (err) { - trace( - 'ProcessRunner', - () => `Error sending SIGTERM to process: ${err.message}` - ); - } - - // Try process group if detached (negative PID) - try { - process.kill(-this.child.pid, 'SIGTERM'); - trace( - 'ProcessRunner', - () => `Sent SIGTERM to process group -${this.child.pid}` - ); - killOperations.push('SIGTERM to group'); - } catch (err) { - trace( - 'ProcessRunner', - () => `Process group SIGTERM failed: ${err.message}` - ); - } - - // Immediately follow up with SIGKILL for CI reliability - try { - process.kill(this.child.pid, 'SIGKILL'); - trace( - 'ProcessRunner', - () => `Sent SIGKILL to process ${this.child.pid}` - ); - killOperations.push('SIGKILL to process'); - } catch (err) { - trace( - 'ProcessRunner', - () => `Error sending SIGKILL to process: ${err.message}` - ); - } - - try { - process.kill(-this.child.pid, 'SIGKILL'); - trace( - 'ProcessRunner', - () => `Sent SIGKILL to process group -${this.child.pid}` - ); - killOperations.push('SIGKILL to group'); - } catch (err) { - trace( - 'ProcessRunner', - () => `Process group SIGKILL failed: ${err.message}` - ); - } - - trace( - 'ProcessRunner', - () => `Kill operations attempted: ${killOperations.join(', ')}` - ); - - // Force cleanup of child reference to prevent hanging awaits - if (this.child) { - this.child.removeAllListeners?.(); - this.child = null; - } - } - } - // finished will be set by the main cleanup below - } catch (err) { - // Process might already be dead - trace( - 'ProcessRunner', - () => - `Error killing process | ${JSON.stringify({ error: err.message }, null, 2)}` - ); - console.error('Error killing process:', err.message); - } - } - - // Mark as finished and emit completion events - const result = createResult({ - code: signal === 'SIGKILL' ? 137 : signal === 'SIGTERM' ? 143 : 130, - stdout: '', - stderr: `Process killed with ${signal}`, - stdin: '', - }); - this.finish(result); - - trace( - 'ProcessRunner', - () => - `kill EXIT | ${JSON.stringify( - { - cancelled: this._cancelled, - finished: this.finished, - }, - null, - 2 - )}` - ); - } - - pipe(destination) { - trace( - 'ProcessRunner', - () => - `pipe ENTER | ${JSON.stringify( - { - hasDestination: !!destination, - destinationType: destination?.constructor?.name, - }, - null, - 2 - )}` - ); - - if (destination instanceof ProcessRunner) { - trace( - 'ProcessRunner', - () => - `BRANCH: pipe => PROCESS_RUNNER_DEST | ${JSON.stringify({}, null, 2)}` - ); - const pipeSpec = { - mode: 'pipeline', - source: this, - destination, - }; - - const pipeRunner = new ProcessRunner(pipeSpec, { - ...this.options, - capture: destination.options.capture ?? true, - }); - - trace( - 'ProcessRunner', - () => `pipe EXIT | ${JSON.stringify({ mode: 'pipeline' }, null, 2)}` - ); - return pipeRunner; - } - - // If destination is a template literal result (from $`command`), use its spec - if (destination && destination.spec) { - trace( - 'ProcessRunner', - () => - `BRANCH: pipe => TEMPLATE_LITERAL_DEST | ${JSON.stringify({}, null, 2)}` - ); - const destRunner = new ProcessRunner( - destination.spec, - destination.options - ); - return this.pipe(destRunner); - } - - trace( - 'ProcessRunner', - () => `BRANCH: pipe => INVALID_DEST | ${JSON.stringify({}, null, 2)}` - ); - throw new Error( - 'pipe() destination must be a ProcessRunner or $`command` result' - ); - } - - // Promise interface (for await) - then(onFulfilled, onRejected) { - trace( - 'ProcessRunner', - () => - `then() called | ${JSON.stringify( - { - hasPromise: !!this.promise, - started: this.started, - finished: this.finished, - }, - null, - 2 - )}` - ); - - if (!this.promise) { - this.promise = this._startAsync(); - } - return this.promise.then(onFulfilled, onRejected); - } - - catch(onRejected) { - trace( - 'ProcessRunner', - () => - `catch() called | ${JSON.stringify( - { - hasPromise: !!this.promise, - started: this.started, - finished: this.finished, - }, - null, - 2 - )}` - ); - - if (!this.promise) { - this.promise = this._startAsync(); - } - return this.promise.catch(onRejected); - } - - finally(onFinally) { - trace( - 'ProcessRunner', - () => - `finally() called | ${JSON.stringify( - { - hasPromise: !!this.promise, - started: this.started, - finished: this.finished, - }, - null, - 2 - )}` - ); - - if (!this.promise) { - this.promise = this._startAsync(); - } - return this.promise.finally(() => { - // Ensure cleanup happened - if (!this.finished) { - trace('ProcessRunner', () => 'Finally handler ensuring cleanup'); - const fallbackResult = createResult({ - code: 1, - stdout: '', - stderr: 'Process terminated unexpectedly', - stdin: '', - }); - this.finish(fallbackResult); - } - if (onFinally) { - onFinally(); - } - }); - } - - // Internal sync execution - _startSync() { - trace( - 'ProcessRunner', - () => - `_startSync ENTER | ${JSON.stringify( - { - started: this.started, - spec: this.spec, - }, - null, - 2 - )}` - ); - - if (this.started) { - trace( - 'ProcessRunner', - () => - `BRANCH: _startSync => ALREADY_STARTED | ${JSON.stringify({}, null, 2)}` - ); - throw new Error( - 'Command already started - cannot run sync after async start' - ); - } - - this.started = true; - this._mode = 'sync'; - trace( - 'ProcessRunner', - () => - `Starting sync execution | ${JSON.stringify({ mode: this._mode }, null, 2)}` - ); - - const { cwd, env, stdin } = this.options; - const shell = findAvailableShell(); - const argv = - this.spec.mode === 'shell' - ? [shell.cmd, ...shell.args, this.spec.command] - : [this.spec.file, ...this.spec.args]; - - if (globalShellSettings.xtrace) { - const traceCmd = - this.spec.mode === 'shell' ? this.spec.command : argv.join(' '); - console.log(`+ ${traceCmd}`); - } - - if (globalShellSettings.verbose) { - const verboseCmd = - this.spec.mode === 'shell' ? this.spec.command : argv.join(' '); - console.log(verboseCmd); - } - - let result; - - if (isBun) { - // Use Bun's synchronous spawn - const proc = Bun.spawnSync(argv, { - cwd, - env, - stdin: - typeof stdin === 'string' - ? Buffer.from(stdin) - : Buffer.isBuffer(stdin) - ? stdin - : stdin === 'ignore' - ? undefined - : undefined, - stdout: 'pipe', - stderr: 'pipe', - }); - - result = createResult({ - code: proc.exitCode || 0, - stdout: proc.stdout?.toString('utf8') || '', - stderr: proc.stderr?.toString('utf8') || '', - stdin: - typeof stdin === 'string' - ? stdin - : Buffer.isBuffer(stdin) - ? stdin.toString('utf8') - : '', - }); - result.child = proc; - } else { - // Use Node's synchronous spawn - const proc = cp.spawnSync(argv[0], argv.slice(1), { - cwd, - env, - input: - typeof stdin === 'string' - ? stdin - : Buffer.isBuffer(stdin) - ? stdin - : undefined, - encoding: 'utf8', - stdio: ['pipe', 'pipe', 'pipe'], - }); - - result = createResult({ - code: proc.status || 0, - stdout: proc.stdout || '', - stderr: proc.stderr || '', - stdin: - typeof stdin === 'string' - ? stdin - : Buffer.isBuffer(stdin) - ? stdin.toString('utf8') - : '', - }); - result.child = proc; - } - - // Mirror output if requested (but always capture for result) - if (this.options.mirror) { - if (result.stdout) { - safeWrite(process.stdout, result.stdout); - } - if (result.stderr) { - safeWrite(process.stderr, result.stderr); - } - } - - // Store chunks for events (batched after completion) - this.outChunks = result.stdout ? [Buffer.from(result.stdout)] : []; - this.errChunks = result.stderr ? [Buffer.from(result.stderr)] : []; - - // Emit batched events after completion - if (result.stdout) { - const stdoutBuf = Buffer.from(result.stdout); - this._emitProcessedData('stdout', stdoutBuf); - } - - if (result.stderr) { - const stderrBuf = Buffer.from(result.stderr); - this._emitProcessedData('stderr', stderrBuf); - } - - this.finish(result); +// command-stream - A unified shell command execution library +// Main entry point - integrates all ProcessRunner modules + +import { trace } from './$.trace.mjs'; +import { + globalShellSettings, + virtualCommands, + isVirtualCommandsEnabled, + enableVirtualCommands as enableVirtualCommandsState, + disableVirtualCommands as disableVirtualCommandsState, + forceCleanupAll, + resetGlobalState, +} from './$.state.mjs'; +import { buildShellCommand, quote, raw } from './$.quote.mjs'; +import { + AnsiUtils, + configureAnsi, + getAnsiConfig, + processOutput, +} from './$.ansi.mjs'; + +// Import ProcessRunner base and method modules +import { ProcessRunner } from './$.process-runner-base.mjs'; +import { attachExecutionMethods } from './$.process-runner-execution.mjs'; +import { attachPipelineMethods } from './$.process-runner-pipeline.mjs'; +import { attachOrchestrationMethods } from './$.process-runner-orchestration.mjs'; +import { attachVirtualCommandMethods } from './$.process-runner-virtual.mjs'; +import { attachStreamKillMethods } from './$.process-runner-stream-kill.mjs'; + +// Create dependencies object for method attachment +const deps = { + virtualCommands, + globalShellSettings, + isVirtualCommandsEnabled, +}; - if (globalShellSettings.errexit && result.code !== 0) { - const error = new Error(`Command failed with exit code ${result.code}`); - error.code = result.code; - error.stdout = result.stdout; - error.stderr = result.stderr; - error.result = result; - throw error; - } +// Attach all methods to ProcessRunner prototype using mixin pattern +attachExecutionMethods(ProcessRunner, deps); +attachPipelineMethods(ProcessRunner, deps); +attachOrchestrationMethods(ProcessRunner, deps); +attachVirtualCommandMethods(ProcessRunner, deps); +attachStreamKillMethods(ProcessRunner, deps); - return result; - } -} +trace( + 'Initialization', + () => 'ProcessRunner methods attached via mixin pattern' +); // Public APIs async function sh(commandString, options = {}) { @@ -6328,6 +99,7 @@ async function exec(file, args = [], options = {}) { return result; } +// eslint-disable-next-line require-await -- delegates to sh/exec which are async async function run(commandOrTokens, options = {}) { trace( 'API', @@ -6469,11 +241,6 @@ function create(defaultOptions = {}) { return tagged; } -function raw(value) { - trace('API', () => `raw() called with value: ${String(value).slice(0, 50)}`); - return { raw: String(value) }; -} - function set(option) { trace('API', () => `set() called with option: ${option}`); const mapping = { @@ -6573,17 +340,9 @@ function listCommands() { return commands; } -function enableVirtualCommands() { - trace('VirtualCommands', () => 'Enabling virtual commands'); - virtualCommandsEnabled = true; - return virtualCommandsEnabled; -} - -function disableVirtualCommands() { - trace('VirtualCommands', () => 'Disabling virtual commands'); - virtualCommandsEnabled = false; - return virtualCommandsEnabled; -} +// Use the imported functions from $.state.mjs +const enableVirtualCommands = enableVirtualCommandsState; +const disableVirtualCommands = disableVirtualCommandsState; // Import virtual commands import cdCommand from './commands/$.cd.mjs'; @@ -6638,98 +397,6 @@ function registerBuiltins() { register('test', testCommand); } -// ANSI control character utilities -const AnsiUtils = { - stripAnsi(text) { - if (typeof text !== 'string') { - return text; - } - return text.replace(/\x1b\[[0-9;]*[mGKHFJ]/g, ''); - }, - - stripControlChars(text) { - if (typeof text !== 'string') { - return text; - } - // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09) - return text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); - }, - - stripAll(text) { - if (typeof text !== 'string') { - return text; - } - // Preserve newlines (\n = \x0A), carriage returns (\r = \x0D), and tabs (\t = \x09) - return text.replace( - /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]|\x1b\[[0-9;]*[mGKHFJ]/g, - '' - ); - }, - - cleanForProcessing(data) { - if (Buffer.isBuffer(data)) { - return Buffer.from(this.stripAll(data.toString('utf8'))); - } - return this.stripAll(data); - }, -}; - -let globalAnsiConfig = { - preserveAnsi: true, - preserveControlChars: true, -}; - -function configureAnsi(options = {}) { - trace( - 'AnsiUtils', - () => `configureAnsi() called | ${JSON.stringify({ options }, null, 2)}` - ); - globalAnsiConfig = { ...globalAnsiConfig, ...options }; - trace( - 'AnsiUtils', - () => `New ANSI config | ${JSON.stringify({ globalAnsiConfig }, null, 2)}` - ); - return globalAnsiConfig; -} - -function getAnsiConfig() { - trace( - 'AnsiUtils', - () => - `getAnsiConfig() returning | ${JSON.stringify({ globalAnsiConfig }, null, 2)}` - ); - return { ...globalAnsiConfig }; -} - -function processOutput(data, options = {}) { - trace( - 'AnsiUtils', - () => - `processOutput() called | ${JSON.stringify( - { - dataType: typeof data, - dataLength: Buffer.isBuffer(data) ? data.length : data?.length, - options, - }, - null, - 2 - )}` - ); - const config = { ...globalAnsiConfig, ...options }; - if (!config.preserveAnsi && !config.preserveControlChars) { - return AnsiUtils.cleanForProcessing(data); - } else if (!config.preserveAnsi) { - return Buffer.isBuffer(data) - ? Buffer.from(AnsiUtils.stripAnsi(data.toString('utf8'))) - : AnsiUtils.stripAnsi(data); - } else if (!config.preserveControlChars) { - return Buffer.isBuffer(data) - ? Buffer.from(AnsiUtils.stripControlChars(data.toString('utf8'))) - : AnsiUtils.stripControlChars(data); - } - return data; -} - // Initialize built-in commands trace('Initialization', () => 'Registering built-in virtual commands'); registerBuiltins(); diff --git a/js/src/$.process-runner-base.mjs b/js/src/$.process-runner-base.mjs new file mode 100644 index 0000000..b778e92 --- /dev/null +++ b/js/src/$.process-runner-base.mjs @@ -0,0 +1,563 @@ +// ProcessRunner base class - core constructor, properties, and lifecycle methods +// Part of the modular ProcessRunner architecture + +import { trace } from './$.trace.mjs'; +import { + activeProcessRunners, + virtualCommands, + installSignalHandlers, + monitorParentStreams, + uninstallSignalHandlers, +} from './$.state.mjs'; +import { StreamEmitter } from './$.stream-emitter.mjs'; +import { processOutput } from './$.ansi.mjs'; + +const isBun = typeof globalThis.Bun !== 'undefined'; + +/** + * Wait for child stream to become available + * @param {object} self - ProcessRunner instance + * @param {string} streamName - Name of stream (stdin, stdout, stderr) + * @returns {Promise} + */ +function waitForChildStream(self, streamName) { + return new Promise((resolve) => { + const checkForChild = () => { + if (self.child && self.child[streamName]) { + resolve(self.child[streamName]); + } else if (self.finished || self._virtualGenerator) { + resolve(null); + } else { + setImmediate(checkForChild); + } + }; + setImmediate(checkForChild); + }); +} + +/** + * Check if command is a virtual command + * @param {object} self - ProcessRunner instance + * @returns {boolean} + */ +function isVirtualCommand(self) { + return ( + self._virtualGenerator || + (self.spec && + self.spec.command && + virtualCommands.has(self.spec.command.split(' ')[0])) + ); +} + +/** + * Get stream from child or wait for it + * @param {object} self - ProcessRunner instance + * @param {string} streamName - Name of stream + * @param {boolean} checkVirtual - Whether to check for virtual commands + * @returns {object|Promise|null} + */ +function getOrWaitForStream(self, streamName, checkVirtual = true) { + self._autoStartIfNeeded(`streams.${streamName} access`); + + if (self.child && self.child[streamName]) { + return self.child[streamName]; + } + if (self.finished) { + return null; + } + if (checkVirtual && isVirtualCommand(self)) { + return null; + } + if (!self.started) { + self._startAsync(); + return waitForChildStream(self, streamName); + } + if (self.promise && !self.child) { + return waitForChildStream(self, streamName); + } + return null; +} + +/** + * Get stdin stream with special handling for pipe mode + * @param {object} self - ProcessRunner instance + * @returns {object|Promise|null} + */ +function getStdinStream(self) { + self._autoStartIfNeeded('streams.stdin access'); + + if (self.child && self.child.stdin) { + return self.child.stdin; + } + if (self.finished) { + return null; + } + + const isVirtual = isVirtualCommand(self); + const willFallbackToReal = isVirtual && self.options.stdin === 'pipe'; + + if (isVirtual && !willFallbackToReal) { + return null; + } + if (!self.started) { + self._startAsync(); + return waitForChildStream(self, 'stdin'); + } + if (self.promise && !self.child) { + return waitForChildStream(self, 'stdin'); + } + return null; +} + +/** + * Cleanup abort controller + * @param {object} runner - ProcessRunner instance + */ +function cleanupAbortController(runner) { + if (!runner._abortController) { + return; + } + trace('ProcessRunner', () => 'Cleaning up abort controller'); + try { + runner._abortController.abort(); + } catch (e) { + trace('ProcessRunner', () => `Error aborting controller: ${e.message}`); + } + runner._abortController = null; +} + +/** + * Cleanup child process reference + * @param {object} runner - ProcessRunner instance + */ +function cleanupChildProcess(runner) { + if (!runner.child) { + return; + } + trace('ProcessRunner', () => `Cleaning up child process ${runner.child.pid}`); + try { + runner.child.removeAllListeners?.(); + } catch (e) { + trace('ProcessRunner', () => `Error removing listeners: ${e.message}`); + } + runner.child = null; +} + +/** + * Cleanup virtual generator + * @param {object} runner - ProcessRunner instance + */ +function cleanupGenerator(runner) { + if (!runner._virtualGenerator) { + return; + } + trace('ProcessRunner', () => 'Cleaning up virtual generator'); + try { + if (runner._virtualGenerator.return) { + runner._virtualGenerator.return(); + } + } catch (e) { + trace('ProcessRunner', () => `Error closing generator: ${e.message}`); + } + runner._virtualGenerator = null; +} + +/** + * Cleanup pipeline components + * @param {object} runner - ProcessRunner instance + */ +function cleanupPipeline(runner) { + if (runner.spec?.mode !== 'pipeline') { + return; + } + trace('ProcessRunner', () => 'Cleaning up pipeline components'); + if (runner.spec.source && typeof runner.spec.source._cleanup === 'function') { + runner.spec.source._cleanup(); + } + if ( + runner.spec.destination && + typeof runner.spec.destination._cleanup === 'function' + ) { + runner.spec.destination._cleanup(); + } +} + +/** + * ProcessRunner - Enhanced process runner with streaming capabilities + * Extends StreamEmitter for event-based output handling + */ +class ProcessRunner extends StreamEmitter { + constructor(spec, options = {}) { + super(); + + trace( + 'ProcessRunner', + () => + `constructor ENTER | ${JSON.stringify( + { + spec: + typeof spec === 'object' + ? { ...spec, command: spec.command?.slice(0, 100) } + : spec, + options, + }, + null, + 2 + )}` + ); + + this.spec = spec; + this.options = { + mirror: true, + capture: true, + stdin: 'inherit', + cwd: undefined, + env: undefined, + interactive: false, + shellOperators: true, + ...options, + }; + + this.outChunks = this.options.capture ? [] : null; + this.errChunks = this.options.capture ? [] : null; + this.inChunks = + this.options.capture && this.options.stdin === 'inherit' + ? [] + : this.options.capture && + (typeof this.options.stdin === 'string' || + Buffer.isBuffer(this.options.stdin)) + ? [Buffer.from(this.options.stdin)] + : []; + + this.result = null; + this.child = null; + this.started = false; + this.finished = false; + + this.promise = null; + this._mode = null; + + this._cancelled = false; + this._cancellationSignal = null; + this._virtualGenerator = null; + this._abortController = new AbortController(); + + activeProcessRunners.add(this); + monitorParentStreams(); + + trace( + 'ProcessRunner', + () => + `Added to activeProcessRunners | ${JSON.stringify( + { + command: this.spec?.command || 'unknown', + totalActive: activeProcessRunners.size, + }, + null, + 2 + )}` + ); + installSignalHandlers(); + + this.finished = false; + } + + // Stream property getters + get stdout() { + trace( + 'ProcessRunner', + () => + `stdout getter accessed | ${JSON.stringify( + { + hasChild: !!this.child, + hasStdout: !!(this.child && this.child.stdout), + }, + null, + 2 + )}` + ); + return this.child ? this.child.stdout : null; + } + + get stderr() { + trace( + 'ProcessRunner', + () => + `stderr getter accessed | ${JSON.stringify( + { + hasChild: !!this.child, + hasStderr: !!(this.child && this.child.stderr), + }, + null, + 2 + )}` + ); + return this.child ? this.child.stderr : null; + } + + get stdin() { + trace( + 'ProcessRunner', + () => + `stdin getter accessed | ${JSON.stringify( + { + hasChild: !!this.child, + hasStdin: !!(this.child && this.child.stdin), + }, + null, + 2 + )}` + ); + return this.child ? this.child.stdin : null; + } + + _autoStartIfNeeded(reason) { + if (!this.started && !this.finished) { + trace('ProcessRunner', () => `Auto-starting process due to ${reason}`); + this.start({ + mode: 'async', + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + } + } + + get streams() { + const self = this; + return { + get stdin() { + trace('ProcessRunner.streams', () => `stdin access`); + return getStdinStream(self); + }, + get stdout() { + trace('ProcessRunner.streams', () => `stdout access`); + return getOrWaitForStream(self, 'stdout'); + }, + get stderr() { + trace('ProcessRunner.streams', () => `stderr access`); + return getOrWaitForStream(self, 'stderr'); + }, + }; + } + + get buffers() { + const self = this; + return { + get stdin() { + self._autoStartIfNeeded('buffers.stdin access'); + if (self.finished && self.result) { + return Buffer.from(self.result.stdin || '', 'utf8'); + } + return self.then + ? self.then((result) => Buffer.from(result.stdin || '', 'utf8')) + : Promise.resolve(Buffer.alloc(0)); + }, + get stdout() { + self._autoStartIfNeeded('buffers.stdout access'); + if (self.finished && self.result) { + return Buffer.from(self.result.stdout || '', 'utf8'); + } + return self.then + ? self.then((result) => Buffer.from(result.stdout || '', 'utf8')) + : Promise.resolve(Buffer.alloc(0)); + }, + get stderr() { + self._autoStartIfNeeded('buffers.stderr access'); + if (self.finished && self.result) { + return Buffer.from(self.result.stderr || '', 'utf8'); + } + return self.then + ? self.then((result) => Buffer.from(result.stderr || '', 'utf8')) + : Promise.resolve(Buffer.alloc(0)); + }, + }; + } + + get strings() { + const self = this; + return { + get stdin() { + self._autoStartIfNeeded('strings.stdin access'); + if (self.finished && self.result) { + return self.result.stdin || ''; + } + return self.then + ? self.then((result) => result.stdin || '') + : Promise.resolve(''); + }, + get stdout() { + self._autoStartIfNeeded('strings.stdout access'); + if (self.finished && self.result) { + return self.result.stdout || ''; + } + return self.then + ? self.then((result) => result.stdout || '') + : Promise.resolve(''); + }, + get stderr() { + self._autoStartIfNeeded('strings.stderr access'); + if (self.finished && self.result) { + return self.result.stderr || ''; + } + return self.then + ? self.then((result) => result.stderr || '') + : Promise.resolve(''); + }, + }; + } + + // Centralized method to properly finish a process with correct event emission order + finish(result) { + trace( + 'ProcessRunner', + () => + `finish() called | ${JSON.stringify( + { + alreadyFinished: this.finished, + resultCode: result?.code, + hasStdout: !!result?.stdout, + hasStderr: !!result?.stderr, + command: this.spec?.command?.slice(0, 50), + }, + null, + 2 + )}` + ); + + if (this.finished) { + trace( + 'ProcessRunner', + () => `Already finished, returning existing result` + ); + return this.result || result; + } + + this.result = result; + trace('ProcessRunner', () => `Result stored, about to emit events`); + + this.emit('end', result); + trace('ProcessRunner', () => `'end' event emitted`); + this.emit('exit', result.code); + trace( + 'ProcessRunner', + () => `'exit' event emitted with code ${result.code}` + ); + + this.finished = true; + trace('ProcessRunner', () => `Marked as finished, calling cleanup`); + + this._cleanup(); + trace('ProcessRunner', () => `Cleanup completed`); + + return result; + } + + _emitProcessedData(type, buf) { + if (this._cancelled) { + trace( + 'ProcessRunner', + () => 'Skipping data emission - process cancelled' + ); + return; + } + const processedBuf = processOutput(buf, this.options.ansi); + this.emit(type, processedBuf); + this.emit('data', { type, data: processedBuf }); + } + + _handleParentStreamClosure() { + if (this.finished || this._cancelled) { + trace( + 'ProcessRunner', + () => + `Parent stream closure ignored | ${JSON.stringify({ + finished: this.finished, + cancelled: this._cancelled, + })}` + ); + return; + } + + trace( + 'ProcessRunner', + () => + `Handling parent stream closure | ${JSON.stringify( + { + started: this.started, + hasChild: !!this.child, + command: this.spec.command?.slice(0, 50) || this.spec.file, + }, + null, + 2 + )}` + ); + + this._cancelled = true; + + if (this._abortController) { + this._abortController.abort(); + } + + if (this.child) { + try { + if (this.child.stdin && typeof this.child.stdin.end === 'function') { + this.child.stdin.end(); + } else if ( + isBun && + this.child.stdin && + typeof this.child.stdin.getWriter === 'function' + ) { + const writer = this.child.stdin.getWriter(); + writer.close().catch(() => {}); + } + + setImmediate(() => { + if (this.child && !this.finished) { + trace( + 'ProcessRunner', + () => 'Terminating child process after parent stream closure' + ); + if (typeof this.child.kill === 'function') { + this.child.kill('SIGTERM'); + } + } + }); + } catch (error) { + trace( + 'ProcessRunner', + () => + `Error during graceful shutdown | ${JSON.stringify({ error: error.message }, null, 2)}` + ); + } + } + + this._cleanup(); + } + + _cleanup() { + trace( + 'ProcessRunner', + () => `_cleanup() | active=${activeProcessRunners.size}` + ); + + activeProcessRunners.delete(this); + cleanupPipeline(this); + + if (activeProcessRunners.size === 0) { + uninstallSignalHandlers(); + } + + if (this.listeners) { + this.listeners.clear(); + } + + cleanupAbortController(this); + cleanupChildProcess(this); + cleanupGenerator(this); + + trace('ProcessRunner', () => `_cleanup() completed`); + } +} + +export { ProcessRunner, isBun }; diff --git a/js/src/$.process-runner-execution.mjs b/js/src/$.process-runner-execution.mjs new file mode 100644 index 0000000..d16f387 --- /dev/null +++ b/js/src/$.process-runner-execution.mjs @@ -0,0 +1,1497 @@ +// ProcessRunner execution methods - start, sync, async, and related methods +// Part of the modular ProcessRunner architecture + +import cp from 'child_process'; +import { trace } from './$.trace.mjs'; +import { findAvailableShell } from './$.shell.mjs'; +import { StreamUtils, safeWrite, asBuffer } from './$.stream-utils.mjs'; +import { pumpReadable } from './$.quote.mjs'; +import { createResult } from './$.result.mjs'; +import { parseShellCommand, needsRealShell } from './shell-parser.mjs'; + +const isBun = typeof globalThis.Bun !== 'undefined'; + +/** + * Check for shell operators in command + * @param {string} command - Command to check + * @returns {boolean} + */ +function hasShellOperators(command) { + return ( + command.includes('&&') || + command.includes('||') || + command.includes('(') || + command.includes(';') || + (command.includes('cd ') && command.includes('&&')) + ); +} + +/** + * Check if command is a streaming pattern + * @param {string} command - Command to check + * @returns {boolean} + */ +function isStreamingPattern(command) { + return ( + command.includes('sleep') && + command.includes(';') && + (command.includes('echo') || command.includes('printf')) + ); +} + +/** + * Determine if shell operators should be used + * @param {object} runner - ProcessRunner instance + * @param {string} command - Command to check + * @returns {boolean} + */ +function shouldUseShellOperators(runner, command) { + const hasOps = hasShellOperators(command); + const isStreaming = isStreamingPattern(command); + return ( + runner.options.shellOperators && + hasOps && + !isStreaming && + !runner._isStreaming + ); +} + +/** + * Check if stdin is interactive + * @param {string} stdin - Stdin option + * @param {object} options - Runner options + * @returns {boolean} + */ +function isInteractiveMode(stdin, options) { + return ( + stdin === 'inherit' && + process.stdin.isTTY === true && + process.stdout.isTTY === true && + process.stderr.isTTY === true && + options.interactive === true + ); +} + +/** + * Spawn process using Bun + * @param {Array} argv - Command arguments + * @param {object} config - Spawn configuration + * @returns {object} Child process + */ +function spawnWithBun(argv, config) { + const { cwd, env, isInteractive } = config; + + trace( + 'ProcessRunner', + () => + `spawnBun: Creating process | ${JSON.stringify({ + command: argv[0], + args: argv.slice(1), + isInteractive, + cwd, + platform: process.platform, + })}` + ); + + if (isInteractive) { + trace( + 'ProcessRunner', + () => `spawnBun: Using interactive mode with inherited stdio` + ); + return Bun.spawn(argv, { + cwd, + env, + stdin: 'inherit', + stdout: 'inherit', + stderr: 'inherit', + }); + } + + trace( + 'ProcessRunner', + () => + `spawnBun: Using non-interactive mode with pipes and detached=${process.platform !== 'win32'}` + ); + + return Bun.spawn(argv, { + cwd, + env, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + detached: process.platform !== 'win32', + }); +} + +/** + * Spawn process using Node + * @param {Array} argv - Command arguments + * @param {object} config - Spawn configuration + * @returns {object} Child process + */ +function spawnWithNode(argv, config) { + const { cwd, env, isInteractive } = config; + + trace( + 'ProcessRunner', + () => + `spawnNode: Creating process | ${JSON.stringify({ + command: argv[0], + args: argv.slice(1), + isInteractive, + cwd, + platform: process.platform, + })}` + ); + + if (isInteractive) { + return cp.spawn(argv[0], argv.slice(1), { + cwd, + env, + stdio: 'inherit', + }); + } + + const child = cp.spawn(argv[0], argv.slice(1), { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + detached: process.platform !== 'win32', + }); + + trace( + 'ProcessRunner', + () => + `spawnNode: Process created | ${JSON.stringify({ + pid: child.pid, + killed: child.killed, + hasStdout: !!child.stdout, + hasStderr: !!child.stderr, + hasStdin: !!child.stdin, + })}` + ); + + return child; +} + +/** + * Spawn child process with appropriate runtime + * @param {Array} argv - Command arguments + * @param {object} config - Spawn configuration + * @returns {object} Child process + */ +function spawnChild(argv, config) { + const { stdin } = config; + const needsExplicitPipe = stdin !== 'inherit' && stdin !== 'ignore'; + const preferNodeForInput = isBun && needsExplicitPipe; + + trace( + 'ProcessRunner', + () => + `About to spawn process | ${JSON.stringify({ + needsExplicitPipe, + preferNodeForInput, + runtime: isBun ? 'Bun' : 'Node', + command: argv[0], + args: argv.slice(1), + })}` + ); + + if (preferNodeForInput) { + return spawnWithNode(argv, config); + } + return isBun ? spawnWithBun(argv, config) : spawnWithNode(argv, config); +} + +/** + * Setup child process event listeners + * @param {object} runner - ProcessRunner instance + */ +function setupChildEventListeners(runner) { + if (!runner.child || typeof runner.child.on !== 'function') { + return; + } + + runner.child.on('spawn', () => { + trace( + 'ProcessRunner', + () => + `Child process spawned successfully | ${JSON.stringify({ + pid: runner.child.pid, + command: runner.spec?.command?.slice(0, 50), + })}` + ); + }); + + runner.child.on('error', (error) => { + trace( + 'ProcessRunner', + () => + `Child process error event | ${JSON.stringify({ + pid: runner.child?.pid, + error: error.message, + code: error.code, + errno: error.errno, + syscall: error.syscall, + command: runner.spec?.command?.slice(0, 50), + })}` + ); + }); +} + +/** + * Create stdout pump + * @param {object} runner - ProcessRunner instance + * @param {number} childPid - Child process PID + * @returns {Promise} + */ +function createStdoutPump(runner, childPid) { + if (!runner.child.stdout) { + return Promise.resolve(); + } + + return pumpReadable(runner.child.stdout, (buf) => { + trace( + 'ProcessRunner', + () => + `stdout data received | ${JSON.stringify({ + pid: childPid, + bufferLength: buf.length, + capture: runner.options.capture, + mirror: runner.options.mirror, + preview: buf.toString().slice(0, 100), + })}` + ); + + if (runner.options.capture) { + runner.outChunks.push(buf); + } + if (runner.options.mirror) { + safeWrite(process.stdout, buf); + } + + runner._emitProcessedData('stdout', buf); + }); +} + +/** + * Create stderr pump + * @param {object} runner - ProcessRunner instance + * @param {number} childPid - Child process PID + * @returns {Promise} + */ +function createStderrPump(runner, childPid) { + if (!runner.child.stderr) { + return Promise.resolve(); + } + + return pumpReadable(runner.child.stderr, (buf) => { + trace( + 'ProcessRunner', + () => + `stderr data received | ${JSON.stringify({ + pid: childPid, + bufferLength: buf.length, + capture: runner.options.capture, + mirror: runner.options.mirror, + preview: buf.toString().slice(0, 100), + })}` + ); + + if (runner.options.capture) { + runner.errChunks.push(buf); + } + if (runner.options.mirror) { + safeWrite(process.stderr, buf); + } + + runner._emitProcessedData('stderr', buf); + }); +} + +/** + * Handle stdin for inherit mode + * @param {object} runner - ProcessRunner instance + * @param {boolean} isInteractive - Is interactive mode + * @returns {Promise} + */ +function handleInheritStdin(runner, isInteractive) { + if (isInteractive) { + trace( + 'ProcessRunner', + () => `stdin: Using inherit mode for interactive command` + ); + return Promise.resolve(); + } + + const isPipedIn = process.stdin && process.stdin.isTTY === false; + trace( + 'ProcessRunner', + () => + `stdin: Non-interactive inherit mode | ${JSON.stringify({ + isPipedIn, + stdinTTY: process.stdin.isTTY, + })}` + ); + + if (isPipedIn) { + trace('ProcessRunner', () => `stdin: Pumping piped input to child process`); + return runner._pumpStdinTo( + runner.child, + runner.options.capture ? runner.inChunks : null + ); + } + + trace( + 'ProcessRunner', + () => `stdin: Forwarding TTY stdin for non-interactive command` + ); + return runner._forwardTTYStdin(); +} + +/** + * Handle stdin based on configuration + * @param {object} runner - ProcessRunner instance + * @param {string|Buffer} stdin - Stdin configuration + * @param {boolean} isInteractive - Is interactive mode + * @returns {Promise} + */ +function handleStdin(runner, stdin, isInteractive) { + trace( + 'ProcessRunner', + () => + `Setting up stdin handling | ${JSON.stringify({ + stdinType: typeof stdin, + stdin: + stdin === 'inherit' + ? 'inherit' + : stdin === 'ignore' + ? 'ignore' + : typeof stdin === 'string' + ? `string(${stdin.length})` + : 'other', + isInteractive, + hasChildStdin: !!runner.child?.stdin, + processTTY: process.stdin.isTTY, + })}` + ); + + if (stdin === 'inherit') { + return handleInheritStdin(runner, isInteractive); + } + + if (stdin === 'ignore') { + trace('ProcessRunner', () => `stdin: Ignoring and closing stdin`); + if (runner.child.stdin && typeof runner.child.stdin.end === 'function') { + runner.child.stdin.end(); + } + return Promise.resolve(); + } + + if (stdin === 'pipe') { + trace( + 'ProcessRunner', + () => `stdin: Using pipe mode - leaving stdin open for manual control` + ); + return Promise.resolve(); + } + + if (typeof stdin === 'string' || Buffer.isBuffer(stdin)) { + const buf = Buffer.isBuffer(stdin) ? stdin : Buffer.from(stdin); + trace( + 'ProcessRunner', + () => + `stdin: Writing buffer to child | ${JSON.stringify({ + bufferLength: buf.length, + willCapture: runner.options.capture && !!runner.inChunks, + })}` + ); + if (runner.options.capture && runner.inChunks) { + runner.inChunks.push(Buffer.from(buf)); + } + return runner._writeToStdin(buf); + } + + return Promise.resolve(); +} + +/** + * Create promise for child exit + * @param {object} child - Child process + * @returns {Promise} + */ +function createExitPromise(child) { + if (isBun) { + return child.exited; + } + + return new Promise((resolve) => { + trace( + 'ProcessRunner', + () => `Setting up child process event listeners for PID ${child.pid}` + ); + + child.on('close', (code, signal) => { + trace( + 'ProcessRunner', + () => + `Child process close event | ${JSON.stringify({ + pid: child.pid, + code, + signal, + killed: child.killed, + exitCode: child.exitCode, + signalCode: child.signalCode, + })}` + ); + resolve(code); + }); + + child.on('exit', (code, signal) => { + trace( + 'ProcessRunner', + () => + `Child process exit event | ${JSON.stringify({ + pid: child.pid, + code, + signal, + killed: child.killed, + exitCode: child.exitCode, + signalCode: child.signalCode, + })}` + ); + }); + }); +} + +/** + * Determine final exit code + * @param {number|null|undefined} code - Raw exit code + * @param {boolean} cancelled - Was process cancelled + * @returns {number} + */ +function determineFinalExitCode(code, cancelled) { + trace( + 'ProcessRunner', + () => + `Raw exit code from child | ${JSON.stringify({ + code, + codeType: typeof code, + cancelled, + isBun, + })}` + ); + + if (code !== undefined && code !== null) { + return code; + } + + if (cancelled) { + trace( + 'ProcessRunner', + () => `Process was killed, using SIGTERM exit code 143` + ); + return 143; + } + + trace('ProcessRunner', () => `Process exited without code, defaulting to 0`); + return 0; +} + +/** + * Build result data from runner state + * @param {object} runner - ProcessRunner instance + * @param {number} exitCode - Exit code + * @returns {object} + */ +function buildResultData(runner, exitCode) { + return { + code: exitCode, + stdout: runner.options.capture + ? runner.outChunks && runner.outChunks.length > 0 + ? Buffer.concat(runner.outChunks).toString('utf8') + : '' + : undefined, + stderr: runner.options.capture + ? runner.errChunks && runner.errChunks.length > 0 + ? Buffer.concat(runner.errChunks).toString('utf8') + : '' + : undefined, + stdin: + runner.options.capture && runner.inChunks + ? Buffer.concat(runner.inChunks).toString('utf8') + : undefined, + child: runner.child, + }; +} + +/** + * Throw errexit error if needed + * @param {object} runner - ProcessRunner instance + * @param {object} globalShellSettings - Shell settings + */ +function throwErrexitIfNeeded(runner, globalShellSettings) { + if (!globalShellSettings.errexit || runner.result.code === 0) { + return; + } + + trace('ProcessRunner', () => `Errexit mode: throwing error`); + + const error = new Error( + `Command failed with exit code ${runner.result.code}` + ); + error.code = runner.result.code; + error.stdout = runner.result.stdout; + error.stderr = runner.result.stderr; + error.result = runner.result; + + throw error; +} + +/** + * Get stdin input for sync spawn + * @param {string|Buffer} stdin - Stdin option + * @returns {Buffer|undefined} + */ +function getSyncStdinInput(stdin) { + if (typeof stdin === 'string') { + return Buffer.from(stdin); + } + if (Buffer.isBuffer(stdin)) { + return stdin; + } + return undefined; +} + +/** + * Get stdin string for result + * @param {string|Buffer} stdin - Stdin option + * @returns {string} + */ +function getStdinString(stdin) { + if (typeof stdin === 'string') { + return stdin; + } + if (Buffer.isBuffer(stdin)) { + return stdin.toString('utf8'); + } + return ''; +} + +/** + * Execute sync process using Bun + * @param {Array} argv - Command arguments + * @param {object} options - Spawn options + * @returns {object} Result object + */ +function executeSyncBun(argv, options) { + const { cwd, env, stdin } = options; + const proc = Bun.spawnSync(argv, { + cwd, + env, + stdin: getSyncStdinInput(stdin), + stdout: 'pipe', + stderr: 'pipe', + }); + + const result = createResult({ + code: proc.exitCode || 0, + stdout: proc.stdout?.toString('utf8') || '', + stderr: proc.stderr?.toString('utf8') || '', + stdin: getStdinString(stdin), + }); + result.child = proc; + return result; +} + +/** + * Execute sync process using Node + * @param {Array} argv - Command arguments + * @param {object} options - Spawn options + * @returns {object} Result object + */ +function executeSyncNode(argv, options) { + const { cwd, env, stdin } = options; + const proc = cp.spawnSync(argv[0], argv.slice(1), { + cwd, + env, + input: getSyncStdinInput(stdin), + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + + const result = createResult({ + code: proc.status || 0, + stdout: proc.stdout || '', + stderr: proc.stderr || '', + stdin: getStdinString(stdin), + }); + result.child = proc; + return result; +} + +/** + * Execute sync process with appropriate runtime + * @param {Array} argv - Command arguments + * @param {object} options - Spawn options + * @returns {object} Result object + */ +function executeSyncProcess(argv, options) { + return isBun ? executeSyncBun(argv, options) : executeSyncNode(argv, options); +} + +/** + * Handle sync result processing + * @param {object} runner - ProcessRunner instance + * @param {object} result - Result object + * @param {object} globalShellSettings - Shell settings + * @returns {object} Result + */ +function processSyncResult(runner, result, globalShellSettings) { + if (runner.options.mirror) { + if (result.stdout) { + safeWrite(process.stdout, result.stdout); + } + if (result.stderr) { + safeWrite(process.stderr, result.stderr); + } + } + + runner.outChunks = result.stdout ? [Buffer.from(result.stdout)] : []; + runner.errChunks = result.stderr ? [Buffer.from(result.stderr)] : []; + + if (result.stdout) { + runner._emitProcessedData('stdout', Buffer.from(result.stdout)); + } + if (result.stderr) { + runner._emitProcessedData('stderr', Buffer.from(result.stderr)); + } + + runner.finish(result); + + if (globalShellSettings.errexit && result.code !== 0) { + const error = new Error(`Command failed with exit code ${result.code}`); + error.code = result.code; + error.stdout = result.stdout; + error.stderr = result.stderr; + error.result = result; + throw error; + } + + return result; +} + +/** + * Setup external abort signal listener + * @param {object} runner - ProcessRunner instance + */ +function setupExternalAbortSignal(runner) { + const signal = runner.options.signal; + if (!signal || typeof signal.addEventListener !== 'function') { + return; + } + + trace( + 'ProcessRunner', + () => + `Setting up external abort signal listener | ${JSON.stringify({ + hasSignal: !!signal, + signalAborted: signal.aborted, + hasInternalController: !!runner._abortController, + internalAborted: runner._abortController?.signal.aborted, + })}` + ); + + signal.addEventListener('abort', () => { + trace( + 'ProcessRunner', + () => + `External abort signal triggered | ${JSON.stringify({ + externalSignalAborted: signal.aborted, + hasInternalController: !!runner._abortController, + internalAborted: runner._abortController?.signal.aborted, + command: runner.spec?.command?.slice(0, 50), + })}` + ); + + runner.kill('SIGTERM'); + trace( + 'ProcessRunner', + () => 'Process kill initiated due to external abort signal' + ); + + if (runner._abortController && !runner._abortController.signal.aborted) { + trace( + 'ProcessRunner', + () => 'Aborting internal controller due to external signal' + ); + runner._abortController.abort(); + } + }); + + if (signal.aborted) { + trace( + 'ProcessRunner', + () => + `External signal already aborted, killing process and aborting internal controller` + ); + runner.kill('SIGTERM'); + if (runner._abortController && !runner._abortController.signal.aborted) { + runner._abortController.abort(); + } + } +} + +/** + * Reinitialize capture chunks when capture option changes + * @param {object} runner - ProcessRunner instance + */ +function reinitCaptureChunks(runner) { + trace( + 'ProcessRunner', + () => + `BRANCH: capture => REINIT_CHUNKS | ${JSON.stringify({ + capture: runner.options.capture, + })}` + ); + + runner.outChunks = runner.options.capture ? [] : null; + runner.errChunks = runner.options.capture ? [] : null; + runner.inChunks = + runner.options.capture && runner.options.stdin === 'inherit' + ? [] + : runner.options.capture && + (typeof runner.options.stdin === 'string' || + Buffer.isBuffer(runner.options.stdin)) + ? [Buffer.from(runner.options.stdin)] + : []; +} + +/** + * Try running command via enhanced shell parser + * @param {object} runner - ProcessRunner instance + * @param {string} command - Command to parse + * @returns {Promise|null} Result if handled, null if not + */ +async function tryEnhancedShellParser(runner, command) { + const enhancedParsed = parseShellCommand(command); + if (!enhancedParsed || enhancedParsed.type === 'simple') { + return null; + } + + trace( + 'ProcessRunner', + () => + `Using enhanced parser for shell operators | ${JSON.stringify({ + type: enhancedParsed.type, + command: command.slice(0, 50), + })}` + ); + + if (enhancedParsed.type === 'sequence') { + return await runner._runSequence(enhancedParsed); + } + if (enhancedParsed.type === 'subshell') { + return await runner._runSubshell(enhancedParsed); + } + if (enhancedParsed.type === 'pipeline') { + return await runner._runPipeline(enhancedParsed.commands); + } + + return null; +} + +/** + * Try running command as virtual command + * @param {object} runner - ProcessRunner instance + * @param {object} parsed - Parsed command + * @param {object} deps - Dependencies + * @returns {Promise|null} Result if handled, null if not + */ +async function tryVirtualCommand(runner, parsed, deps) { + const { virtualCommands, isVirtualCommandsEnabled } = deps; + + if ( + parsed.type !== 'simple' || + !isVirtualCommandsEnabled() || + !virtualCommands.has(parsed.cmd) || + runner.options._bypassVirtual + ) { + return null; + } + + const hasCustomStdin = + runner.options.stdin && + runner.options.stdin !== 'inherit' && + runner.options.stdin !== 'ignore'; + + const commandsThatNeedRealStdin = ['sleep', 'cat']; + const shouldBypassVirtual = + hasCustomStdin && commandsThatNeedRealStdin.includes(parsed.cmd); + + if (shouldBypassVirtual) { + trace( + 'ProcessRunner', + () => + `Bypassing built-in virtual command due to custom stdin | ${JSON.stringify( + { + cmd: parsed.cmd, + stdin: typeof runner.options.stdin, + } + )}` + ); + return null; + } + + trace( + 'ProcessRunner', + () => + `BRANCH: virtualCommand => ${parsed.cmd} | ${JSON.stringify({ + isVirtual: true, + args: parsed.args, + })}` + ); + + return await runner._runVirtual(parsed.cmd, parsed.args, runner.spec.command); +} + +/** + * Log xtrace/verbose if enabled + * @param {object} globalShellSettings - Shell settings + * @param {string} command - Command or argv + */ +function logShellTrace(globalShellSettings, command) { + if (globalShellSettings.xtrace) { + console.log(`+ ${command}`); + } + if (globalShellSettings.verbose) { + console.log(command); + } +} + +/** + * Handle shell mode execution + * @param {object} runner - ProcessRunner instance + * @param {object} deps - Dependencies + * @returns {Promise|null} Result if handled by special cases + */ +async function handleShellMode(runner, deps) { + const { virtualCommands, isVirtualCommandsEnabled } = deps; + const command = runner.spec.command; + + trace( + 'ProcessRunner', + () => `BRANCH: spec.mode => shell | ${JSON.stringify({})}` + ); + + const useShellOps = shouldUseShellOperators(runner, command); + + trace( + 'ProcessRunner', + () => + `Shell operator detection | ${JSON.stringify({ + hasShellOperators: hasShellOperators(command), + shellOperatorsEnabled: runner.options.shellOperators, + isStreamingPattern: isStreamingPattern(command), + isStreaming: runner._isStreaming, + shouldUseShellOperators: useShellOps, + command: command.slice(0, 100), + })}` + ); + + if ( + !runner.options._bypassVirtual && + useShellOps && + !needsRealShell(command) + ) { + const result = await tryEnhancedShellParser(runner, command); + if (result) { + return result; + } + } + + const parsed = runner._parseCommand(command); + trace( + 'ProcessRunner', + () => + `Parsed command | ${JSON.stringify({ + type: parsed?.type, + cmd: parsed?.cmd, + argsCount: parsed?.args?.length, + })}` + ); + + if (parsed) { + if (parsed.type === 'pipeline') { + trace( + 'ProcessRunner', + () => + `BRANCH: parsed.type => pipeline | ${JSON.stringify({ + commandCount: parsed.commands?.length, + })}` + ); + return await runner._runPipeline(parsed.commands); + } + + const virtualResult = await tryVirtualCommand(runner, parsed, { + virtualCommands, + isVirtualCommandsEnabled, + }); + if (virtualResult) { + return virtualResult; + } + } + + return null; +} + +/** + * Execute child process and collect results + * @param {object} runner - ProcessRunner instance + * @param {Array} argv - Command arguments + * @param {object} config - Spawn configuration + * @returns {Promise} Result + */ +async function executeChildProcess(runner, argv, config) { + const { stdin, isInteractive } = config; + + runner.child = spawnChild(argv, config); + + if (runner.child) { + trace( + 'ProcessRunner', + () => + `Child process created | ${JSON.stringify({ + pid: runner.child.pid, + detached: runner.child.options?.detached, + killed: runner.child.killed, + hasStdout: !!runner.child.stdout, + hasStderr: !!runner.child.stderr, + hasStdin: !!runner.child.stdin, + platform: process.platform, + command: runner.spec?.command?.slice(0, 100), + })}` + ); + setupChildEventListeners(runner); + } + + const childPid = runner.child?.pid; + const outPump = createStdoutPump(runner, childPid); + const errPump = createStderrPump(runner, childPid); + const stdinPumpPromise = handleStdin(runner, stdin, isInteractive); + const exited = createExitPromise(runner.child); + + const code = await exited; + await Promise.all([outPump, errPump, stdinPumpPromise]); + + const finalExitCode = determineFinalExitCode(code, runner._cancelled); + const resultData = buildResultData(runner, finalExitCode); + + trace( + 'ProcessRunner', + () => + `Process completed | ${JSON.stringify({ + command: runner.command, + finalExitCode, + captured: runner.options.capture, + hasStdout: !!resultData.stdout, + hasStderr: !!resultData.stderr, + stdoutLength: resultData.stdout?.length || 0, + stderrLength: resultData.stderr?.length || 0, + stdoutPreview: resultData.stdout?.slice(0, 100), + stderrPreview: resultData.stderr?.slice(0, 100), + childPid: runner.child?.pid, + cancelled: runner._cancelled, + cancellationSignal: runner._cancellationSignal, + platform: process.platform, + runtime: isBun ? 'Bun' : 'Node.js', + })}` + ); + + return { + ...resultData, + text() { + return Promise.resolve(resultData.stdout || ''); + }, + }; +} + +/** + * Attach execution methods to ProcessRunner prototype + * @param {Function} ProcessRunner - The ProcessRunner class + * @param {Object} deps - Dependencies (virtualCommands, globalShellSettings, isVirtualCommandsEnabled) + */ +export function attachExecutionMethods(ProcessRunner, deps) { + const { globalShellSettings } = deps; + + // Unified start method + ProcessRunner.prototype.start = function (options = {}) { + const mode = options.mode || 'async'; + + trace( + 'ProcessRunner', + () => + `start ENTER | ${JSON.stringify({ + mode, + options, + started: this.started, + hasPromise: !!this.promise, + hasChild: !!this.child, + command: this.spec?.command?.slice(0, 50), + })}` + ); + + if (Object.keys(options).length > 0 && !this.started) { + trace( + 'ProcessRunner', + () => + `BRANCH: options => MERGE | ${JSON.stringify({ + oldOptions: this.options, + newOptions: options, + })}` + ); + + this.options = { ...this.options, ...options }; + setupExternalAbortSignal(this); + + if ('capture' in options) { + reinitCaptureChunks(this); + } + + trace( + 'ProcessRunner', + () => + `OPTIONS_MERGED | ${JSON.stringify({ finalOptions: this.options })}` + ); + } + + if (mode === 'sync') { + trace('ProcessRunner', () => `BRANCH: mode => sync`); + return this._startSync(); + } + + trace('ProcessRunner', () => `BRANCH: mode => async`); + return this._startAsync(); + }; + + ProcessRunner.prototype.sync = function () { + return this.start({ mode: 'sync' }); + }; + + ProcessRunner.prototype.async = function () { + return this.start({ mode: 'async' }); + }; + + ProcessRunner.prototype.run = function (options = {}) { + trace( + 'ProcessRunner', + () => `run ENTER | ${JSON.stringify({ options }, null, 2)}` + ); + return this.start(options); + }; + + ProcessRunner.prototype._startAsync = function () { + if (this.started) { + return this.promise; + } + if (this.promise) { + return this.promise; + } + + this.promise = this._doStartAsync(); + return this.promise; + }; + + ProcessRunner.prototype._doStartAsync = async function () { + trace( + 'ProcessRunner', + () => + `_doStartAsync ENTER | ${JSON.stringify({ + mode: this.spec.mode, + command: this.spec.command?.slice(0, 100), + })}` + ); + + this.started = true; + this._mode = 'async'; + + try { + const { cwd, env, stdin } = this.options; + + // Handle pipeline mode + if (this.spec.mode === 'pipeline') { + trace( + 'ProcessRunner', + () => + `BRANCH: spec.mode => pipeline | ${JSON.stringify({ + hasSource: !!this.spec.source, + hasDestination: !!this.spec.destination, + })}` + ); + return await this._runProgrammaticPipeline( + this.spec.source, + this.spec.destination + ); + } + + // Handle shell mode special cases + if (this.spec.mode === 'shell') { + const shellResult = await handleShellMode(this, deps); + if (shellResult) { + return shellResult; + } + } + + // Build command arguments + const shell = findAvailableShell(); + const argv = + this.spec.mode === 'shell' + ? [shell.cmd, ...shell.args, this.spec.command] + : [this.spec.file, ...this.spec.args]; + + trace( + 'ProcessRunner', + () => + `Constructed argv | ${JSON.stringify({ + mode: this.spec.mode, + argv, + originalCommand: this.spec.command, + })}` + ); + + // Log command if tracing enabled + const traceCmd = + this.spec.mode === 'shell' ? this.spec.command : argv.join(' '); + logShellTrace(globalShellSettings, traceCmd); + + // Detect interactive mode + const isInteractive = isInteractiveMode(stdin, this.options); + + trace( + 'ProcessRunner', + () => + `Interactive command detection | ${JSON.stringify({ + isInteractive, + stdinInherit: stdin === 'inherit', + stdinTTY: process.stdin.isTTY, + stdoutTTY: process.stdout.isTTY, + stderrTTY: process.stderr.isTTY, + interactiveOption: this.options.interactive, + })}` + ); + + // Execute child process + const result = await executeChildProcess(this, argv, { + cwd, + env, + stdin, + isInteractive, + }); + + this.finish(result); + + trace( + 'ProcessRunner', + () => + `Process finished, result set | ${JSON.stringify({ + finished: this.finished, + resultCode: this.result?.code, + })}` + ); + + throwErrexitIfNeeded(this, globalShellSettings); + + return this.result; + } catch (error) { + trace( + 'ProcessRunner', + () => + `Caught error in _doStartAsync | ${JSON.stringify({ + errorMessage: error.message, + errorCode: error.code, + isCommandError: error.isCommandError, + hasResult: !!error.result, + command: this.spec?.command?.slice(0, 100), + })}` + ); + + if (!this.finished) { + const errorResult = createResult({ + code: error.code ?? 1, + stdout: error.stdout ?? '', + stderr: error.stderr ?? error.message ?? '', + stdin: '', + }); + this.finish(errorResult); + } + + throw error; + } + }; + + ProcessRunner.prototype._pumpStdinTo = async function (child, captureChunks) { + trace('ProcessRunner', () => `_pumpStdinTo ENTER`); + if (!child.stdin) { + return; + } + + const bunWriter = + isBun && child.stdin && typeof child.stdin.getWriter === 'function' + ? child.stdin.getWriter() + : null; + + for await (const chunk of process.stdin) { + const buf = asBuffer(chunk); + captureChunks && captureChunks.push(buf); + if (bunWriter) { + await bunWriter.write(buf); + } else if (typeof child.stdin.write === 'function') { + StreamUtils.addStdinErrorHandler(child.stdin, 'child stdin buffer'); + StreamUtils.safeStreamWrite(child.stdin, buf, 'child stdin buffer'); + } else if (isBun && typeof Bun.write === 'function') { + await Bun.write(child.stdin, buf); + } + } + + if (bunWriter) { + await bunWriter.close(); + } else if (typeof child.stdin.end === 'function') { + child.stdin.end(); + } + }; + + ProcessRunner.prototype._writeToStdin = async function (buf) { + trace('ProcessRunner', () => `_writeToStdin | len=${buf?.length || 0}`); + const bytes = + buf instanceof Uint8Array + ? buf + : new Uint8Array(buf.buffer, buf.byteOffset ?? 0, buf.byteLength); + + if (await StreamUtils.writeToStream(this.child.stdin, bytes, 'stdin')) { + if (StreamUtils.isBunStream(this.child.stdin)) { + // Stream was already closed by writeToStream utility - no action needed + } else if (StreamUtils.isNodeStream(this.child.stdin)) { + try { + this.child.stdin.end(); + } catch (_endError) { + /* Expected when stream is already closed */ + } + } + } else if (isBun && typeof Bun.write === 'function') { + await Bun.write(this.child.stdin, buf); + } + }; + + ProcessRunner.prototype._forwardTTYStdin = function () { + trace('ProcessRunner', () => `_forwardTTYStdin ENTER`); + if (!process.stdin.isTTY || !this.child.stdin) { + return; + } + + try { + if (process.stdin.setRawMode) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + + const onData = (chunk) => { + if (chunk[0] === 3) { + this._sendSigintToChild(); + return; + } + if (this.child.stdin?.write) { + this.child.stdin.write(chunk); + } + }; + + const cleanup = () => { + process.stdin.removeListener('data', onData); + if (process.stdin.setRawMode) { + process.stdin.setRawMode(false); + } + process.stdin.pause(); + }; + + process.stdin.on('data', onData); + + const childExit = isBun + ? this.child.exited + : new Promise((resolve) => { + this.child.once('close', resolve); + this.child.once('exit', resolve); + }); + + childExit.then(cleanup).catch(cleanup); + + return childExit; + } catch (_error) { + // TTY forwarding error - ignore + } + }; + + ProcessRunner.prototype._sendSigintToChild = function () { + if (!this.child?.pid) { + return; + } + try { + if (isBun) { + this.child.kill('SIGINT'); + } else { + try { + process.kill(-this.child.pid, 'SIGINT'); + } catch (_e) { + process.kill(this.child.pid, 'SIGINT'); + } + } + } catch (_err) { + // Error sending SIGINT - ignore + } + }; + + ProcessRunner.prototype._parseCommand = function (command) { + const trimmed = command.trim(); + if (!trimmed) { + return null; + } + + if (trimmed.includes('|')) { + return this._parsePipeline(trimmed); + } + + const parts = trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; + if (parts.length === 0) { + return null; + } + + const cmd = parts[0]; + const args = parts.slice(1).map((arg) => { + if ( + (arg.startsWith('"') && arg.endsWith('"')) || + (arg.startsWith("'") && arg.endsWith("'")) + ) { + return { value: arg.slice(1, -1), quoted: true, quoteChar: arg[0] }; + } + return { value: arg, quoted: false }; + }); + + return { cmd, args, type: 'simple' }; + }; + + ProcessRunner.prototype._parsePipeline = function (command) { + const segments = []; + let current = ''; + let inQuotes = false; + let quoteChar = ''; + + for (let i = 0; i < command.length; i++) { + const char = command[i]; + + if (!inQuotes && (char === '"' || char === "'")) { + inQuotes = true; + quoteChar = char; + current += char; + } else if (inQuotes && char === quoteChar) { + inQuotes = false; + quoteChar = ''; + current += char; + } else if (!inQuotes && char === '|') { + segments.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + if (current.trim()) { + segments.push(current.trim()); + } + + const commands = segments + .map((segment) => { + const parts = segment.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || []; + if (parts.length === 0) { + return null; + } + + const cmd = parts[0]; + const args = parts.slice(1).map((arg) => { + if ( + (arg.startsWith('"') && arg.endsWith('"')) || + (arg.startsWith("'") && arg.endsWith("'")) + ) { + return { value: arg.slice(1, -1), quoted: true, quoteChar: arg[0] }; + } + return { value: arg, quoted: false }; + }); + + return { cmd, args }; + }) + .filter(Boolean); + + return { type: 'pipeline', commands }; + }; + + // Sync execution + ProcessRunner.prototype._startSync = function () { + trace('ProcessRunner', () => `_startSync ENTER`); + + if (this.started) { + throw new Error( + 'Command already started - cannot run sync after async start' + ); + } + + this.started = true; + this._mode = 'sync'; + + const { cwd, env, stdin } = this.options; + const shell = findAvailableShell(); + const argv = + this.spec.mode === 'shell' + ? [shell.cmd, ...shell.args, this.spec.command] + : [this.spec.file, ...this.spec.args]; + + const traceCmd = + this.spec.mode === 'shell' ? this.spec.command : argv.join(' '); + logShellTrace(globalShellSettings, traceCmd); + + const result = executeSyncProcess(argv, { cwd, env, stdin }); + return processSyncResult(this, result, globalShellSettings); + }; + + // Promise interface + ProcessRunner.prototype.then = function (onFulfilled, onRejected) { + if (!this.promise) { + this.promise = this._startAsync(); + } + return this.promise.then(onFulfilled, onRejected); + }; + + ProcessRunner.prototype.catch = function (onRejected) { + if (!this.promise) { + this.promise = this._startAsync(); + } + return this.promise.catch(onRejected); + }; + + ProcessRunner.prototype.finally = function (onFinally) { + if (!this.promise) { + this.promise = this._startAsync(); + } + return this.promise.finally(() => { + if (!this.finished) { + this.finish( + createResult({ + code: 1, + stdout: '', + stderr: 'Process terminated unexpectedly', + stdin: '', + }) + ); + } + if (onFinally) { + onFinally(); + } + }); + }; +} diff --git a/js/src/$.process-runner-orchestration.mjs b/js/src/$.process-runner-orchestration.mjs new file mode 100644 index 0000000..df871ca --- /dev/null +++ b/js/src/$.process-runner-orchestration.mjs @@ -0,0 +1,250 @@ +// ProcessRunner orchestration methods - sequence, subshell, simple command, and pipe +// Part of the modular ProcessRunner architecture + +import { trace } from './$.trace.mjs'; + +/** + * Execute a command based on its type + * @param {object} runner - The ProcessRunner instance + * @param {object} command - Command to execute + * @returns {Promise} Command result + */ +function executeCommand(runner, command) { + if (command.type === 'subshell') { + return runner._runSubshell(command); + } else if (command.type === 'pipeline') { + return runner._runPipeline(command.commands); + } else if (command.type === 'sequence') { + return runner._runSequence(command); + } else if (command.type === 'simple') { + return runner._runSimpleCommand(command); + } + return Promise.resolve({ code: 0, stdout: '', stderr: '' }); +} + +/** + * Restore working directory after subshell execution + * @param {string} savedCwd - Directory to restore + */ +async function restoreCwd(savedCwd) { + trace( + 'ProcessRunner', + () => `Restoring cwd from ${process.cwd()} to ${savedCwd}` + ); + const fs = await import('fs'); + if (fs.existsSync(savedCwd)) { + process.chdir(savedCwd); + } else { + const fallbackDir = process.env.HOME || process.env.USERPROFILE || '/'; + trace( + 'ProcessRunner', + () => + `Saved directory ${savedCwd} no longer exists, falling back to ${fallbackDir}` + ); + try { + process.chdir(fallbackDir); + } catch (e) { + trace('ProcessRunner', () => `Failed to restore directory: ${e.message}`); + } + } +} + +/** + * Handle file redirections for virtual command output + * @param {object} result - Command result + * @param {Array} redirects - Redirect specifications + */ +async function handleRedirects(result, redirects) { + if (!redirects || redirects.length === 0) { + return; + } + for (const redirect of redirects) { + if (redirect.type === '>' || redirect.type === '>>') { + const fs = await import('fs'); + if (redirect.type === '>') { + fs.writeFileSync(redirect.target, result.stdout); + } else { + fs.appendFileSync(redirect.target, result.stdout); + } + result.stdout = ''; + } + } +} + +/** + * Build command string from parsed command parts + * @param {string} cmd - Command name + * @param {Array} args - Command arguments + * @param {Array} redirects - Redirect specifications + * @returns {string} Assembled command string + */ +function buildCommandString(cmd, args, redirects) { + let commandStr = cmd; + for (const arg of args) { + if (arg.quoted && arg.quoteChar) { + commandStr += ` ${arg.quoteChar}${arg.value}${arg.quoteChar}`; + } else if (arg.value !== undefined) { + commandStr += ` ${arg.value}`; + } else { + commandStr += ` ${arg}`; + } + } + if (redirects) { + for (const redirect of redirects) { + commandStr += ` ${redirect.type} ${redirect.target}`; + } + } + return commandStr; +} + +/** + * Attach orchestration methods to ProcessRunner prototype + * @param {Function} ProcessRunner - The ProcessRunner class + * @param {Object} deps - Dependencies + */ +export function attachOrchestrationMethods(ProcessRunner, deps) { + const { virtualCommands, isVirtualCommandsEnabled } = deps; + + ProcessRunner.prototype._runSequence = async function (sequence) { + trace( + 'ProcessRunner', + () => + `_runSequence ENTER | ${JSON.stringify({ commandCount: sequence.commands.length, operators: sequence.operators }, null, 2)}` + ); + + let lastResult = { code: 0, stdout: '', stderr: '' }; + let combinedStdout = ''; + let combinedStderr = ''; + + for (let i = 0; i < sequence.commands.length; i++) { + const command = sequence.commands[i]; + const operator = i > 0 ? sequence.operators[i - 1] : null; + + trace( + 'ProcessRunner', + () => + `Executing command ${i} | ${JSON.stringify({ command: command.type, operator, lastCode: lastResult.code }, null, 2)}` + ); + + if (operator === '&&' && lastResult.code !== 0) { + trace( + 'ProcessRunner', + () => `Skipping due to && with exit code ${lastResult.code}` + ); + continue; + } + if (operator === '||' && lastResult.code === 0) { + trace( + 'ProcessRunner', + () => `Skipping due to || with exit code ${lastResult.code}` + ); + continue; + } + + lastResult = await executeCommand(this, command); + combinedStdout += lastResult.stdout; + combinedStderr += lastResult.stderr; + } + + return { + code: lastResult.code, + stdout: combinedStdout, + stderr: combinedStderr, + text() { + return Promise.resolve(combinedStdout); + }, + }; + }; + + ProcessRunner.prototype._runSubshell = async function (subshell) { + trace( + 'ProcessRunner', + () => + `_runSubshell ENTER | ${JSON.stringify({ commandType: subshell.command.type }, null, 2)}` + ); + const savedCwd = process.cwd(); + try { + return await executeCommand(this, subshell.command); + } finally { + await restoreCwd(savedCwd); + } + }; + + ProcessRunner.prototype._runSimpleCommand = async function (command) { + trace( + 'ProcessRunner', + () => + `_runSimpleCommand ENTER | ${JSON.stringify({ cmd: command.cmd, argsCount: command.args?.length || 0, hasRedirects: !!command.redirects }, null, 2)}` + ); + + const { cmd, args, redirects } = command; + + if (isVirtualCommandsEnabled() && virtualCommands.has(cmd)) { + trace('ProcessRunner', () => `Using virtual command: ${cmd}`); + const argValues = args.map((a) => a.value || a); + const result = await this._runVirtual(cmd, argValues); + await handleRedirects(result, redirects); + return result; + } + + const commandStr = buildCommandString(cmd, args, redirects); + trace('ProcessRunner', () => `Executing real command: ${commandStr}`); + + const ProcessRunnerRef = this.constructor; + const runner = new ProcessRunnerRef( + { mode: 'shell', command: commandStr }, + { ...this.options, cwd: process.cwd(), _bypassVirtual: true } + ); + + return await runner; + }; + + ProcessRunner.prototype.pipe = function (destination) { + trace( + 'ProcessRunner', + () => + `pipe ENTER | ${JSON.stringify({ hasDestination: !!destination, destinationType: destination?.constructor?.name }, null, 2)}` + ); + + const ProcessRunnerRef = this.constructor; + + if (destination instanceof ProcessRunnerRef) { + trace( + 'ProcessRunner', + () => + `BRANCH: pipe => PROCESS_RUNNER_DEST | ${JSON.stringify({}, null, 2)}` + ); + const pipeSpec = { mode: 'pipeline', source: this, destination }; + const pipeRunner = new ProcessRunnerRef(pipeSpec, { + ...this.options, + capture: destination.options.capture ?? true, + }); + trace( + 'ProcessRunner', + () => `pipe EXIT | ${JSON.stringify({ mode: 'pipeline' }, null, 2)}` + ); + return pipeRunner; + } + + if (destination && destination.spec) { + trace( + 'ProcessRunner', + () => + `BRANCH: pipe => TEMPLATE_LITERAL_DEST | ${JSON.stringify({}, null, 2)}` + ); + const destRunner = new ProcessRunnerRef( + destination.spec, + destination.options + ); + return this.pipe(destRunner); + } + + trace( + 'ProcessRunner', + () => `BRANCH: pipe => INVALID_DEST | ${JSON.stringify({}, null, 2)}` + ); + throw new Error( + 'pipe() destination must be a ProcessRunner or $`command` result' + ); + }; +} diff --git a/js/src/$.process-runner-pipeline.mjs b/js/src/$.process-runner-pipeline.mjs new file mode 100644 index 0000000..8b8cbe2 --- /dev/null +++ b/js/src/$.process-runner-pipeline.mjs @@ -0,0 +1,1162 @@ +// ProcessRunner pipeline methods - all pipeline execution strategies +// Part of the modular ProcessRunner architecture + +import cp from 'child_process'; +import { trace } from './$.trace.mjs'; +import { findAvailableShell } from './$.shell.mjs'; +import { StreamUtils, safeWrite } from './$.stream-utils.mjs'; +import { createResult } from './$.result.mjs'; + +const isBun = typeof globalThis.Bun !== 'undefined'; + +/** + * Commands that need streaming workaround + */ +const STREAMING_COMMANDS = ['jq', 'grep', 'sed', 'cat', 'awk']; + +/** + * Check if command needs streaming workaround + * @param {object} command - Command object + * @returns {boolean} + */ +function needsStreamingWorkaround(command) { + return STREAMING_COMMANDS.includes(command.cmd); +} + +/** + * Analyze pipeline for virtual commands + * @param {Array} commands - Pipeline commands + * @param {Function} isVirtualCommandsEnabled - Check if virtual commands enabled + * @param {Map} virtualCommands - Virtual commands registry + * @returns {object} Analysis result + */ +function analyzePipeline(commands, isVirtualCommandsEnabled, virtualCommands) { + const pipelineInfo = commands.map((command) => ({ + ...command, + isVirtual: isVirtualCommandsEnabled() && virtualCommands.has(command.cmd), + })); + return { + pipelineInfo, + hasVirtual: pipelineInfo.some((info) => info.isVirtual), + virtualCount: pipelineInfo.filter((p) => p.isVirtual).length, + realCount: pipelineInfo.filter((p) => !p.isVirtual).length, + }; +} + +/** + * Read stream to string + * @param {ReadableStream} stream - Stream to read + * @returns {Promise} + */ +async function readStreamToString(stream) { + const reader = stream.getReader(); + let result = ''; + try { + let done = false; + while (!done) { + const readResult = await reader.read(); + done = readResult.done; + if (!done && readResult.value) { + result += new TextDecoder().decode(readResult.value); + } + } + } finally { + reader.releaseLock(); + } + return result; +} + +/** + * Build command parts from command object + * @param {object} command - Command with cmd and args + * @returns {string[]} Command parts array + */ +function buildCommandParts(command) { + const { cmd, args } = command; + const parts = [cmd]; + for (const arg of args) { + if (arg.value !== undefined) { + if (arg.quoted) { + parts.push(`${arg.quoteChar}${arg.value}${arg.quoteChar}`); + } else if (arg.value.includes(' ')) { + parts.push(`"${arg.value}"`); + } else { + parts.push(arg.value); + } + } else if ( + typeof arg === 'string' && + arg.includes(' ') && + !arg.startsWith('"') && + !arg.startsWith("'") + ) { + parts.push(`"${arg}"`); + } else { + parts.push(arg); + } + } + return parts; +} + +/** + * Check if command string needs shell execution + * @param {string} commandStr - Command string + * @returns {boolean} + */ +function needsShellExecution(commandStr) { + return ( + commandStr.includes('*') || + commandStr.includes('$') || + commandStr.includes('>') || + commandStr.includes('<') || + commandStr.includes('&&') || + commandStr.includes('||') || + commandStr.includes(';') || + commandStr.includes('`') + ); +} + +/** + * Get spawn args based on shell need + * @param {boolean} needsShell - Whether shell is needed + * @param {string} cmd - Command name + * @param {Array} args - Command args + * @param {string} commandStr - Full command string + * @returns {string[]} Spawn arguments + */ +function getSpawnArgs(needsShell, cmd, args, commandStr) { + if (needsShell) { + const shell = findAvailableShell(); + return [shell.cmd, ...shell.args.filter((arg) => arg !== '-l'), commandStr]; + } + return [cmd, ...args.map((a) => (a.value !== undefined ? a.value : a))]; +} + +/** + * Determine stdin configuration for first command + * @param {object} options - Runner options + * @returns {object} Stdin config with stdin, needsManualStdin, stdinData + */ +function getFirstCommandStdin(options) { + if (options.stdin && typeof options.stdin === 'string') { + return { + stdin: 'pipe', + needsManualStdin: true, + stdinData: Buffer.from(options.stdin), + }; + } + if (options.stdin && Buffer.isBuffer(options.stdin)) { + return { stdin: 'pipe', needsManualStdin: true, stdinData: options.stdin }; + } + return { stdin: 'ignore', needsManualStdin: false, stdinData: null }; +} + +/** + * Get stdin string from options + * @param {object} options - Runner options + * @returns {string} + */ +function getStdinString(options) { + if (options.stdin && typeof options.stdin === 'string') { + return options.stdin; + } + if (options.stdin && Buffer.isBuffer(options.stdin)) { + return options.stdin.toString('utf8'); + } + return ''; +} + +/** + * Handle pipefail check + * @param {number[]} exitCodes - Exit codes from pipeline + * @param {object} shellSettings - Shell settings + */ +function checkPipefail(exitCodes, shellSettings) { + if (shellSettings.pipefail) { + const failedIndex = exitCodes.findIndex((code) => code !== 0); + if (failedIndex !== -1) { + const error = new Error( + `Pipeline command at index ${failedIndex} failed with exit code ${exitCodes[failedIndex]}` + ); + error.code = exitCodes[failedIndex]; + throw error; + } + } +} + +/** + * Create and throw errexit error + * @param {object} result - Result object + * @param {object} shellSettings - Shell settings + */ +function throwErrexitError(result, shellSettings) { + if (shellSettings.errexit && result.code !== 0) { + const error = new Error(`Pipeline failed with exit code ${result.code}`); + error.code = result.code; + error.stdout = result.stdout; + error.stderr = result.stderr; + error.result = result; + throw error; + } +} + +/** + * Write stdin to Bun process + * @param {object} proc - Process with stdin + * @param {Buffer} stdinData - Data to write + */ +async function writeBunStdin(proc, stdinData) { + if (!proc.stdin) { + return; + } + const stdinHandler = StreamUtils.setupStdinHandling( + proc.stdin, + 'Bun process stdin' + ); + try { + if (stdinHandler.isWritable()) { + await proc.stdin.write(stdinData); + await proc.stdin.end(); + } + } catch (e) { + if (e.code !== 'EPIPE') { + trace('ProcessRunner', () => `stdin write error | ${e.message}`); + } + } +} + +/** + * Collect stderr from process async + * @param {object} runner - ProcessRunner + * @param {object} proc - Process + * @param {boolean} isLast - Is last command + * @param {object} collector - Object to collect stderr + */ +function collectStderrAsync(runner, proc, isLast, collector) { + (async () => { + for await (const chunk of proc.stderr) { + const buf = Buffer.from(chunk); + collector.stderr += buf.toString(); + if (isLast) { + if (runner.options.mirror) { + safeWrite(process.stderr, buf); + } + runner._emitProcessedData('stderr', buf); + } + } + })(); +} + +/** + * Create initial input stream from stdin option + * @param {object} options - Runner options + * @returns {ReadableStream|null} + */ +function createInitialInputStream(options) { + if (!options.stdin) { + return null; + } + const inputData = + typeof options.stdin === 'string' + ? options.stdin + : options.stdin.toString('utf8'); + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(inputData)); + controller.close(); + }, + }); +} + +/** + * Get argument values from args array + * @param {Array} args - Args array + * @returns {Array} Argument values + */ +function getArgValues(args) { + return args.map((arg) => (arg.value !== undefined ? arg.value : arg)); +} + +/** + * Create readable stream from string + * @param {string} data - String data + * @returns {ReadableStream} + */ +function createStringStream(data) { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(data)); + controller.close(); + }, + }); +} + +/** + * Pipe stream to process stdin + * @param {ReadableStream} stream - Input stream + * @param {object} proc - Process + */ +function pipeStreamToProcess(stream, proc) { + if (!stream || !proc.stdin) { + return; + } + const reader = stream.getReader(); + const writer = proc.stdin.getWriter ? proc.stdin.getWriter() : proc.stdin; + + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (writer.write) { + try { + await writer.write(value); + } catch (error) { + StreamUtils.handleStreamError(error, 'stream writer', false); + break; + } + } else if (writer.getWriter) { + try { + const w = writer.getWriter(); + await w.write(value); + w.releaseLock(); + } catch (error) { + StreamUtils.handleStreamError( + error, + 'stream writer (getWriter)', + false + ); + break; + } + } + } + } finally { + reader.releaseLock(); + if (writer.close) { + await writer.close(); + } else if (writer.end) { + writer.end(); + } + } + })(); +} + +/** + * Spawn shell command in Bun + * @param {string} commandStr - Command string + * @param {object} options - Options (cwd, env, stdin) + * @returns {object} Process + */ +function spawnShellCommand(commandStr, options) { + const shell = findAvailableShell(); + return Bun.spawn( + [shell.cmd, ...shell.args.filter((arg) => arg !== '-l'), commandStr], + { + cwd: options.cwd, + env: options.env, + stdin: options.stdin, + stdout: 'pipe', + stderr: 'pipe', + } + ); +} + +/** + * Collect last command stdout + * @param {object} runner - ProcessRunner + * @param {object} proc - Process + * @returns {Promise} Output string + */ +async function collectFinalStdout(runner, proc) { + const chunks = []; + for await (const chunk of proc.stdout) { + const buf = Buffer.from(chunk); + chunks.push(buf); + if (runner.options.mirror) { + safeWrite(process.stdout, buf); + } + runner._emitProcessedData('stdout', buf); + } + return Buffer.concat(chunks).toString('utf8'); +} + +/** + * Spawn async node process for pipeline + * @param {object} runner - ProcessRunner instance + * @param {string[]} argv - Command arguments + * @param {string} stdin - Stdin input + * @param {boolean} isLastCommand - Is this the last command + * @returns {Promise} Result with status, stdout, stderr + */ +function spawnNodeAsync(runner, argv, stdin, isLastCommand) { + return new Promise((resolve, reject) => { + const proc = cp.spawn(argv[0], argv.slice(1), { + cwd: runner.options.cwd, + env: runner.options.env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + proc.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + if (isLastCommand) { + if (runner.options.mirror) { + safeWrite(process.stdout, chunk); + } + runner._emitProcessedData('stdout', chunk); + } + }); + + proc.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + if (isLastCommand) { + if (runner.options.mirror) { + safeWrite(process.stderr, chunk); + } + runner._emitProcessedData('stderr', chunk); + } + }); + + proc.on('close', (code) => resolve({ status: code, stdout, stderr })); + proc.on('error', reject); + + if (proc.stdin) { + StreamUtils.addStdinErrorHandler( + proc.stdin, + 'spawnNodeAsync stdin', + reject + ); + } + if (stdin) { + StreamUtils.safeStreamWrite(proc.stdin, stdin, 'spawnNodeAsync stdin'); + } + StreamUtils.safeStreamEnd(proc.stdin, 'spawnNodeAsync stdin'); + }); +} + +/** + * Log shell trace/verbose + * @param {object} settings - Shell settings + * @param {string} cmd - Command + * @param {string[]} argValues - Argument values + */ +function logShellTrace(settings, cmd, argValues) { + const cmdStr = `${cmd} ${argValues.join(' ')}`; + if (settings.xtrace) { + console.log(`+ ${cmdStr}`); + } + if (settings.verbose) { + console.log(cmdStr); + } +} + +/** + * Handle virtual command in non-streaming pipeline + * @param {object} runner - ProcessRunner instance + * @param {Function} handler - Handler function + * @param {string[]} argValues - Argument values + * @param {string} currentInput - Current input + * @param {object} options - Runner options + * @returns {Promise} Result + */ +async function runVirtualHandler( + runner, + handler, + argValues, + currentInput, + options +) { + if (handler.constructor.name === 'AsyncGeneratorFunction') { + const chunks = []; + for await (const chunk of handler({ + args: argValues, + stdin: currentInput, + ...options, + })) { + chunks.push(Buffer.from(chunk)); + } + return { + code: 0, + stdout: options.capture + ? Buffer.concat(chunks).toString('utf8') + : undefined, + stderr: options.capture ? '' : undefined, + stdin: options.capture ? currentInput : undefined, + }; + } + const result = await handler({ + args: argValues, + stdin: currentInput, + ...options, + }); + return { + ...result, + code: result.code ?? 0, + stdout: options.capture ? (result.stdout ?? '') : undefined, + stderr: options.capture ? (result.stderr ?? '') : undefined, + stdin: options.capture ? currentInput : undefined, + }; +} + +/** + * Emit final result output + * @param {object} runner - ProcessRunner instance + * @param {object} result - Result object + */ +function emitFinalOutput(runner, result) { + if (result.stdout) { + const buf = Buffer.from(result.stdout); + if (runner.options.mirror) { + safeWrite(process.stdout, buf); + } + runner._emitProcessedData('stdout', buf); + } + if (result.stderr) { + const buf = Buffer.from(result.stderr); + if (runner.options.mirror) { + safeWrite(process.stderr, buf); + } + runner._emitProcessedData('stderr', buf); + } +} + +/** + * Create final result for pipeline + * @param {object} runner - ProcessRunner instance + * @param {object} result - Current result + * @param {string} currentOutput - Current output + * @param {object} shellSettings - Shell settings + * @returns {object} Final result + */ +function createFinalPipelineResult( + runner, + result, + currentOutput, + shellSettings +) { + const finalResult = createResult({ + code: result.code, + stdout: currentOutput, + stderr: result.stderr, + stdin: getStdinString(runner.options), + }); + runner.finish(finalResult); + throwErrexitError(finalResult, shellSettings); + return finalResult; +} + +/** + * Handle pipeline error + * @param {object} runner - ProcessRunner instance + * @param {Error} error - Error + * @param {string} currentOutput - Current output + * @param {object} shellSettings - Shell settings + * @returns {object} Error result + */ +function handlePipelineError(runner, error, currentOutput, shellSettings) { + const result = createResult({ + code: error.code ?? 1, + stdout: currentOutput, + stderr: error.stderr ?? error.message, + stdin: getStdinString(runner.options), + }); + if (result.stderr) { + const buf = Buffer.from(result.stderr); + if (runner.options.mirror) { + safeWrite(process.stderr, buf); + } + runner._emitProcessedData('stderr', buf); + } + runner.finish(result); + if (shellSettings.errexit) { + throw error; + } + return result; +} + +/** + * Handle virtual command in non-streaming pipeline iteration + * @param {object} runner - ProcessRunner instance + * @param {object} command - Command object + * @param {string} currentInput - Current input + * @param {boolean} isLastCommand - Is last command + * @param {object} deps - Dependencies + * @returns {Promise} { output, input, finalResult } + */ +async function handleVirtualPipelineCommand( + runner, + command, + currentInput, + isLastCommand, + deps +) { + const { virtualCommands, globalShellSettings } = deps; + const handler = virtualCommands.get(command.cmd); + const argValues = getArgValues(command.args); + logShellTrace(globalShellSettings, command.cmd, argValues); + + const result = await runVirtualHandler( + runner, + handler, + argValues, + currentInput, + runner.options + ); + + if (isLastCommand) { + emitFinalOutput(runner, result); + return { + finalResult: createFinalPipelineResult( + runner, + result, + result.stdout, + globalShellSettings + ), + }; + } + + if (globalShellSettings.errexit && result.code !== 0) { + const error = new Error( + `Pipeline command failed with exit code ${result.code}` + ); + error.code = result.code; + error.result = result; + throw error; + } + + return { input: result.stdout }; +} + +/** + * Handle shell command in non-streaming pipeline iteration + * @param {object} runner - ProcessRunner instance + * @param {object} command - Command object + * @param {string} currentInput - Current input + * @param {boolean} isLastCommand - Is last command + * @param {object} deps - Dependencies + * @returns {Promise} { output, input, finalResult } + */ +async function handleShellPipelineCommand( + runner, + command, + currentInput, + isLastCommand, + deps +) { + const { globalShellSettings } = deps; + const commandStr = buildCommandParts(command).join(' '); + logShellTrace(globalShellSettings, commandStr, []); + + const shell = findAvailableShell(); + const argv = [shell.cmd, ...shell.args.filter((a) => a !== '-l'), commandStr]; + const proc = await spawnNodeAsync(runner, argv, currentInput, isLastCommand); + const result = { + code: proc.status || 0, + stdout: proc.stdout || '', + stderr: proc.stderr || '', + }; + + if (globalShellSettings.pipefail && result.code !== 0) { + const error = new Error( + `Pipeline command '${commandStr}' failed with exit code ${result.code}` + ); + error.code = result.code; + throw error; + } + + if (isLastCommand) { + let allStderr = ''; + if (runner.errChunks?.length > 0) { + allStderr = Buffer.concat(runner.errChunks).toString('utf8'); + } + if (result.stderr) { + allStderr += result.stderr; + } + const finalResult = createResult({ + code: result.code, + stdout: result.stdout, + stderr: allStderr, + stdin: getStdinString(runner.options), + }); + runner.finish(finalResult); + throwErrexitError(finalResult, globalShellSettings); + return { finalResult }; + } + + if (result.stderr && runner.options.capture) { + runner.errChunks = runner.errChunks || []; + runner.errChunks.push(Buffer.from(result.stderr)); + } + + return { input: result.stdout }; +} + +/** + * Attach pipeline methods to ProcessRunner prototype + * @param {Function} ProcessRunner - The ProcessRunner class + * @param {Object} deps - Dependencies + */ +export function attachPipelineMethods(ProcessRunner, deps) { + const { virtualCommands, globalShellSettings, isVirtualCommandsEnabled } = + deps; + + // Use module-level helper + ProcessRunner.prototype._readStreamToString = readStreamToString; + + ProcessRunner.prototype._runStreamingPipelineBun = async function (commands) { + trace( + 'ProcessRunner', + () => `_runStreamingPipelineBun | cmds=${commands.length}` + ); + + const analysis = analyzePipeline( + commands, + isVirtualCommandsEnabled, + virtualCommands + ); + if (analysis.hasVirtual) { + return this._runMixedStreamingPipeline(commands); + } + if (commands.some(needsStreamingWorkaround)) { + return this._runTeeStreamingPipeline(commands); + } + + const processes = []; + const collector = { stderr: '' }; + + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const commandStr = buildCommandParts(command).join(' '); + const needsShell = needsShellExecution(commandStr); + const spawnArgs = getSpawnArgs( + needsShell, + command.cmd, + command.args, + commandStr + ); + + let stdin; + let stdinConfig = null; + if (i === 0) { + stdinConfig = getFirstCommandStdin(this.options); + stdin = stdinConfig.stdin; + } else { + stdin = processes[i - 1].stdout; + } + + const proc = Bun.spawn(spawnArgs, { + cwd: this.options.cwd, + env: this.options.env, + stdin, + stdout: 'pipe', + stderr: 'pipe', + }); + + if (stdinConfig?.needsManualStdin && stdinConfig.stdinData) { + writeBunStdin(proc, stdinConfig.stdinData); + } + + processes.push(proc); + collectStderrAsync(this, proc, i === commands.length - 1, collector); + } + + const lastProc = processes[processes.length - 1]; + let finalOutput = ''; + + for await (const chunk of lastProc.stdout) { + const buf = Buffer.from(chunk); + finalOutput += buf.toString(); + if (this.options.mirror) { + safeWrite(process.stdout, buf); + } + this._emitProcessedData('stdout', buf); + } + + const exitCodes = await Promise.all(processes.map((p) => p.exited)); + checkPipefail(exitCodes, globalShellSettings); + + const result = createResult({ + code: exitCodes[exitCodes.length - 1] || 0, + stdout: finalOutput, + stderr: collector.stderr, + stdin: getStdinString(this.options), + }); + + this.finish(result); + throwErrexitError(result, globalShellSettings); + return result; + }; + + ProcessRunner.prototype._runTeeStreamingPipeline = async function (commands) { + trace( + 'ProcessRunner', + () => `_runTeeStreamingPipeline | cmds=${commands.length}` + ); + + const processes = []; + const collector = { stderr: '' }; + let currentStream = null; + + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const commandStr = buildCommandParts(command).join(' '); + const needsShell = needsShellExecution(commandStr); + const spawnArgs = getSpawnArgs( + needsShell, + command.cmd, + command.args, + commandStr + ); + + let stdin; + let stdinConfig = null; + if (i === 0) { + stdinConfig = getFirstCommandStdin(this.options); + stdin = stdinConfig.stdin; + } else { + stdin = currentStream; + } + + const proc = Bun.spawn(spawnArgs, { + cwd: this.options.cwd, + env: this.options.env, + stdin, + stdout: 'pipe', + stderr: 'pipe', + }); + + if ( + stdinConfig?.needsManualStdin && + stdinConfig.stdinData && + proc.stdin + ) { + await writeBunStdin(proc, stdinConfig.stdinData); + } + + processes.push(proc); + + if (i < commands.length - 1) { + const [readStream, pipeStream] = proc.stdout.tee(); + currentStream = pipeStream; + (async () => { + for await (const _chunk of readStream) { + /* consume */ + } + })(); + } else { + currentStream = proc.stdout; + } + + collectStderrAsync(this, proc, i === commands.length - 1, collector); + } + + const lastProc = processes[processes.length - 1]; + let finalOutput = ''; + + for await (const chunk of lastProc.stdout) { + const buf = Buffer.from(chunk); + finalOutput += buf.toString(); + if (this.options.mirror) { + safeWrite(process.stdout, buf); + } + this._emitProcessedData('stdout', buf); + } + + const exitCodes = await Promise.all(processes.map((p) => p.exited)); + checkPipefail(exitCodes, globalShellSettings); + + const result = createResult({ + code: exitCodes[exitCodes.length - 1] || 0, + stdout: finalOutput, + stderr: collector.stderr, + stdin: getStdinString(this.options), + }); + + this.finish(result); + throwErrexitError(result, globalShellSettings); + return result; + }; + + ProcessRunner.prototype._runMixedStreamingPipeline = async function ( + commands + ) { + trace( + 'ProcessRunner', + () => `_runMixedStreamingPipeline | cmds=${commands.length}` + ); + + let currentInputStream = createInitialInputStream(this.options); + let finalOutput = ''; + const collector = { stderr: '' }; + + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const { cmd, args } = command; + const isLastCommand = i === commands.length - 1; + + if (isVirtualCommandsEnabled() && virtualCommands.has(cmd)) { + const handler = virtualCommands.get(cmd); + const argValues = getArgValues(args); + const inputData = currentInputStream + ? await this._readStreamToString(currentInputStream) + : ''; + + if (handler.constructor.name === 'AsyncGeneratorFunction') { + const chunks = []; + const self = this; + currentInputStream = new ReadableStream({ + async start(controller) { + const { stdin: _, ...opts } = self.options; + for await (const chunk of handler({ + args: argValues, + stdin: inputData, + ...opts, + })) { + const data = Buffer.from(chunk); + controller.enqueue(data); + if (isLastCommand) { + chunks.push(data); + if (self.options.mirror) { + safeWrite(process.stdout, data); + } + self.emit('stdout', data); + self.emit('data', { type: 'stdout', data }); + } + } + controller.close(); + if (isLastCommand) { + finalOutput = Buffer.concat(chunks).toString('utf8'); + } + }, + }); + } else { + const { stdin: _, ...opts } = this.options; + const result = await handler({ + args: argValues, + stdin: inputData, + ...opts, + }); + const outputData = result.stdout || ''; + if (isLastCommand) { + finalOutput = outputData; + const buf = Buffer.from(outputData); + if (this.options.mirror) { + safeWrite(process.stdout, buf); + } + this._emitProcessedData('stdout', buf); + } + currentInputStream = createStringStream(outputData); + if (result.stderr) { + collector.stderr += result.stderr; + } + } + } else { + const commandStr = buildCommandParts(command).join(' '); + const proc = spawnShellCommand(commandStr, { + cwd: this.options.cwd, + env: this.options.env, + stdin: currentInputStream ? 'pipe' : 'ignore', + }); + + pipeStreamToProcess(currentInputStream, proc); + currentInputStream = proc.stdout; + collectStderrAsync(this, proc, isLastCommand, collector); + + if (isLastCommand) { + finalOutput = await collectFinalStdout(this, proc); + await proc.exited; + } + } + } + + const result = createResult({ + code: 0, + stdout: finalOutput, + stderr: collector.stderr, + stdin: getStdinString(this.options), + }); + + this.finish(result); + return result; + }; + + ProcessRunner.prototype._runPipelineNonStreaming = async function (commands) { + trace( + 'ProcessRunner', + () => `_runPipelineNonStreaming | cmds=${commands.length}` + ); + + const currentOutput = ''; + let currentInput = getStdinString(this.options); + const pipelineDeps = { virtualCommands, globalShellSettings }; + + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const isLastCommand = i === commands.length - 1; + const isVirtual = + isVirtualCommandsEnabled() && virtualCommands.has(command.cmd); + + try { + const handleResult = isVirtual + ? await handleVirtualPipelineCommand( + this, + command, + currentInput, + isLastCommand, + pipelineDeps + ) + : await handleShellPipelineCommand( + this, + command, + currentInput, + isLastCommand, + pipelineDeps + ); + + if (handleResult.finalResult) { + return handleResult.finalResult; + } + currentInput = handleResult.input; + } catch (error) { + return handlePipelineError( + this, + error, + currentOutput, + globalShellSettings + ); + } + } + }; + + ProcessRunner.prototype._runPipeline = function (commands) { + trace( + 'ProcessRunner', + () => + `_runPipeline ENTER | ${JSON.stringify( + { + commandsCount: commands.length, + }, + null, + 2 + )}` + ); + + if (commands.length === 0) { + trace( + 'ProcessRunner', + () => + `BRANCH: _runPipeline => NO_COMMANDS | ${JSON.stringify({}, null, 2)}` + ); + return createResult({ + code: 1, + stdout: '', + stderr: 'No commands in pipeline', + stdin: '', + }); + } + + if (isBun) { + trace( + 'ProcessRunner', + () => + `BRANCH: _runPipeline => BUN_STREAMING | ${JSON.stringify({}, null, 2)}` + ); + return this._runStreamingPipelineBun(commands); + } + + trace( + 'ProcessRunner', + () => + `BRANCH: _runPipeline => NODE_NON_STREAMING | ${JSON.stringify({}, null, 2)}` + ); + return this._runPipelineNonStreaming(commands); + }; + + ProcessRunner.prototype._runProgrammaticPipeline = async function ( + source, + destination + ) { + trace( + 'ProcessRunner', + () => `_runProgrammaticPipeline ENTER | ${JSON.stringify({}, null, 2)}` + ); + + try { + trace('ProcessRunner', () => 'Executing source command'); + const sourceResult = await source; + + if (sourceResult.code !== 0) { + trace( + 'ProcessRunner', + () => + `BRANCH: _runProgrammaticPipeline => SOURCE_FAILED | ${JSON.stringify( + { + code: sourceResult.code, + stderr: sourceResult.stderr, + }, + null, + 2 + )}` + ); + return sourceResult; + } + + const ProcessRunnerRef = this.constructor; + const destWithStdin = new ProcessRunnerRef(destination.spec, { + ...destination.options, + stdin: sourceResult.stdout, + }); + + const destResult = await destWithStdin; + + trace( + 'ProcessRunner', + () => + `destResult debug | ${JSON.stringify( + { + code: destResult.code, + codeType: typeof destResult.code, + hasCode: 'code' in destResult, + keys: Object.keys(destResult), + resultType: typeof destResult, + fullResult: JSON.stringify(destResult, null, 2).slice(0, 200), + }, + null, + 2 + )}` + ); + + return createResult({ + code: destResult.code, + stdout: destResult.stdout, + stderr: sourceResult.stderr + destResult.stderr, + stdin: sourceResult.stdin, + }); + } catch (error) { + const result = createResult({ + code: error.code ?? 1, + stdout: '', + stderr: error.message || 'Pipeline execution failed', + stdin: + this.options.stdin && typeof this.options.stdin === 'string' + ? this.options.stdin + : this.options.stdin && Buffer.isBuffer(this.options.stdin) + ? this.options.stdin.toString('utf8') + : '', + }); + + const buf = Buffer.from(result.stderr); + if (this.options.mirror) { + safeWrite(process.stderr, buf); + } + this._emitProcessedData('stderr', buf); + + this.finish(result); + + return result; + } + }; +} diff --git a/js/src/$.process-runner-stream-kill.mjs b/js/src/$.process-runner-stream-kill.mjs new file mode 100644 index 0000000..9a13140 --- /dev/null +++ b/js/src/$.process-runner-stream-kill.mjs @@ -0,0 +1,312 @@ +// ProcessRunner stream and kill methods - streaming and process termination +// Part of the modular ProcessRunner architecture + +import { trace } from './$.trace.mjs'; +import { createResult } from './$.result.mjs'; + +const isBun = typeof globalThis.Bun !== 'undefined'; + +/** + * Send a signal to a process and its group + * @param {number} pid - Process ID + * @param {string} sig - Signal name (e.g., 'SIGTERM', 'SIGKILL') + * @param {string} runtime - Runtime identifier for logging + * @returns {string[]} List of successful operations + */ +function sendSignalToProcess(pid, sig, runtime) { + const operations = []; + const prefix = runtime === 'Bun' ? 'Bun ' : ''; + + try { + process.kill(pid, sig); + trace('ProcessRunner', () => `Sent ${sig} to ${prefix}process ${pid}`); + operations.push(`${sig} to process`); + } catch (err) { + trace( + 'ProcessRunner', + () => `Error sending ${sig} to ${prefix}process: ${err.message}` + ); + } + + try { + process.kill(-pid, sig); + trace( + 'ProcessRunner', + () => `Sent ${sig} to ${prefix}process group -${pid}` + ); + operations.push(`${sig} to group`); + } catch (err) { + trace( + 'ProcessRunner', + () => `${prefix}process group ${sig} failed: ${err.message}` + ); + } + + return operations; +} + +/** + * Kill a child process with escalating signals + * @param {object} child - Child process object + */ +function killChildProcess(child) { + if (!child || !child.pid) { + return; + } + + const runtime = isBun ? 'Bun' : 'Node'; + trace( + 'ProcessRunner', + () => + `Killing ${runtime} process | ${JSON.stringify({ pid: child.pid }, null, 2)}` + ); + + const killOperations = []; + killOperations.push(...sendSignalToProcess(child.pid, 'SIGTERM', runtime)); + killOperations.push(...sendSignalToProcess(child.pid, 'SIGKILL', runtime)); + + trace( + 'ProcessRunner', + () => `${runtime} kill operations attempted: ${killOperations.join(', ')}` + ); + + if (isBun) { + try { + child.kill(); + trace( + 'ProcessRunner', + () => `Called child.kill() for Bun process ${child.pid}` + ); + } catch (err) { + trace( + 'ProcessRunner', + () => `Error calling child.kill(): ${err.message}` + ); + } + } + + child.removeAllListeners?.(); +} + +/** + * Kill pipeline components (source and destination) + * @param {object} spec - Process runner spec + * @param {string} signal - Kill signal + */ +function killPipelineComponents(spec, signal) { + if (spec?.mode !== 'pipeline') { + return; + } + trace('ProcessRunner', () => 'Killing pipeline components'); + if (spec.source && typeof spec.source.kill === 'function') { + spec.source.kill(signal); + } + if (spec.destination && typeof spec.destination.kill === 'function') { + spec.destination.kill(signal); + } +} + +/** + * Handle abort controller during kill + * @param {AbortController} controller - The abort controller + */ +function abortController(controller) { + if (!controller) { + trace('ProcessRunner', () => 'No abort controller to abort'); + return; + } + trace( + 'ProcessRunner', + () => + `Aborting internal controller | ${JSON.stringify({ wasAborted: controller?.signal?.aborted }, null, 2)}` + ); + controller.abort(); + trace( + 'ProcessRunner', + () => + `Internal controller aborted | ${JSON.stringify({ nowAborted: controller?.signal?.aborted }, null, 2)}` + ); +} + +/** + * Handle virtual generator cleanup during kill + * @param {object} generator - The virtual generator + * @param {string} signal - Kill signal + */ +function cleanupVirtualGenerator(generator, signal) { + if (!generator) { + trace( + 'ProcessRunner', + () => + `No virtual generator to cleanup | ${JSON.stringify({ hasVirtualGenerator: false }, null, 2)}` + ); + return; + } + + trace( + 'ProcessRunner', + () => + `Virtual generator found for cleanup | ${JSON.stringify( + { + hasReturn: typeof generator.return === 'function', + hasThrow: typeof generator.throw === 'function', + signal, + }, + null, + 2 + )}` + ); + + if (generator.return) { + trace('ProcessRunner', () => 'Closing virtual generator with return()'); + try { + generator.return(); + trace('ProcessRunner', () => 'Virtual generator closed successfully'); + } catch (err) { + trace( + 'ProcessRunner', + () => + `Error closing generator | ${JSON.stringify({ error: err.message, stack: err.stack?.slice(0, 200) }, null, 2)}` + ); + } + } else { + trace('ProcessRunner', () => 'Virtual generator has no return() method'); + } +} + +/** + * Get exit code for signal + * @param {string} signal - Signal name + * @returns {number} Exit code + */ +function getSignalExitCode(signal) { + if (signal === 'SIGKILL') { + return 137; + } + if (signal === 'SIGTERM') { + return 143; + } + return 130; +} + +/** + * Kill the runner and create result + * @param {object} runner - ProcessRunner instance + * @param {string} signal - Kill signal + */ +function killRunner(runner, signal) { + runner._cancelled = true; + runner._cancellationSignal = signal; + killPipelineComponents(runner.spec, signal); + + if (runner._cancelResolve) { + trace('ProcessRunner', () => 'Resolving cancel promise'); + runner._cancelResolve(); + } + + abortController(runner._abortController); + cleanupVirtualGenerator(runner._virtualGenerator, signal); + + if (runner.child && !runner.finished) { + trace('ProcessRunner', () => `Killing child process ${runner.child.pid}`); + try { + killChildProcess(runner.child); + runner.child = null; + } catch (err) { + trace('ProcessRunner', () => `Error killing process: ${err.message}`); + console.error('Error killing process:', err.message); + } + } + + const result = createResult({ + code: getSignalExitCode(signal), + stdout: '', + stderr: `Process killed with ${signal}`, + stdin: '', + }); + runner.finish(result); +} + +/** + * Attach stream and kill methods to ProcessRunner prototype + * @param {Function} ProcessRunner - The ProcessRunner class + * @param {Object} deps - Dependencies (not used but kept for consistency) + */ +export function attachStreamKillMethods(ProcessRunner) { + ProcessRunner.prototype[Symbol.asyncIterator] = async function* () { + yield* this.stream(); + }; + + ProcessRunner.prototype.stream = async function* () { + trace('ProcessRunner', () => `stream ENTER | started=${this.started}`); + this._isStreaming = true; + if (!this.started) { + this._startAsync(); + } + + let buffer = []; + let resolve, _reject; + let ended = false; + let killed = false; + + const onData = (chunk) => { + if (!killed) { + buffer.push(chunk); + if (resolve) { + resolve(); + resolve = _reject = null; + } + } + }; + + const onEnd = () => { + ended = true; + if (resolve) { + resolve(); + resolve = _reject = null; + } + }; + + this.on('data', onData); + this.on('end', onEnd); + + try { + while (!ended || buffer.length > 0) { + if (killed) { + break; + } + if (buffer.length > 0) { + const chunk = buffer.shift(); + this._streamYielding = true; + yield chunk; + this._streamYielding = false; + } else if (!ended) { + await new Promise((res, rej) => { + resolve = res; + _reject = rej; + }); + } + } + } finally { + this.off('data', onData); + this.off('end', onEnd); + if (!this.finished) { + killed = true; + buffer = []; + this._streamBreaking = true; + this.kill(); + } + } + }; + + ProcessRunner.prototype.kill = function (signal = 'SIGTERM') { + trace( + 'ProcessRunner', + () => `kill | signal=${signal} finished=${this.finished}` + ); + if (this.finished) { + return; + } + killRunner(this, signal); + }; +} diff --git a/js/src/$.process-runner-virtual.mjs b/js/src/$.process-runner-virtual.mjs new file mode 100644 index 0000000..efe07db --- /dev/null +++ b/js/src/$.process-runner-virtual.mjs @@ -0,0 +1,297 @@ +// ProcessRunner virtual command methods - virtual command execution +// Part of the modular ProcessRunner architecture + +import { trace } from './$.trace.mjs'; +import { safeWrite } from './$.stream-utils.mjs'; + +/** + * Get stdin data from options + * @param {object} options - Runner options + * @returns {string} Stdin data + */ +function getStdinData(options) { + if (options.stdin && typeof options.stdin === 'string') { + return options.stdin; + } + if (options.stdin && Buffer.isBuffer(options.stdin)) { + return options.stdin.toString('utf8'); + } + return ''; +} + +/** + * Get exit code for cancellation signal + * @param {string} signal - Cancellation signal + * @returns {number} Exit code + */ +function getCancellationExitCode(signal) { + if (signal === 'SIGINT') { + return 130; + } + if (signal === 'SIGTERM') { + return 143; + } + return 1; +} + +/** + * Create abort promise for non-generator handlers + * @param {AbortController} abortController - Abort controller + * @returns {Promise} Promise that rejects on abort + */ +function createAbortPromise(abortController) { + return new Promise((_, reject) => { + if (abortController && abortController.signal.aborted) { + reject(new Error('Command cancelled')); + } + if (abortController) { + abortController.signal.addEventListener('abort', () => { + reject(new Error('Command cancelled')); + }); + } + }); +} + +/** + * Emit output and mirror if needed + * @param {object} runner - ProcessRunner instance + * @param {string} type - Output type (stdout/stderr) + * @param {string} data - Output data + */ +function emitOutput(runner, type, data) { + if (!data) { + return; + } + const buf = Buffer.from(data); + const stream = type === 'stdout' ? process.stdout : process.stderr; + if (runner.options.mirror) { + safeWrite(stream, buf); + } + runner._emitProcessedData(type, buf); +} + +/** + * Handle error in virtual command + * @param {object} runner - ProcessRunner instance + * @param {Error} error - Error that occurred + * @param {object} shellSettings - Global shell settings + * @returns {object} Result object + */ +function handleVirtualError(runner, error, shellSettings) { + let exitCode = error.code ?? 1; + if (runner._cancelled && runner._cancellationSignal) { + exitCode = getCancellationExitCode(runner._cancellationSignal); + } + + const result = { + code: exitCode, + stdout: error.stdout ?? '', + stderr: error.stderr ?? error.message, + stdin: '', + }; + + emitOutput(runner, 'stderr', result.stderr); + runner.finish(result); + + if (shellSettings.errexit) { + error.result = result; + throw error; + } + + return result; +} + +/** + * Run async generator handler + * @param {object} runner - ProcessRunner instance + * @param {Function} handler - Generator handler + * @param {Array} argValues - Argument values + * @param {string} stdinData - Stdin data + * @returns {Promise} Result object + */ +async function runGeneratorHandler(runner, handler, argValues, stdinData) { + const chunks = []; + const commandOptions = { + cwd: runner.options.cwd, + env: runner.options.env, + options: runner.options, + isCancelled: () => runner._cancelled, + }; + + const generator = handler({ + args: argValues, + stdin: stdinData, + abortSignal: runner._abortController?.signal, + ...commandOptions, + }); + runner._virtualGenerator = generator; + + const cancelPromise = new Promise((resolve) => { + runner._cancelResolve = resolve; + }); + + try { + const iterator = generator[Symbol.asyncIterator](); + let done = false; + + while (!done && !runner._cancelled) { + const result = await Promise.race([ + iterator.next(), + cancelPromise.then(() => ({ done: true, cancelled: true })), + ]); + + if (result.cancelled || runner._cancelled) { + if (iterator.return) { + await iterator.return(); + } + break; + } + + done = result.done; + + if (!done && !runner._cancelled && !runner._streamBreaking) { + const buf = Buffer.from(result.value); + chunks.push(buf); + + if (runner.options.mirror) { + safeWrite(process.stdout, buf); + } + runner._emitProcessedData('stdout', buf); + } + } + } finally { + runner._virtualGenerator = null; + runner._cancelResolve = null; + } + + return { + code: 0, + stdout: runner.options.capture + ? Buffer.concat(chunks).toString('utf8') + : undefined, + stderr: runner.options.capture ? '' : undefined, + stdin: runner.options.capture ? stdinData : undefined, + }; +} + +/** + * Run regular (non-generator) handler + * @param {object} runner - ProcessRunner instance + * @param {Function} handler - Handler function + * @param {Array} argValues - Argument values + * @param {string} stdinData - Stdin data + * @returns {Promise} Result object + */ +async function runRegularHandler(runner, handler, argValues, stdinData) { + const commandOptions = { + cwd: runner.options.cwd, + env: runner.options.env, + options: runner.options, + isCancelled: () => runner._cancelled, + }; + + const handlerPromise = handler({ + args: argValues, + stdin: stdinData, + abortSignal: runner._abortController?.signal, + ...commandOptions, + }); + + const abortPromise = createAbortPromise(runner._abortController); + let result; + + try { + result = await Promise.race([handlerPromise, abortPromise]); + } catch (err) { + if (err.message === 'Command cancelled') { + const exitCode = getCancellationExitCode(runner._cancellationSignal); + result = { code: exitCode, stdout: '', stderr: '' }; + } else { + throw err; + } + } + + result = { + ...result, + code: result.code ?? 0, + stdout: runner.options.capture ? (result.stdout ?? '') : undefined, + stderr: runner.options.capture ? (result.stderr ?? '') : undefined, + stdin: runner.options.capture ? stdinData : undefined, + }; + + emitOutput(runner, 'stdout', result.stdout); + emitOutput(runner, 'stderr', result.stderr); + + return result; +} + +/** + * Attach virtual command methods to ProcessRunner prototype + * @param {Function} ProcessRunner - The ProcessRunner class + * @param {Object} deps - Dependencies + */ +export function attachVirtualCommandMethods(ProcessRunner, deps) { + const { virtualCommands, globalShellSettings } = deps; + + ProcessRunner.prototype._runVirtual = async function ( + cmd, + args, + originalCommand = null + ) { + trace('ProcessRunner', () => `_runVirtual | cmd=${cmd}`); + + const handler = virtualCommands.get(cmd); + if (!handler) { + throw new Error(`Virtual command not found: ${cmd}`); + } + + try { + // Special handling for streaming mode (stdin: "pipe") + if (this.options.stdin === 'pipe') { + const modifiedOptions = { + ...this.options, + stdin: 'pipe', + _bypassVirtual: true, + }; + const ProcessRunnerRef = this.constructor; + const realRunner = new ProcessRunnerRef( + { mode: 'shell', command: originalCommand || cmd }, + modifiedOptions + ); + return await realRunner._doStartAsync(); + } + + const stdinData = getStdinData(this.options); + const argValues = args.map((arg) => + arg.value !== undefined ? arg.value : arg + ); + + if (globalShellSettings.xtrace) { + console.log(`+ ${originalCommand || `${cmd} ${argValues.join(' ')}`}`); + } + if (globalShellSettings.verbose) { + console.log(`${originalCommand || `${cmd} ${argValues.join(' ')}`}`); + } + + const isGenerator = handler.constructor.name === 'AsyncGeneratorFunction'; + const result = isGenerator + ? await runGeneratorHandler(this, handler, argValues, stdinData) + : await runRegularHandler(this, handler, argValues, stdinData); + + this.finish(result); + + if (globalShellSettings.errexit && result.code !== 0) { + const error = new Error(`Command failed with exit code ${result.code}`); + error.code = result.code; + error.stdout = result.stdout; + error.stderr = result.stderr; + error.result = result; + throw error; + } + + return result; + } catch (error) { + return handleVirtualError(this, error, globalShellSettings); + } + }; +} diff --git a/js/src/$.quote.mjs b/js/src/$.quote.mjs new file mode 100644 index 0000000..ebac821 --- /dev/null +++ b/js/src/$.quote.mjs @@ -0,0 +1,161 @@ +// Shell quoting and command building utilities +// Handles safe interpolation of values into shell commands + +import { trace } from './$.trace.mjs'; + +/** + * Quote a value for safe shell interpolation + * @param {*} value - Value to quote + * @returns {string} Safely quoted string + */ +export function quote(value) { + if (value === null || value === undefined) { + return "''"; + } + if (Array.isArray(value)) { + return value.map(quote).join(' '); + } + if (typeof value !== 'string') { + value = String(value); + } + if (value === '') { + return "''"; + } + + // If the value is already properly quoted and doesn't need further escaping, + // check if we can use it as-is or with simpler quoting + if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { + // If it's already single-quoted and doesn't contain unescaped single quotes in the middle, + // we can potentially use it as-is + const inner = value.slice(1, -1); + if (!inner.includes("'")) { + // The inner content has no single quotes, so the original quoting is fine + return value; + } + } + + if (value.startsWith('"') && value.endsWith('"') && value.length > 2) { + // If it's already double-quoted, wrap it in single quotes to preserve it + return `'${value}'`; + } + + // Check if the string needs quoting at all + // Safe characters: alphanumeric, dash, underscore, dot, slash, colon, equals, comma, plus + // This regex matches strings that DON'T need quoting + const safePattern = /^[a-zA-Z0-9_\-./=,+@:]+$/; + + if (safePattern.test(value)) { + // The string is safe and doesn't need quoting + return value; + } + + // Default behavior: wrap in single quotes and escape any internal single quotes + // This handles spaces, special shell characters, etc. + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** + * Build a shell command from template strings and values + * @param {string[]} strings - Template literal strings + * @param {*[]} values - Interpolated values + * @returns {string} Complete shell command + */ +export function buildShellCommand(strings, values) { + trace( + 'Utils', + () => + `buildShellCommand ENTER | ${JSON.stringify( + { + stringsLength: strings.length, + valuesLength: values.length, + }, + null, + 2 + )}` + ); + + // Special case: if we have a single value with empty surrounding strings, + // and the value looks like a complete shell command, treat it as raw + if ( + values.length === 1 && + strings.length === 2 && + strings[0] === '' && + strings[1] === '' && + typeof values[0] === 'string' + ) { + const commandStr = values[0]; + // Check if this looks like a complete shell command (contains spaces and shell-safe characters) + const commandPattern = /^[a-zA-Z0-9_\-./=,+@:\s"'`$(){}<>|&;*?[\]~\\]+$/; + if (commandPattern.test(commandStr) && commandStr.trim().length > 0) { + trace( + 'Utils', + () => + `BRANCH: buildShellCommand => COMPLETE_COMMAND | ${JSON.stringify({ command: commandStr }, null, 2)}` + ); + return commandStr; + } + } + + let out = ''; + for (let i = 0; i < strings.length; i++) { + out += strings[i]; + if (i < values.length) { + const v = values[i]; + if ( + v && + typeof v === 'object' && + Object.prototype.hasOwnProperty.call(v, 'raw') + ) { + trace( + 'Utils', + () => + `BRANCH: buildShellCommand => RAW_VALUE | ${JSON.stringify({ value: String(v.raw) }, null, 2)}` + ); + out += String(v.raw); + } else { + const quoted = quote(v); + trace( + 'Utils', + () => + `BRANCH: buildShellCommand => QUOTED_VALUE | ${JSON.stringify({ original: v, quoted }, null, 2)}` + ); + out += quoted; + } + } + } + + trace( + 'Utils', + () => + `buildShellCommand EXIT | ${JSON.stringify({ command: out }, null, 2)}` + ); + return out; +} + +/** + * Mark a value as raw (not to be quoted) + * @param {*} value - Value to mark as raw + * @returns {{ raw: string }} Raw value wrapper + */ +export function raw(value) { + trace('API', () => `raw() called with value: ${String(value).slice(0, 50)}`); + return { raw: String(value) }; +} + +/** + * Pump a readable stream, calling onChunk for each chunk + * @param {Readable} readable - Readable stream + * @param {function} onChunk - Callback for each chunk + */ +export async function pumpReadable(readable, onChunk) { + if (!readable) { + trace('Utils', () => 'pumpReadable: No readable stream provided'); + return; + } + trace('Utils', () => 'pumpReadable: Starting to pump readable stream'); + for await (const chunk of readable) { + const { asBuffer } = await import('./$.stream-utils.mjs'); + await onChunk(asBuffer(chunk)); + } + trace('Utils', () => 'pumpReadable: Finished pumping readable stream'); +} diff --git a/js/src/$.result.mjs b/js/src/$.result.mjs new file mode 100644 index 0000000..9b87625 --- /dev/null +++ b/js/src/$.result.mjs @@ -0,0 +1,23 @@ +// Result creation utilities for command-stream +// Creates standardized result objects + +/** + * Create a standardized result object + * @param {object} params - Result parameters + * @param {number} params.code - Exit code + * @param {string} params.stdout - Standard output + * @param {string} params.stderr - Standard error + * @param {string} params.stdin - Standard input that was sent + * @returns {object} Result object with text() method + */ +export function createResult({ code, stdout = '', stderr = '', stdin = '' }) { + return { + code, + stdout, + stderr, + stdin, + text() { + return Promise.resolve(stdout); + }, + }; +} diff --git a/js/src/$.shell-settings.mjs b/js/src/$.shell-settings.mjs new file mode 100644 index 0000000..642c036 --- /dev/null +++ b/js/src/$.shell-settings.mjs @@ -0,0 +1,84 @@ +// Shell settings management (set, unset, shell object) +// Provides bash-like shell option management + +import { trace } from './$.trace.mjs'; +import { getShellSettings, setShellSettings } from './$.state.mjs'; + +/** + * Set a shell option + * @param {string} option - Option to set + * @returns {object} Current shell settings + */ +export function set(option) { + trace('API', () => `set() called with option: ${option}`); + const mapping = { + e: 'errexit', // set -e: exit on error + errexit: 'errexit', + v: 'verbose', // set -v: verbose + verbose: 'verbose', + x: 'xtrace', // set -x: trace execution + xtrace: 'xtrace', + u: 'nounset', // set -u: error on unset vars + nounset: 'nounset', + 'o pipefail': 'pipefail', // set -o pipefail + pipefail: 'pipefail', + }; + + const globalShellSettings = getShellSettings(); + + if (mapping[option]) { + setShellSettings({ [mapping[option]]: true }); + if (globalShellSettings.verbose) { + console.log(`+ set -${option}`); + } + } + return getShellSettings(); +} + +/** + * Unset a shell option + * @param {string} option - Option to unset + * @returns {object} Current shell settings + */ +export function unset(option) { + trace('API', () => `unset() called with option: ${option}`); + const mapping = { + e: 'errexit', + errexit: 'errexit', + v: 'verbose', + verbose: 'verbose', + x: 'xtrace', + xtrace: 'xtrace', + u: 'nounset', + nounset: 'nounset', + 'o pipefail': 'pipefail', + pipefail: 'pipefail', + }; + + const globalShellSettings = getShellSettings(); + + if (mapping[option]) { + setShellSettings({ [mapping[option]]: false }); + if (globalShellSettings.verbose) { + console.log(`+ set +${option}`); + } + } + return getShellSettings(); +} + +/** + * Convenience object for common shell patterns + */ +export const shell = { + set, + unset, + settings: () => ({ ...getShellSettings() }), + + // Bash-like shortcuts + errexit: (enable = true) => (enable ? set('e') : unset('e')), + verbose: (enable = true) => (enable ? set('v') : unset('v')), + xtrace: (enable = true) => (enable ? set('x') : unset('x')), + pipefail: (enable = true) => + enable ? set('o pipefail') : unset('o pipefail'), + nounset: (enable = true) => (enable ? set('u') : unset('u')), +}; diff --git a/js/src/$.shell.mjs b/js/src/$.shell.mjs new file mode 100644 index 0000000..d0156bf --- /dev/null +++ b/js/src/$.shell.mjs @@ -0,0 +1,157 @@ +// Shell detection utilities for command-stream +// Handles cross-platform shell detection and caching + +import cp from 'child_process'; +import fs from 'fs'; +import { trace } from './$.trace.mjs'; + +// Shell detection cache +let cachedShell = null; + +/** + * Find an available shell by checking multiple options in order + * Returns the shell command and arguments to use + * @returns {{ cmd: string, args: string[] }} Shell command and arguments + */ +export function findAvailableShell() { + if (cachedShell) { + trace('ShellDetection', () => `Using cached shell: ${cachedShell.cmd}`); + return cachedShell; + } + + const isWindows = process.platform === 'win32'; + + // Windows-specific shells + const windowsShells = [ + // Git Bash is the most Unix-compatible option on Windows + // Check common installation paths + { + cmd: 'C:\\Program Files\\Git\\bin\\bash.exe', + args: ['-c'], + checkPath: true, + }, + { + cmd: 'C:\\Program Files\\Git\\usr\\bin\\bash.exe', + args: ['-c'], + checkPath: true, + }, + { + cmd: 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + args: ['-c'], + checkPath: true, + }, + // Git Bash via PATH (if added to PATH by user) + { cmd: 'bash.exe', args: ['-c'], checkPath: false }, + // WSL bash as fallback + { cmd: 'wsl.exe', args: ['bash', '-c'], checkPath: false }, + // PowerShell as last resort (different syntax for commands) + { cmd: 'powershell.exe', args: ['-Command'], checkPath: false }, + { cmd: 'pwsh.exe', args: ['-Command'], checkPath: false }, + // cmd.exe as final fallback + { cmd: 'cmd.exe', args: ['/c'], checkPath: false }, + ]; + + // Unix-specific shells + const unixShells = [ + // Try absolute paths first (most reliable) + { cmd: '/bin/sh', args: ['-l', '-c'], checkPath: true }, + { cmd: '/usr/bin/sh', args: ['-l', '-c'], checkPath: true }, + { cmd: '/bin/bash', args: ['-l', '-c'], checkPath: true }, + { cmd: '/usr/bin/bash', args: ['-l', '-c'], checkPath: true }, + { cmd: '/bin/zsh', args: ['-l', '-c'], checkPath: true }, + { cmd: '/usr/bin/zsh', args: ['-l', '-c'], checkPath: true }, + // macOS specific paths + { cmd: '/usr/local/bin/bash', args: ['-l', '-c'], checkPath: true }, + { cmd: '/usr/local/bin/zsh', args: ['-l', '-c'], checkPath: true }, + // Linux brew paths + { + cmd: '/home/linuxbrew/.linuxbrew/bin/bash', + args: ['-l', '-c'], + checkPath: true, + }, + { + cmd: '/home/linuxbrew/.linuxbrew/bin/zsh', + args: ['-l', '-c'], + checkPath: true, + }, + // Try shells in PATH as fallback (which might not work in all environments) + // Using separate -l and -c flags for better compatibility + { cmd: 'sh', args: ['-l', '-c'], checkPath: false }, + { cmd: 'bash', args: ['-l', '-c'], checkPath: false }, + { cmd: 'zsh', args: ['-l', '-c'], checkPath: false }, + ]; + + // Select shells based on platform + const shellsToTry = isWindows ? windowsShells : unixShells; + + for (const shell of shellsToTry) { + try { + if (shell.checkPath) { + // Check if the absolute path exists + if (fs.existsSync(shell.cmd)) { + trace( + 'ShellDetection', + () => `Found shell at absolute path: ${shell.cmd}` + ); + cachedShell = { cmd: shell.cmd, args: shell.args }; + return cachedShell; + } + } else { + // On Windows, use 'where' instead of 'which' + const whichCmd = isWindows ? 'where' : 'which'; + const result = cp.spawnSync(whichCmd, [shell.cmd], { + encoding: 'utf-8', + // On Windows, we need shell: true for 'where' to work + shell: isWindows, + }); + if (result.status === 0 && result.stdout) { + const shellPath = result.stdout.trim().split('\n')[0]; // Take first result + trace( + 'ShellDetection', + () => `Found shell in PATH: ${shell.cmd} => ${shellPath}` + ); + cachedShell = { cmd: shell.cmd, args: shell.args }; + return cachedShell; + } + } + } catch (e) { + // Continue to next shell option + trace( + 'ShellDetection', + () => `Failed to check shell ${shell.cmd}: ${e.message}` + ); + } + } + + // Final fallback based on platform + if (isWindows) { + trace( + 'ShellDetection', + () => 'WARNING: No shell found, using cmd.exe as fallback on Windows' + ); + cachedShell = { cmd: 'cmd.exe', args: ['/c'] }; + } else { + trace( + 'ShellDetection', + () => 'WARNING: No shell found, using /bin/sh as fallback' + ); + cachedShell = { cmd: '/bin/sh', args: ['-l', '-c'] }; + } + return cachedShell; +} + +/** + * Clear the shell cache (useful for testing) + */ +export function clearShellCache() { + cachedShell = null; + trace('ShellDetection', () => 'Shell cache cleared'); +} + +/** + * Get the currently cached shell (if any) + * @returns {{ cmd: string, args: string[] } | null} + */ +export function getCachedShell() { + return cachedShell; +} diff --git a/js/src/$.state.mjs b/js/src/$.state.mjs new file mode 100644 index 0000000..9dc3fc4 --- /dev/null +++ b/js/src/$.state.mjs @@ -0,0 +1,401 @@ +// Global state management for command-stream +// Handles signal handlers, process tracking, and cleanup + +import fs from 'fs'; +import { trace } from './$.trace.mjs'; +import { clearShellCache } from './$.shell.mjs'; +import { resetAnsiConfig } from './$.ansi.mjs'; + +const isBun = typeof globalThis.Bun !== 'undefined'; + +// Save initial working directory for restoration +const initialWorkingDirectory = process.cwd(); + +// Track parent stream state for graceful shutdown +let parentStreamsMonitored = false; + +// Set of active ProcessRunner instances +export const activeProcessRunners = new Set(); + +// Track if SIGINT handler has been installed +let sigintHandlerInstalled = false; +let sigintHandler = null; // Store reference to remove it later + +// Global shell settings (use a proxy for modules that need direct property access) +const globalShellSettings = { + errexit: false, // set -e equivalent: exit on error + verbose: false, // set -v equivalent: print commands + xtrace: false, // set -x equivalent: trace execution + pipefail: false, // set -o pipefail equivalent: pipe failure detection + nounset: false, // set -u equivalent: error on undefined variables +}; + +// Export the globalShellSettings object +export { globalShellSettings }; + +// Virtual commands registry +export const virtualCommands = new Map(); +let virtualCommandsEnabled = true; + +/** + * Get the current shell settings + * @returns {object} Current shell settings + */ +export function getShellSettings() { + return globalShellSettings; +} + +/** + * Set shell settings + * @param {object} settings - Settings to apply + */ +export function setShellSettings(settings) { + Object.assign(globalShellSettings, settings); +} + +/** + * Reset shell settings to defaults + */ +export function resetShellSettings() { + globalShellSettings.errexit = false; + globalShellSettings.verbose = false; + globalShellSettings.xtrace = false; + globalShellSettings.pipefail = false; + globalShellSettings.nounset = false; + globalShellSettings.noglob = false; + globalShellSettings.allexport = false; +} + +/** + * Check if virtual commands are enabled + * @returns {boolean} + */ +export function isVirtualCommandsEnabled() { + return virtualCommandsEnabled; +} + +/** + * Enable virtual commands + */ +export function enableVirtualCommands() { + trace('VirtualCommands', () => 'Enabling virtual commands'); + virtualCommandsEnabled = true; + return virtualCommandsEnabled; +} + +/** + * Disable virtual commands + */ +export function disableVirtualCommands() { + trace('VirtualCommands', () => 'Disabling virtual commands'); + virtualCommandsEnabled = false; + return virtualCommandsEnabled; +} + +/** + * Find active runners (child processes and virtual commands) + * @returns {Array} Active runners + */ +function findActiveRunners() { + const activeChildren = []; + for (const runner of activeProcessRunners) { + if (!runner.finished) { + if (runner.child && runner.child.pid) { + activeChildren.push(runner); + } else if (!runner.child) { + activeChildren.push(runner); + } + } + } + return activeChildren; +} + +/** + * Send SIGINT to a child process + * @param {object} runner - ProcessRunner instance + */ +function sendSigintToChild(runner) { + trace('ProcessRunner', () => `Sending SIGINT to child ${runner.child.pid}`); + if (isBun) { + runner.child.kill('SIGINT'); + } else { + try { + process.kill(-runner.child.pid, 'SIGINT'); + } catch (_err) { + process.kill(runner.child.pid, 'SIGINT'); + } + } +} + +/** + * Forward SIGINT to all active runners + * @param {Array} activeChildren - Active runners to signal + */ +function forwardSigintToRunners(activeChildren) { + for (const runner of activeChildren) { + try { + if (runner.child && runner.child.pid) { + sendSigintToChild(runner); + } else { + trace('ProcessRunner', () => 'Cancelling virtual command'); + runner.kill('SIGINT'); + } + } catch (err) { + trace('ProcessRunner', () => `Error forwarding SIGINT: ${err.message}`); + } + } +} + +/** + * Handle exit after SIGINT forwarding + * @param {boolean} hasOtherHandlers - Whether other handlers exist + * @param {number} activeCount - Number of active children + */ +function handleSigintExit(hasOtherHandlers, activeCount) { + trace('ProcessRunner', () => `SIGINT forwarded to ${activeCount} processes`); + if (!hasOtherHandlers) { + trace('ProcessRunner', () => 'No other handlers, exiting with code 130'); + if (process.stdout && typeof process.stdout.write === 'function') { + process.stdout.write('', () => process.exit(130)); + } else { + process.exit(130); + } + } +} + +/** + * Check if our handler is installed + * @returns {boolean} + */ +function isOurHandlerInstalled() { + const currentListeners = process.listeners('SIGINT'); + return currentListeners.some((l) => { + const str = l.toString(); + // Look for our unique marker or helper function names + return ( + str.includes('findActiveRunners') || + str.includes('forwardSigintToRunners') || + str.includes('handleSigintExit') || + // Legacy detection for backwards compatibility + (str.includes('activeProcessRunners') && + str.includes('ProcessRunner') && + str.includes('activeChildren')) + ); + }); +} + +/** + * Install SIGINT handler for graceful shutdown + */ +export function installSignalHandlers() { + const hasOurHandler = isOurHandlerInstalled(); + + if (sigintHandlerInstalled && hasOurHandler) { + return; + } + + if (sigintHandlerInstalled && !hasOurHandler) { + sigintHandlerInstalled = false; + sigintHandler = null; + } + + trace('SignalHandler', () => `Installing SIGINT handler`); + sigintHandlerInstalled = true; + + sigintHandler = () => { + const hasOtherHandlers = process.listeners('SIGINT').length > 1; + const activeChildren = findActiveRunners(); + + if (activeChildren.length === 0) { + return; + } + + forwardSigintToRunners(activeChildren); + handleSigintExit(hasOtherHandlers, activeChildren.length); + }; + + process.on('SIGINT', sigintHandler); +} + +/** + * Uninstall SIGINT handler + */ +export function uninstallSignalHandlers() { + if (!sigintHandlerInstalled || !sigintHandler) { + trace( + 'SignalHandler', + () => 'SIGINT handler not installed or missing, skipping removal' + ); + return; + } + + trace( + 'SignalHandler', + () => + `Removing SIGINT handler | ${JSON.stringify({ activeRunners: activeProcessRunners.size })}` + ); + process.removeListener('SIGINT', sigintHandler); + sigintHandlerInstalled = false; + sigintHandler = null; +} + +/** + * Check if a listener is a command-stream SIGINT handler + * @param {Function} listener - Listener function + * @returns {boolean} + */ +function isCommandStreamListener(listener) { + const str = listener.toString(); + return ( + str.includes('findActiveRunners') || + str.includes('forwardSigintToRunners') || + str.includes('handleSigintExit') || + str.includes('activeProcessRunners') || + str.includes('ProcessRunner') || + str.includes('activeChildren') + ); +} + +/** + * Force cleanup of all command-stream SIGINT handlers and state - for testing + */ +export function forceCleanupAll() { + const sigintListeners = process.listeners('SIGINT'); + const commandStreamListeners = sigintListeners.filter( + isCommandStreamListener + ); + + commandStreamListeners.forEach((listener) => { + process.removeListener('SIGINT', listener); + }); + + activeProcessRunners.clear(); + sigintHandlerInstalled = false; + sigintHandler = null; + + trace('SignalHandler', () => `Force cleanup completed`); +} + +/** + * Monitor parent streams for graceful shutdown + */ +export function monitorParentStreams() { + if (parentStreamsMonitored) { + trace('StreamMonitor', () => 'Parent streams already monitored, skipping'); + return; + } + trace('StreamMonitor', () => 'Setting up parent stream monitoring'); + parentStreamsMonitored = true; + + const checkParentStream = (stream, name) => { + if (stream && typeof stream.on === 'function') { + stream.on('close', () => { + trace( + 'ProcessRunner', + () => + `Parent ${name} closed - triggering graceful shutdown | ${JSON.stringify({ activeProcesses: activeProcessRunners.size }, null, 2)}` + ); + for (const runner of activeProcessRunners) { + if (runner._handleParentStreamClosure) { + runner._handleParentStreamClosure(); + } + } + }); + } + }; + + checkParentStream(process.stdout, 'stdout'); + checkParentStream(process.stderr, 'stderr'); +} + +/** + * Reset parent stream monitoring flag (for testing) + */ +export function resetParentStreamMonitoring() { + parentStreamsMonitored = false; +} + +/** + * Get a valid fallback directory + * @returns {string} Fallback directory path + */ +function getFallbackDirectory() { + if (process.env.HOME && fs.existsSync(process.env.HOME)) { + return process.env.HOME; + } + if (fs.existsSync('/workspace/command-stream')) { + return '/workspace/command-stream'; + } + return '/'; +} + +/** + * Restore working directory to initial or fallback + */ +function restoreWorkingDirectory() { + try { + let currentDir; + try { + currentDir = process.cwd(); + } catch (_e) { + currentDir = null; + } + + if (currentDir && currentDir === initialWorkingDirectory) { + return; + } + + if (fs.existsSync(initialWorkingDirectory)) { + process.chdir(initialWorkingDirectory); + } else { + const fallback = getFallbackDirectory(); + process.chdir(fallback); + } + } catch (_e) { + try { + process.chdir(getFallbackDirectory()); + } catch (e2) { + console.error('FATAL: Cannot set any working directory!', e2); + } + } +} + +/** + * Cleanup all active runners + */ +function cleanupActiveRunners() { + for (const runner of activeProcessRunners) { + if (!runner) { + continue; + } + try { + if (!runner.started && runner._cleanup) { + runner._cleanup(); + } else if (runner.kill) { + runner.kill(); + } + } catch (_e) { + // Ignore errors + } + } +} + +/** + * Complete global state reset for testing - clears all library state + */ +export function resetGlobalState() { + restoreWorkingDirectory(); + cleanupActiveRunners(); + forceCleanupAll(); + clearShellCache(); + parentStreamsMonitored = false; + resetShellSettings(); + virtualCommandsEnabled = true; + resetAnsiConfig(); + + if (virtualCommands.size === 0) { + import('./commands/index.mjs').catch(() => {}); + } + + trace('GlobalState', () => 'Global state reset completed'); +} diff --git a/js/src/$.stream-emitter.mjs b/js/src/$.stream-emitter.mjs new file mode 100644 index 0000000..37c1abb --- /dev/null +++ b/js/src/$.stream-emitter.mjs @@ -0,0 +1,111 @@ +// EventEmitter-like implementation for stream events +// Provides a minimal event emitter for ProcessRunner + +import { trace } from './$.trace.mjs'; + +/** + * Simple EventEmitter-like implementation for stream events + * Used as base class for ProcessRunner + */ +export class StreamEmitter { + constructor() { + this.listeners = new Map(); + } + + /** + * Register a listener for an event + * @param {string} event - Event name + * @param {function} listener - Event handler + * @returns {this} For chaining + */ + on(event, listener) { + trace( + 'StreamEmitter', + () => + `on() called | ${JSON.stringify({ + event, + hasExistingListeners: this.listeners.has(event), + listenerCount: this.listeners.get(event)?.length || 0, + })}` + ); + + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event).push(listener); + + // No auto-start - explicit start() or await will start the process + + return this; + } + + /** + * Register a one-time listener for an event + * @param {string} event - Event name + * @param {function} listener - Event handler + * @returns {this} For chaining + */ + once(event, listener) { + trace('StreamEmitter', () => `once() called for event: ${event}`); + const onceWrapper = (...args) => { + this.off(event, onceWrapper); + listener(...args); + }; + return this.on(event, onceWrapper); + } + + /** + * Emit an event to all registered listeners + * @param {string} event - Event name + * @param {...*} args - Arguments to pass to listeners + * @returns {this} For chaining + */ + emit(event, ...args) { + const eventListeners = this.listeners.get(event); + trace( + 'StreamEmitter', + () => + `Emitting event | ${JSON.stringify({ + event, + hasListeners: !!eventListeners, + listenerCount: eventListeners?.length || 0, + })}` + ); + if (eventListeners) { + // Create a copy to avoid issues if listeners modify the array + const listenersToCall = [...eventListeners]; + for (const listener of listenersToCall) { + listener(...args); + } + } + return this; + } + + /** + * Remove a listener for an event + * @param {string} event - Event name + * @param {function} listener - Event handler to remove + * @returns {this} For chaining + */ + off(event, listener) { + trace( + 'StreamEmitter', + () => + `off() called | ${JSON.stringify({ + event, + hasListeners: !!this.listeners.get(event), + listenerCount: this.listeners.get(event)?.length || 0, + })}` + ); + + const eventListeners = this.listeners.get(event); + if (eventListeners) { + const index = eventListeners.indexOf(listener); + if (index !== -1) { + eventListeners.splice(index, 1); + trace('StreamEmitter', () => `Removed listener at index ${index}`); + } + } + return this; + } +} diff --git a/js/src/$.stream-utils.mjs b/js/src/$.stream-utils.mjs new file mode 100644 index 0000000..3c460da --- /dev/null +++ b/js/src/$.stream-utils.mjs @@ -0,0 +1,390 @@ +// Stream utility functions for safe operations and error handling +// Provides cross-runtime compatible stream operations + +import { trace } from './$.trace.mjs'; + +const isBun = typeof globalThis.Bun !== 'undefined'; + +// Stream utility functions for safe operations and error handling +export const StreamUtils = { + /** + * Check if a stream is safe to write to + * @param {object} stream - The stream to check + * @returns {boolean} Whether the stream is writable + */ + isStreamWritable(stream) { + return stream && stream.writable && !stream.destroyed && !stream.closed; + }, + + /** + * Add standardized error handler to stdin streams + * @param {object} stream - The stream to add handler to + * @param {string} contextName - Name for trace logging + * @param {function} onNonEpipeError - Optional callback for non-EPIPE errors + */ + addStdinErrorHandler(stream, contextName = 'stdin', onNonEpipeError = null) { + if (stream && typeof stream.on === 'function') { + stream.on('error', (error) => { + const handled = this.handleStreamError( + error, + `${contextName} error event`, + false + ); + if (!handled && onNonEpipeError) { + onNonEpipeError(error); + } + }); + } + }, + + /** + * Safely write to a stream with comprehensive error handling + * @param {object} stream - The stream to write to + * @param {Buffer|string} data - The data to write + * @param {string} contextName - Name for trace logging + * @returns {boolean} Whether the write was successful + */ + safeStreamWrite(stream, data, contextName = 'stream') { + if (!this.isStreamWritable(stream)) { + trace( + 'ProcessRunner', + () => + `${contextName} write skipped - not writable | ${JSON.stringify( + { + hasStream: !!stream, + writable: stream?.writable, + destroyed: stream?.destroyed, + closed: stream?.closed, + }, + null, + 2 + )}` + ); + return false; + } + + try { + const result = stream.write(data); + trace( + 'ProcessRunner', + () => + `${contextName} write successful | ${JSON.stringify( + { + dataLength: data?.length || 0, + }, + null, + 2 + )}` + ); + return result; + } catch (error) { + if (error.code !== 'EPIPE') { + trace( + 'ProcessRunner', + () => + `${contextName} write error | ${JSON.stringify( + { + error: error.message, + code: error.code, + isEPIPE: false, + }, + null, + 2 + )}` + ); + throw error; // Re-throw non-EPIPE errors + } else { + trace( + 'ProcessRunner', + () => + `${contextName} EPIPE error (ignored) | ${JSON.stringify( + { + error: error.message, + code: error.code, + isEPIPE: true, + }, + null, + 2 + )}` + ); + } + return false; + } + }, + + /** + * Safely end a stream with error handling + * @param {object} stream - The stream to end + * @param {string} contextName - Name for trace logging + * @returns {boolean} Whether the end was successful + */ + safeStreamEnd(stream, contextName = 'stream') { + if (!this.isStreamWritable(stream) || typeof stream.end !== 'function') { + trace( + 'ProcessRunner', + () => + `${contextName} end skipped - not available | ${JSON.stringify( + { + hasStream: !!stream, + hasEnd: stream && typeof stream.end === 'function', + writable: stream?.writable, + }, + null, + 2 + )}` + ); + return false; + } + + try { + stream.end(); + trace('ProcessRunner', () => `${contextName} ended successfully`); + return true; + } catch (error) { + if (error.code !== 'EPIPE') { + trace( + 'ProcessRunner', + () => + `${contextName} end error | ${JSON.stringify( + { + error: error.message, + code: error.code, + }, + null, + 2 + )}` + ); + } else { + trace( + 'ProcessRunner', + () => + `${contextName} EPIPE on end (ignored) | ${JSON.stringify( + { + error: error.message, + code: error.code, + }, + null, + 2 + )}` + ); + } + return false; + } + }, + + /** + * Setup comprehensive stdin handling (error handler + safe operations) + * @param {object} stream - The stream to setup + * @param {string} contextName - Name for trace logging + * @returns {{ write: function, end: function, isWritable: function }} + */ + setupStdinHandling(stream, contextName = 'stdin') { + this.addStdinErrorHandler(stream, contextName); + + return { + write: (data) => this.safeStreamWrite(stream, data, contextName), + end: () => this.safeStreamEnd(stream, contextName), + isWritable: () => this.isStreamWritable(stream), + }; + }, + + /** + * Handle stream errors with consistent EPIPE behavior + * @param {Error} error - The error to handle + * @param {string} contextName - Name for trace logging + * @param {boolean} shouldThrow - Whether to throw non-EPIPE errors + * @returns {boolean} Whether the error was an EPIPE (handled gracefully) + */ + handleStreamError(error, contextName, shouldThrow = true) { + if (error.code !== 'EPIPE') { + trace( + 'ProcessRunner', + () => + `${contextName} error | ${JSON.stringify( + { + error: error.message, + code: error.code, + isEPIPE: false, + }, + null, + 2 + )}` + ); + if (shouldThrow) { + throw error; + } + return false; + } else { + trace( + 'ProcessRunner', + () => + `${contextName} EPIPE error (ignored) | ${JSON.stringify( + { + error: error.message, + code: error.code, + isEPIPE: true, + }, + null, + 2 + )}` + ); + return true; // EPIPE handled gracefully + } + }, + + /** + * Detect if stream supports Bun-style writing + * @param {object} stream - The stream to check + * @returns {boolean} + */ + isBunStream(stream) { + return isBun && stream && typeof stream.getWriter === 'function'; + }, + + /** + * Detect if stream supports Node.js-style writing + * @param {object} stream - The stream to check + * @returns {boolean} + */ + isNodeStream(stream) { + return stream && typeof stream.write === 'function'; + }, + + /** + * Write to either Bun or Node.js style stream + * @param {object} stream - The stream to write to + * @param {Buffer|string} data - The data to write + * @param {string} contextName - Name for trace logging + * @returns {Promise} Whether the write was successful + */ + async writeToStream(stream, data, contextName = 'stream') { + if (this.isBunStream(stream)) { + try { + const writer = stream.getWriter(); + await writer.write(data); + writer.releaseLock(); + return true; + } catch (error) { + return this.handleStreamError( + error, + `${contextName} Bun writer`, + false + ); + } + } else if (this.isNodeStream(stream)) { + try { + stream.write(data); + return true; + } catch (error) { + return this.handleStreamError( + error, + `${contextName} Node writer`, + false + ); + } + } + return false; + }, +}; + +/** + * Safe write to a stream with parent stream monitoring + * @param {object} stream - The stream to write to + * @param {Buffer|string} data - The data to write + * @param {object} processRunner - Optional ProcessRunner for parent stream handling + * @param {function} monitorParentStreams - Function to call for monitoring + * @returns {boolean} Whether the write was successful + */ +export function safeWrite( + stream, + data, + processRunner = null, + monitorParentStreams = null +) { + if (monitorParentStreams) { + monitorParentStreams(); + } + + if (!StreamUtils.isStreamWritable(stream)) { + trace( + 'ProcessRunner', + () => + `safeWrite skipped - stream not writable | ${JSON.stringify( + { + hasStream: !!stream, + writable: stream?.writable, + destroyed: stream?.destroyed, + closed: stream?.closed, + }, + null, + 2 + )}` + ); + + if ( + processRunner && + processRunner._handleParentStreamClosure && + (stream === process.stdout || stream === process.stderr) + ) { + processRunner._handleParentStreamClosure(); + } + + return false; + } + + try { + return stream.write(data); + } catch (error) { + trace( + 'ProcessRunner', + () => + `safeWrite error | ${JSON.stringify( + { + error: error.message, + code: error.code, + writable: stream.writable, + destroyed: stream.destroyed, + }, + null, + 2 + )}` + ); + + if ( + error.code === 'EPIPE' && + processRunner && + processRunner._handleParentStreamClosure && + (stream === process.stdout || stream === process.stderr) + ) { + processRunner._handleParentStreamClosure(); + } + + return false; + } +} + +/** + * Convert data to Buffer + * @param {Buffer|string|object} chunk - Data to convert + * @returns {Buffer} The data as a Buffer + */ +export function asBuffer(chunk) { + if (chunk === null || chunk === undefined) { + return Buffer.alloc(0); + } + if (Buffer.isBuffer(chunk)) { + return chunk; + } + if (typeof chunk === 'string') { + return Buffer.from(chunk, 'utf8'); + } + // Handle ArrayBuffer and other views + if (chunk instanceof Uint8Array || chunk instanceof ArrayBuffer) { + return Buffer.from(chunk); + } + // Handle objects with toString + if (typeof chunk.toString === 'function') { + return Buffer.from(chunk.toString(), 'utf8'); + } + return Buffer.from(String(chunk), 'utf8'); +} diff --git a/js/src/$.trace.mjs b/js/src/$.trace.mjs new file mode 100644 index 0000000..c0fd664 --- /dev/null +++ b/js/src/$.trace.mjs @@ -0,0 +1,36 @@ +// Trace function for verbose logging +// Can be controlled via COMMAND_STREAM_VERBOSE or COMMAND_STREAM_TRACE env vars +// Can be disabled per-command via trace: false option +// CI environment no longer auto-enables tracing + +/** + * Log trace messages for debugging + * @param {string} category - The category of the trace message + * @param {string|function} messageOrFunc - The message or a function that returns the message + * @param {object} runner - Optional runner object to check for trace option + */ +export function trace(category, messageOrFunc, runner = null) { + // Check if runner explicitly disabled tracing + if (runner && runner.options && runner.options.trace === false) { + return; + } + + // Check global trace setting (evaluated dynamically for runtime changes) + const TRACE_ENV = process.env.COMMAND_STREAM_TRACE; + const VERBOSE_ENV = process.env.COMMAND_STREAM_VERBOSE === 'true'; + + // COMMAND_STREAM_TRACE=false explicitly disables tracing even if COMMAND_STREAM_VERBOSE=true + // COMMAND_STREAM_TRACE=true explicitly enables tracing + // Otherwise, use COMMAND_STREAM_VERBOSE + const VERBOSE = + TRACE_ENV === 'false' ? false : TRACE_ENV === 'true' ? true : VERBOSE_ENV; + + if (!VERBOSE) { + return; + } + + const message = + typeof messageOrFunc === 'function' ? messageOrFunc() : messageOrFunc; + const timestamp = new Date().toISOString(); + console.error(`[TRACE ${timestamp}] [${category}] ${message}`); +} diff --git a/js/src/$.utils.mjs b/js/src/$.utils.mjs index 1dd1a7b..320bfbd 100644 --- a/js/src/$.utils.mjs +++ b/js/src/$.utils.mjs @@ -1,28 +1,7 @@ import path from 'path'; -// Trace function for verbose logging - consistent with src/$.mjs -// Can be controlled via COMMAND_STREAM_VERBOSE or COMMAND_STREAM_TRACE env vars -// CI environment no longer auto-enables tracing -export function trace(category, messageOrFunc) { - // Check global trace setting (evaluated dynamically for runtime changes) - const TRACE_ENV = process.env.COMMAND_STREAM_TRACE; - const VERBOSE_ENV = process.env.COMMAND_STREAM_VERBOSE === 'true'; - - // COMMAND_STREAM_TRACE=false explicitly disables tracing even if COMMAND_STREAM_VERBOSE=true - // COMMAND_STREAM_TRACE=true explicitly enables tracing - // Otherwise, use COMMAND_STREAM_VERBOSE - const VERBOSE = - TRACE_ENV === 'false' ? false : TRACE_ENV === 'true' ? true : VERBOSE_ENV; - - if (!VERBOSE) { - return; - } - - const message = - typeof messageOrFunc === 'function' ? messageOrFunc() : messageOrFunc; - const timestamp = new Date().toISOString(); - console.error(`[TRACE ${timestamp}] [${category}] ${message}`); -} +// Re-export trace from the dedicated trace module for consistency +export { trace } from './$.trace.mjs'; export const VirtualUtils = { /** diff --git a/js/src/$.virtual-commands.mjs b/js/src/$.virtual-commands.mjs new file mode 100644 index 0000000..f3c5b0d --- /dev/null +++ b/js/src/$.virtual-commands.mjs @@ -0,0 +1,113 @@ +// Virtual commands registration and management +// Handles registration of built-in and custom virtual commands + +import { trace } from './$.trace.mjs'; +import { virtualCommands, getShellSettings } from './$.state.mjs'; + +// Import virtual command implementations +import cdCommand from './commands/$.cd.mjs'; +import pwdCommand from './commands/$.pwd.mjs'; +import echoCommand from './commands/$.echo.mjs'; +import sleepCommand from './commands/$.sleep.mjs'; +import trueCommand from './commands/$.true.mjs'; +import falseCommand from './commands/$.false.mjs'; +import createWhichCommand from './commands/$.which.mjs'; +import createExitCommand from './commands/$.exit.mjs'; +import envCommand from './commands/$.env.mjs'; +import catCommand from './commands/$.cat.mjs'; +import lsCommand from './commands/$.ls.mjs'; +import mkdirCommand from './commands/$.mkdir.mjs'; +import rmCommand from './commands/$.rm.mjs'; +import mvCommand from './commands/$.mv.mjs'; +import cpCommand from './commands/$.cp.mjs'; +import touchCommand from './commands/$.touch.mjs'; +import basenameCommand from './commands/$.basename.mjs'; +import dirnameCommand from './commands/$.dirname.mjs'; +import yesCommand from './commands/$.yes.mjs'; +import seqCommand from './commands/$.seq.mjs'; +import testCommand from './commands/$.test.mjs'; + +/** + * Register a virtual command + * @param {string} name - Command name + * @param {function} handler - Command handler function + * @returns {Map} The virtual commands map + */ +export function register(name, handler) { + trace( + 'VirtualCommands', + () => `register ENTER | ${JSON.stringify({ name }, null, 2)}` + ); + virtualCommands.set(name, handler); + trace( + 'VirtualCommands', + () => `register EXIT | ${JSON.stringify({ registered: true }, null, 2)}` + ); + return virtualCommands; +} + +/** + * Unregister a virtual command + * @param {string} name - Command name to remove + * @returns {boolean} Whether the command was removed + */ +export function unregister(name) { + trace( + 'VirtualCommands', + () => `unregister ENTER | ${JSON.stringify({ name }, null, 2)}` + ); + const deleted = virtualCommands.delete(name); + trace( + 'VirtualCommands', + () => `unregister EXIT | ${JSON.stringify({ deleted }, null, 2)}` + ); + return deleted; +} + +/** + * List all registered virtual commands + * @returns {string[]} Array of command names + */ +export function listCommands() { + const commands = Array.from(virtualCommands.keys()); + trace( + 'VirtualCommands', + () => `listCommands() returning ${commands.length} commands` + ); + return commands; +} + +/** + * Register all built-in virtual commands + */ +export function registerBuiltins() { + trace( + 'VirtualCommands', + () => 'registerBuiltins() called - registering all built-in commands' + ); + + const globalShellSettings = getShellSettings(); + + // Register all imported commands + register('cd', cdCommand); + register('pwd', pwdCommand); + register('echo', echoCommand); + register('sleep', sleepCommand); + register('true', trueCommand); + register('false', falseCommand); + register('which', createWhichCommand(virtualCommands)); + register('exit', createExitCommand(globalShellSettings)); + register('env', envCommand); + register('cat', catCommand); + register('ls', lsCommand); + register('mkdir', mkdirCommand); + register('rm', rmCommand); + register('mv', mvCommand); + register('cp', cpCommand); + register('touch', touchCommand); + register('basename', basenameCommand); + register('dirname', dirnameCommand); + register('yes', yesCommand); + register('seq', seqCommand); + register('test', testCommand); +} diff --git a/js/src/commands/$.which.mjs b/js/src/commands/$.which.mjs index 155a145..5cf82c2 100644 --- a/js/src/commands/$.which.mjs +++ b/js/src/commands/$.which.mjs @@ -28,7 +28,9 @@ export default function createWhichCommand(virtualCommands) { if (fs.statSync(fullPath).isFile()) { return VirtualUtils.success(`${fullPath}\n`); } - } catch {} + } catch { + // File doesn't exist or isn't accessible, continue searching + } } } diff --git a/js/src/commands/index.mjs b/js/src/commands/index.mjs new file mode 100644 index 0000000..42d62f0 --- /dev/null +++ b/js/src/commands/index.mjs @@ -0,0 +1,24 @@ +// Command module index +// Re-exports all built-in virtual commands + +export { default as cd } from './$.cd.mjs'; +export { default as pwd } from './$.pwd.mjs'; +export { default as echo } from './$.echo.mjs'; +export { default as sleep } from './$.sleep.mjs'; +export { default as trueCmd } from './$.true.mjs'; +export { default as falseCmd } from './$.false.mjs'; +export { default as createWhich } from './$.which.mjs'; +export { default as createExit } from './$.exit.mjs'; +export { default as env } from './$.env.mjs'; +export { default as cat } from './$.cat.mjs'; +export { default as ls } from './$.ls.mjs'; +export { default as mkdir } from './$.mkdir.mjs'; +export { default as rm } from './$.rm.mjs'; +export { default as mv } from './$.mv.mjs'; +export { default as cp } from './$.cp.mjs'; +export { default as touch } from './$.touch.mjs'; +export { default as basename } from './$.basename.mjs'; +export { default as dirname } from './$.dirname.mjs'; +export { default as yes } from './$.yes.mjs'; +export { default as seq } from './$.seq.mjs'; +export { default as test } from './$.test.mjs'; diff --git a/js/src/shell-parser.mjs b/js/src/shell-parser.mjs index f79952e..d9a3506 100644 --- a/js/src/shell-parser.mjs +++ b/js/src/shell-parser.mjs @@ -22,6 +22,119 @@ const TokenType = { EOF: 'eof', }; +/** + * Parse a word token from the command string, handling quotes and escapes + * @param {string} command - The command string + * @param {number} startIndex - Starting position + * @returns {{word: string, endIndex: number}} Parsed word and end position + */ +function parseWord(command, startIndex) { + let word = ''; + let i = startIndex; + let inQuote = false; + let quoteChar = ''; + + while (i < command.length) { + const char = command[i]; + + if (!inQuote) { + const result = parseUnquotedChar(command, i, char, word); + if (result.done) { + return { word: result.word, endIndex: i }; + } + word = result.word; + i = result.index; + if (result.startQuote) { + inQuote = true; + quoteChar = result.startQuote; + } + } else { + const result = parseQuotedChar(command, i, char, word, quoteChar); + word = result.word; + i = result.index; + if (result.endQuote) { + inQuote = false; + quoteChar = ''; + } + } + } + + return { word, endIndex: i }; +} + +/** + * Parse a character when not inside quotes + */ +function parseUnquotedChar(command, i, char, word) { + if (char === '"' || char === "'") { + return { word: word + char, index: i + 1, startQuote: char }; + } + if (/\s/.test(char) || '&|;()<>'.includes(char)) { + return { word, done: true }; + } + if (char === '\\' && i + 1 < command.length) { + const escaped = command[i + 1]; + return { word: word + char + escaped, index: i + 2 }; + } + return { word: word + char, index: i + 1 }; +} + +/** + * Parse a character when inside quotes + */ +function parseQuotedChar(command, i, char, word, quoteChar) { + if (char === quoteChar && command[i - 1] !== '\\') { + return { word: word + char, index: i + 1, endQuote: true }; + } + if ( + char === '\\' && + i + 1 < command.length && + (command[i + 1] === quoteChar || command[i + 1] === '\\') + ) { + const escaped = command[i + 1]; + return { word: word + char + escaped, index: i + 2 }; + } + return { word: word + char, index: i + 1 }; +} + +/** + * Try to match an operator at the current position + * @returns {{type: string, value: string, length: number} | null} + */ +function matchOperator(command, i) { + const twoChar = command.slice(i, i + 2); + const oneChar = command[i]; + + if (twoChar === '&&') { + return { type: TokenType.AND, value: '&&', length: 2 }; + } + if (twoChar === '||') { + return { type: TokenType.OR, value: '||', length: 2 }; + } + if (twoChar === '>>') { + return { type: TokenType.REDIRECT_APPEND, value: '>>', length: 2 }; + } + if (oneChar === '|') { + return { type: TokenType.PIPE, value: '|', length: 1 }; + } + if (oneChar === ';') { + return { type: TokenType.SEMICOLON, value: ';', length: 1 }; + } + if (oneChar === '(') { + return { type: TokenType.LPAREN, value: '(', length: 1 }; + } + if (oneChar === ')') { + return { type: TokenType.RPAREN, value: ')', length: 1 }; + } + if (oneChar === '>') { + return { type: TokenType.REDIRECT_OUT, value: '>', length: 1 }; + } + if (oneChar === '<') { + return { type: TokenType.REDIRECT_IN, value: '<', length: 1 }; + } + return null; +} + /** * Tokenize a shell command string */ @@ -40,90 +153,19 @@ function tokenize(command) { } // Check for operators - if (command[i] === '&' && command[i + 1] === '&') { - tokens.push({ type: TokenType.AND, value: '&&' }); - i += 2; - } else if (command[i] === '|' && command[i + 1] === '|') { - tokens.push({ type: TokenType.OR, value: '||' }); - i += 2; - } else if (command[i] === '|') { - tokens.push({ type: TokenType.PIPE, value: '|' }); - i++; - } else if (command[i] === ';') { - tokens.push({ type: TokenType.SEMICOLON, value: ';' }); - i++; - } else if (command[i] === '(') { - tokens.push({ type: TokenType.LPAREN, value: '(' }); - i++; - } else if (command[i] === ')') { - tokens.push({ type: TokenType.RPAREN, value: ')' }); - i++; - } else if (command[i] === '>' && command[i + 1] === '>') { - tokens.push({ type: TokenType.REDIRECT_APPEND, value: '>>' }); - i += 2; - } else if (command[i] === '>') { - tokens.push({ type: TokenType.REDIRECT_OUT, value: '>' }); - i++; - } else if (command[i] === '<') { - tokens.push({ type: TokenType.REDIRECT_IN, value: '<' }); - i++; - } else { - // Parse word (respecting quotes) - let word = ''; - let inQuote = false; - let quoteChar = ''; - - while (i < command.length) { - const char = command[i]; - - if (!inQuote) { - if (char === '"' || char === "'") { - inQuote = true; - quoteChar = char; - word += char; - i++; - } else if (/\s/.test(char) || '&|;()<>'.includes(char)) { - break; - } else if (char === '\\' && i + 1 < command.length) { - // Handle escape sequences - word += char; - i++; - if (i < command.length) { - word += command[i]; - i++; - } - } else { - word += char; - i++; - } - } else { - if (char === quoteChar && command[i - 1] !== '\\') { - inQuote = false; - quoteChar = ''; - word += char; - i++; - } else if ( - char === '\\' && - i + 1 < command.length && - (command[i + 1] === quoteChar || command[i + 1] === '\\') - ) { - // Handle escaped quotes and backslashes inside quotes - word += char; - i++; - if (i < command.length) { - word += command[i]; - i++; - } - } else { - word += char; - i++; - } - } - } + const operator = matchOperator(command, i); + if (operator) { + tokens.push({ type: operator.type, value: operator.value }); + i += operator.length; + continue; + } - if (word) { - tokens.push({ type: TokenType.WORD, value: word }); - } + // Parse word (respecting quotes) + const { word, endIndex } = parseWord(command, i); + i = endIndex; + + if (word) { + tokens.push({ type: TokenType.WORD, value: word }); } } diff --git a/js/tests/resource-cleanup-internals.test.mjs b/js/tests/resource-cleanup-internals.test.mjs index 455feaa..547ae91 100644 --- a/js/tests/resource-cleanup-internals.test.mjs +++ b/js/tests/resource-cleanup-internals.test.mjs @@ -8,19 +8,27 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +// Helper to check if a listener is a command-stream SIGINT handler +function isCommandStreamListener(l) { + const str = l.toString(); + return ( + str.includes('findActiveRunners') || + str.includes('forwardSigintToRunners') || + str.includes('handleSigintExit') || + str.includes('activeProcessRunners') || + str.includes('ProcessRunner') || + str.includes('activeChildren') + ); +} + // Helper to access internal state for testing // This is a testing-only approach to verify cleanup function getInternalState() { // We'll use process listeners as a proxy for internal state const sigintListeners = process.listeners('SIGINT'); - const commandStreamListeners = sigintListeners.filter((l) => { - const str = l.toString(); - return ( - str.includes('activeProcessRunners') || - str.includes('ProcessRunner') || - str.includes('activeChildren') - ); - }); + const commandStreamListeners = sigintListeners.filter( + isCommandStreamListener + ); return { sigintHandlerCount: commandStreamListeners.length, @@ -50,14 +58,9 @@ describe('Resource Cleanup Internal Verification', () => { // Force remove any command-stream SIGINT listeners const sigintListeners = process.listeners('SIGINT'); - const commandStreamListeners = sigintListeners.filter((l) => { - const str = l.toString(); - return ( - str.includes('activeProcessRunners') || - str.includes('ProcessRunner') || - str.includes('activeChildren') - ); - }); + const commandStreamListeners = sigintListeners.filter( + isCommandStreamListener + ); commandStreamListeners.forEach((listener) => { process.removeListener('SIGINT', listener); @@ -431,14 +434,9 @@ describe('Resource Cleanup Internal Verification', () => { `Pipeline error test left behind ${state.sigintHandlerCount - initialState.sigintHandlerCount} handlers, forcing cleanup...` ); const sigintListeners = process.listeners('SIGINT'); - const commandStreamListeners = sigintListeners.filter((l) => { - const str = l.toString(); - return ( - str.includes('activeProcessRunners') || - str.includes('ProcessRunner') || - str.includes('activeChildren') - ); - }); + const commandStreamListeners = sigintListeners.filter( + isCommandStreamListener + ); commandStreamListeners.forEach((listener) => { process.removeListener('SIGINT', listener); diff --git a/js/tests/sigint-cleanup.test.mjs b/js/tests/sigint-cleanup.test.mjs index 0c16ba9..4d79021 100644 --- a/js/tests/sigint-cleanup.test.mjs +++ b/js/tests/sigint-cleanup.test.mjs @@ -82,6 +82,9 @@ describe.skipIf(isWindows)('SIGINT Handler Cleanup Tests', () => { const ourListeners = process.listeners('SIGINT').filter((l) => { const str = l.toString(); return ( + str.includes('findActiveRunners') || + str.includes('forwardSigintToRunners') || + str.includes('handleSigintExit') || str.includes('activeProcessRunners') || str.includes('ProcessRunner') || str.includes('activeChildren') diff --git a/temp-unicode-test.txt b/temp-unicode-test.txt deleted file mode 100644 index e69de29..0000000