import * as plugins from './plugins.js'; import { ShellEnv } from './classes.shellenv.js'; import type { IShellEnvContructorOptions, TExecutor } from './classes.shellenv.js'; import { ShellLog } from './classes.shelllog.js'; import * as cp from 'child_process'; // -- interfaces -- export interface IExecResult { exitCode: number; stdout: string; signal?: NodeJS.Signals; stderr?: string; } export interface IExecResultInteractive extends IExecResult { sendInput: (input: string) => Promise; sendLine: (line: string) => Promise; endInput: () => void; finalPromise: Promise; } export interface IExecResultStreaming { childProcess: cp.ChildProcess; finalPromise: Promise; kill: () => Promise; terminate: () => Promise; keyboardInterrupt: () => Promise; customSignal: (signal: plugins.smartexit.TProcessSignal) => Promise; sendInput: (input: string) => Promise; sendLine: (line: string) => Promise; endInput: () => void; } interface IExecOptions { commandString: string; silent?: boolean; strict?: boolean; streaming?: boolean; interactive?: boolean; passthrough?: boolean; interactiveControl?: boolean; usePty?: boolean; ptyCols?: number; ptyRows?: number; ptyTerm?: string; ptyShell?: string; maxBuffer?: number; onData?: (chunk: Buffer | string) => void; timeout?: number; debug?: boolean; env?: NodeJS.ProcessEnv; signal?: AbortSignal; } export interface ISpawnOptions extends Omit { command: string; args?: string[]; } export class Smartshell { public shellEnv: ShellEnv; public smartexit = new plugins.smartexit.SmartExit(); constructor(optionsArg: IShellEnvContructorOptions) { this.shellEnv = new ShellEnv(optionsArg); } /** * Executes a given command asynchronously. */ private async _exec(options: IExecOptions): Promise { if (options.interactive) { return await this._execInteractive({ commandString: options.commandString }); } return await this._execCommand(options); } /** * Executes an interactive command. */ private async _execInteractive(options: Pick): Promise { // Skip interactive execution in CI environments. if (process.env.CI) { return; } return new Promise((resolve) => { const shell = cp.spawn(options.commandString, { stdio: 'inherit', shell: true, detached: true, }); this.smartexit.addProcess(shell); shell.on('close', (code) => { console.log(`Interactive shell terminated with code ${code}`); this.smartexit.removeProcess(shell); resolve(); }); }); } /** * Executes a command with args array (shell:false) for security */ private async _execSpawn(options: ISpawnOptions): Promise { const shellLogInstance = new ShellLog(); let stderrBuffer = ''; const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB let bufferExceeded = false; // Handle PTY mode if requested if (options.usePty) { throw new Error('PTY mode is not yet supported with execSpawn. Use exec methods with shell:true for PTY.'); } const execChildProcess = cp.spawn(options.command, options.args || [], { shell: false, // SECURITY: Never use shell with untrusted input cwd: process.cwd(), env: options.env || process.env, detached: false, signal: options.signal, }); this.smartexit.addProcess(execChildProcess); // Handle timeout let timeoutHandle: NodeJS.Timeout | null = null; if (options.timeout) { timeoutHandle = setTimeout(() => { if (options.debug) { console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`); } execChildProcess.kill('SIGTERM'); }, options.timeout); } // Connect stdin if passthrough is enabled (but not for interactive control) if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) { process.stdin.pipe(execChildProcess.stdin); } // Create input methods for interactive control const sendInput = async (input: string): Promise => { if (!execChildProcess.stdin) { throw new Error('stdin is not available for this process'); } if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) { throw new Error('stdin has been destroyed or is not writable'); } return new Promise((resolve, reject) => { execChildProcess.stdin.write(input, 'utf8', (error) => { if (error) { reject(error); } else { resolve(); } }); }); }; const sendLine = async (line: string): Promise => { return sendInput(line + '\n'); }; const endInput = (): void => { if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) { execChildProcess.stdin.end(); } }; // Capture stdout and stderr output execChildProcess.stdout.on('data', (data) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } if (options.onData) { options.onData(data); } if (!bufferExceeded) { shellLogInstance.addToBuffer(data); if (shellLogInstance.logStore.length > maxBuffer) { bufferExceeded = true; shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]'); } } }); execChildProcess.stderr.on('data', (data) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } const dataStr = data.toString(); stderrBuffer += dataStr; if (options.onData) { options.onData(data); } if (!bufferExceeded) { shellLogInstance.addToBuffer(data); if (shellLogInstance.logStore.length > maxBuffer) { bufferExceeded = true; shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]'); } } }); // Wrap child process termination into a Promise const childProcessEnded: Promise = new Promise((resolve, reject) => { const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { if (timeoutHandle) { clearTimeout(timeoutHandle); } this.smartexit.removeProcess(execChildProcess); // Safely unpipe stdin when process ends if passthrough was enabled if (options.passthrough && !options.interactiveControl) { try { if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) { process.stdin.unpipe(execChildProcess.stdin); } } catch (err) { if (options.debug) { console.log(`[smartshell] Error unpiping stdin: ${err}`); } } } const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0); const execResult: IExecResult = { exitCode, stdout: shellLogInstance.logStore.toString(), signal: signal || undefined, stderr: stderrBuffer, }; if (options.strict && exitCode !== 0) { const errorMsg = signal ? `Command "${options.command}" terminated by signal ${signal}` : `Command "${options.command}" exited with code ${exitCode}`; reject(new Error(errorMsg)); } else { resolve(execResult); } }; execChildProcess.once('exit', handleExit); execChildProcess.once('error', (error) => { if (timeoutHandle) { clearTimeout(timeoutHandle); } this.smartexit.removeProcess(execChildProcess); // Safely unpipe stdin when process errors if passthrough was enabled if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) { try { if (!execChildProcess.stdin.destroyed) { process.stdin.unpipe(execChildProcess.stdin); } } catch (err) { if (options.debug) { console.log(`[smartshell] Error unpiping stdin on error: ${err}`); } } } reject(error); }); }); // If interactive control is enabled but not streaming, return interactive interface if (options.interactiveControl && !options.streaming) { return { exitCode: 0, // Will be updated when process ends stdout: '', // Will be updated when process ends sendInput, sendLine, endInput, finalPromise: childProcessEnded, } as IExecResultInteractive; } // If streaming mode is enabled, return a streaming interface if (options.streaming) { return { childProcess: execChildProcess, finalPromise: childProcessEnded, sendInput, sendLine, endInput, kill: async () => { if (options.debug) { console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`); } await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL'); }, terminate: async () => { if (options.debug) { console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`); } await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM'); }, keyboardInterrupt: async () => { if (options.debug) { console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`); } await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT'); }, customSignal: async (signal: plugins.smartexit.TProcessSignal) => { if (options.debug) { console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`); } await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal); }, } as IExecResultStreaming; } // For non-streaming mode, wait for the process to complete return await childProcessEnded; } /** * Executes a command and returns either a non-streaming result or a streaming interface. */ private async _execCommand(options: IExecOptions): Promise { const commandToExecute = this.shellEnv.createEnvExecString(options.commandString); const shellLogInstance = new ShellLog(); let stderrBuffer = ''; const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB let bufferExceeded = false; // Handle PTY mode if requested if (options.usePty) { return await this._execCommandPty(options, commandToExecute, shellLogInstance); } const execChildProcess = cp.spawn(commandToExecute, [], { shell: true, cwd: process.cwd(), env: options.env || process.env, detached: false, signal: options.signal, }); this.smartexit.addProcess(execChildProcess); // Handle timeout let timeoutHandle: NodeJS.Timeout | null = null; if (options.timeout) { timeoutHandle = setTimeout(() => { if (options.debug) { console.log(`[smartshell] Timeout reached for process ${execChildProcess.pid}, terminating...`); } execChildProcess.kill('SIGTERM'); }, options.timeout); } // Connect stdin if passthrough is enabled (but not for interactive control) if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) { process.stdin.pipe(execChildProcess.stdin); } // Create input methods for interactive control const sendInput = async (input: string): Promise => { if (!execChildProcess.stdin) { throw new Error('stdin is not available for this process'); } if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) { throw new Error('stdin has been destroyed or is not writable'); } return new Promise((resolve, reject) => { execChildProcess.stdin.write(input, 'utf8', (error) => { if (error) { reject(error); } else { resolve(); } }); }); }; const sendLine = async (line: string): Promise => { return sendInput(line + '\n'); }; const endInput = (): void => { if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) { execChildProcess.stdin.end(); } }; // Capture stdout and stderr output execChildProcess.stdout.on('data', (data) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } if (options.onData) { options.onData(data); } if (!bufferExceeded) { shellLogInstance.addToBuffer(data); if (shellLogInstance.logStore.length > maxBuffer) { bufferExceeded = true; shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]'); } } }); execChildProcess.stderr.on('data', (data) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } const dataStr = data.toString(); stderrBuffer += dataStr; if (options.onData) { options.onData(data); } if (!bufferExceeded) { shellLogInstance.addToBuffer(data); if (shellLogInstance.logStore.length > maxBuffer) { bufferExceeded = true; shellLogInstance.logStore = Buffer.from('[Output truncated - exceeded maxBuffer]'); } } }); // Wrap child process termination into a Promise const childProcessEnded: Promise = new Promise((resolve, reject) => { const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { if (timeoutHandle) { clearTimeout(timeoutHandle); } this.smartexit.removeProcess(execChildProcess); // Safely unpipe stdin when process ends if passthrough was enabled if (options.passthrough && !options.interactiveControl) { try { if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) { process.stdin.unpipe(execChildProcess.stdin); } } catch (err) { if (options.debug) { console.log(`[smartshell] Error unpiping stdin: ${err}`); } } } const exitCode = typeof code === 'number' ? code : (signal ? 1 : 0); const execResult: IExecResult = { exitCode, stdout: shellLogInstance.logStore.toString(), signal: signal || undefined, stderr: stderrBuffer, }; if (options.strict && exitCode !== 0) { const errorMsg = signal ? `Command "${options.commandString}" terminated by signal ${signal}` : `Command "${options.commandString}" exited with code ${exitCode}`; reject(new Error(errorMsg)); } else { resolve(execResult); } }; execChildProcess.once('exit', handleExit); execChildProcess.once('error', (error) => { if (timeoutHandle) { clearTimeout(timeoutHandle); } this.smartexit.removeProcess(execChildProcess); // Safely unpipe stdin when process errors if passthrough was enabled if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) { try { if (!execChildProcess.stdin.destroyed) { process.stdin.unpipe(execChildProcess.stdin); } } catch (err) { if (options.debug) { console.log(`[smartshell] Error unpiping stdin on error: ${err}`); } } } reject(error); }); }); // If interactive control is enabled but not streaming, return interactive interface if (options.interactiveControl && !options.streaming) { return { exitCode: 0, // Will be updated when process ends stdout: '', // Will be updated when process ends sendInput, sendLine, endInput, finalPromise: childProcessEnded, } as IExecResultInteractive; } // If streaming mode is enabled, return a streaming interface if (options.streaming) { return { childProcess: execChildProcess, finalPromise: childProcessEnded, sendInput, sendLine, endInput, kill: async () => { if (options.debug) { console.log(`[smartshell] Running tree kill with SIGKILL on process ${execChildProcess.pid}`); } await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGKILL'); }, terminate: async () => { if (options.debug) { console.log(`[smartshell] Running tree kill with SIGTERM on process ${execChildProcess.pid}`); } await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGTERM'); }, keyboardInterrupt: async () => { if (options.debug) { console.log(`[smartshell] Running tree kill with SIGINT on process ${execChildProcess.pid}`); } await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, 'SIGINT'); }, customSignal: async (signal: plugins.smartexit.TProcessSignal) => { if (options.debug) { console.log(`[smartshell] Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`); } await plugins.smartexit.SmartExit.killTreeByPid(execChildProcess.pid, signal); }, } as IExecResultStreaming; } // For non-streaming mode, wait for the process to complete. return await childProcessEnded; } public async exec(commandString: string): Promise { const result = await this._exec({ commandString }); // Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult return result as IExecResult; } public async execSilent(commandString: string): Promise { return (await this._exec({ commandString, silent: true })) as IExecResult; } public async execStrict(commandString: string): Promise { return (await this._exec({ commandString, strict: true })) as IExecResult; } public async execStrictSilent(commandString: string): Promise { return (await this._exec({ commandString, silent: true, strict: true })) as IExecResult; } public async execStreaming(commandString: string, silent: boolean = false): Promise { return (await this._exec({ commandString, silent, streaming: true })) as IExecResultStreaming; } public async execStreamingSilent(commandString: string): Promise { return (await this._exec({ commandString, silent: true, streaming: true })) as IExecResultStreaming; } public async execInteractive(commandString: string): Promise { await this._exec({ commandString, interactive: true }); } public async execPassthrough(commandString: string): Promise { return await this._exec({ commandString, passthrough: true }) as IExecResult; } public async execStreamingPassthrough(commandString: string): Promise { return await this._exec({ commandString, streaming: true, passthrough: true }) as IExecResultStreaming; } public async execInteractiveControl(commandString: string): Promise { return await this._exec({ commandString, interactiveControl: true }) as IExecResultInteractive; } public async execStreamingInteractiveControl(commandString: string): Promise { return await this._exec({ commandString, streaming: true, interactiveControl: true }) as IExecResultStreaming; } public async execInteractiveControlPty(commandString: string): Promise { return await this._exec({ commandString, interactiveControl: true, usePty: true }) as IExecResultInteractive; } public async execStreamingInteractiveControlPty(commandString: string): Promise { return await this._exec({ commandString, streaming: true, interactiveControl: true, usePty: true }) as IExecResultStreaming; } /** * Executes a command with args array (shell:false) for security * This is the recommended API for untrusted input */ public async execSpawn(command: string, args: string[] = [], options: Omit = {}): Promise { const result = await this._execSpawn({ command, args, ...options }); // Type assertion is safe here because non-streaming, non-interactive exec always returns IExecResult return result as IExecResult; } /** * Executes a command with args array in streaming mode */ public async execSpawnStreaming(command: string, args: string[] = [], options: Omit = {}): Promise { return await this._execSpawn({ command, args, streaming: true, ...options }) as IExecResultStreaming; } /** * Executes a command with args array with interactive control */ public async execSpawnInteractiveControl(command: string, args: string[] = [], options: Omit = {}): Promise { return await this._execSpawn({ command, args, interactiveControl: true, ...options }) as IExecResultInteractive; } public async execAndWaitForLine( commandString: string, regex: RegExp, silent: boolean = false, options: { timeout?: number; terminateOnMatch?: boolean } = {} ): Promise { const execStreamingResult = await this.execStreaming(commandString, silent); return new Promise((resolve, reject) => { let matched = false; let timeoutHandle: NodeJS.Timeout | null = null; // Set up timeout if specified if (options.timeout) { timeoutHandle = setTimeout(async () => { if (!matched) { matched = true; // Remove listener to prevent memory leak execStreamingResult.childProcess.stdout.removeAllListeners('data'); await execStreamingResult.terminate(); reject(new Error(`Timeout waiting for pattern after ${options.timeout}ms`)); } }, options.timeout); } const dataHandler = async (chunk: Buffer | string) => { const data = typeof chunk === 'string' ? chunk : chunk.toString(); if (!matched && regex.test(data)) { matched = true; // Clear timeout if set if (timeoutHandle) { clearTimeout(timeoutHandle); } // Remove listener to prevent memory leak execStreamingResult.childProcess.stdout.removeListener('data', dataHandler); // Terminate process if requested if (options.terminateOnMatch) { await execStreamingResult.terminate(); await execStreamingResult.finalPromise; } resolve(); } }; execStreamingResult.childProcess.stdout.on('data', dataHandler); // Also resolve/reject when process ends execStreamingResult.finalPromise.then(() => { if (!matched) { matched = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } reject(new Error('Process ended without matching pattern')); } }).catch((err) => { if (!matched) { matched = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } reject(err); } }); }); } public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean }): Promise { return this.execAndWaitForLine(commandString, regex, true, options); } private nodePty: any = null; private async lazyLoadNodePty(): Promise { if (this.nodePty) { return this.nodePty; } try { // Try to load node-pty if available // @ts-ignore - node-pty is optional this.nodePty = await import('node-pty'); return this.nodePty; } catch (error) { throw new Error( 'node-pty is required for PTY support but is not installed.\n' + 'Please install it as an optional dependency:\n' + ' pnpm add --save-optional node-pty\n' + 'Note: node-pty requires compilation and may have platform-specific requirements.' ); } } private async _execCommandPty( options: IExecOptions, commandToExecute: string, shellLogInstance: ShellLog ): Promise { const pty = await this.lazyLoadNodePty(); // Platform-aware shell selection let shell: string; let shellArgs: string[]; if (options.ptyShell) { // User-provided shell override shell = options.ptyShell; shellArgs = ['-c', commandToExecute]; } else if (process.platform === 'win32') { // Windows: Use PowerShell by default, or cmd as fallback const powershell = process.env.PROGRAMFILES ? `${process.env.PROGRAMFILES}\\PowerShell\\7\\pwsh.exe` : 'powershell.exe'; // Check if PowerShell Core exists, otherwise use Windows PowerShell const fs = await import('fs'); if (fs.existsSync(powershell)) { shell = powershell; shellArgs = ['-NoProfile', '-NonInteractive', '-Command', commandToExecute]; } else if (process.env.COMSPEC) { shell = process.env.COMSPEC; shellArgs = ['/d', '/s', '/c', commandToExecute]; } else { shell = 'cmd.exe'; shellArgs = ['/d', '/s', '/c', commandToExecute]; } } else { // POSIX: Use SHELL env var or bash as default shell = process.env.SHELL || '/bin/bash'; shellArgs = ['-c', commandToExecute]; } // Create PTY process const ptyProcess = pty.spawn(shell, shellArgs, { name: options.ptyTerm || 'xterm-256color', cols: options.ptyCols || 120, rows: options.ptyRows || 30, cwd: process.cwd(), env: options.env || process.env, }); // Add to smartexit (wrap in a minimal object with pid) this.smartexit.addProcess({ pid: ptyProcess.pid } as any); // Handle output (stdout and stderr are combined in PTY) ptyProcess.onData((data: string) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } shellLogInstance.addToBuffer(data); }); // Wrap PTY termination into a Promise const childProcessEnded: Promise = new Promise((resolve, reject) => { ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => { this.smartexit.removeProcess({ pid: ptyProcess.pid } as any); const execResult: IExecResult = { exitCode: exitCode ?? (signal ? 1 : 0), stdout: shellLogInstance.logStore.toString(), }; if (options.strict && exitCode !== 0) { reject(new Error(`Command "${options.commandString}" exited with code ${exitCode}`)); } else { resolve(execResult); } }); }); // Create input methods for PTY const sendInput = async (input: string): Promise => { return new Promise((resolve, reject) => { try { ptyProcess.write(input); resolve(); } catch (error) { reject(error); } }); }; const sendLine = async (line: string): Promise => { // Use \r for PTY (carriage return is typical for terminal line discipline) return sendInput(line + '\r'); }; const endInput = (): void => { // Send EOF (Ctrl+D) to PTY ptyProcess.write('\x04'); }; // If interactive control is enabled but not streaming, return interactive interface if (options.interactiveControl && !options.streaming) { return { exitCode: 0, // Will be updated when process ends stdout: '', // Will be updated when process ends sendInput, sendLine, endInput, finalPromise: childProcessEnded, } as IExecResultInteractive; } // If streaming mode is enabled, return a streaming interface if (options.streaming) { return { childProcess: { pid: ptyProcess.pid } as any, // Minimal compatibility object finalPromise: childProcessEnded, sendInput, sendLine, endInput, kill: async () => { if (options.debug) { console.log(`[smartshell] Killing PTY process ${ptyProcess.pid}`); } ptyProcess.kill(); }, terminate: async () => { if (options.debug) { console.log(`[smartshell] Terminating PTY process ${ptyProcess.pid}`); } ptyProcess.kill('SIGTERM'); }, keyboardInterrupt: async () => { if (options.debug) { console.log(`[smartshell] Sending SIGINT to PTY process ${ptyProcess.pid}`); } ptyProcess.kill('SIGINT'); }, customSignal: async (signal: plugins.smartexit.TProcessSignal) => { if (options.debug) { console.log(`[smartshell] Sending ${signal} to PTY process ${ptyProcess.pid}`); } ptyProcess.kill(signal as any); }, } as IExecResultStreaming; } // For non-streaming mode, wait for the process to complete return await childProcessEnded; } }