diff --git a/changelog.md b/changelog.md index 56782df..672683f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## Pending + +### Features + +- Add inherited terminal stdio support to `execSpawn` for trusted interactive CLIs. + ## 2026-05-09 - 3.4.0 - feat(smartshell) add cwd-aware execution options, structured strict-mode errors, and safer process tree termination diff --git a/readme.md b/readme.md index a971872..79e5669 100644 --- a/readme.md +++ b/readme.md @@ -197,6 +197,16 @@ const result = await shell.execSpawn('git', ['status', '--short'], { }); ``` +For trusted interactive CLIs that need the real terminal while still avoiding shell parsing, pass `stdio: 'inherit'`: + +```typescript +await shell.execSpawn('opencode', ['run', '--dir', process.cwd(), prompt], { + stdio: 'inherit', +}); +``` + +Inherited stdio returns an `IExecResult` with the exit code, but stdout and stderr are not captured because the child process writes directly to the terminal. + ### Why Spawn Matters ```typescript diff --git a/test/test.spawn.ts b/test/test.spawn.ts index 8557407..009048a 100644 --- a/test/test.spawn.ts +++ b/test/test.spawn.ts @@ -80,6 +80,20 @@ tap.test('execSpawn should properly escape arguments', async () => { expect(result.stdout).toContain('$HOME && ls'); }); +tap.test('execSpawn should support inherited stdio for interactive CLIs', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const result = await testSmartshell.execSpawn('node', ['-e', 'console.log("inherited stdio works")'], { + stdio: 'inherit', + }); + expect(result.exitCode).toEqual(0); + expect(result.stdout).toEqual(''); + expect(result.stderr).toEqual(''); +}); + tap.test('execSpawn streaming should work', async () => { const testSmartshell = new smartshell.Smartshell({ executor: 'bash', diff --git a/ts/classes.smartshell.ts b/ts/classes.smartshell.ts index 7cc1e47..b5b541b 100644 --- a/ts/classes.smartshell.ts +++ b/ts/classes.smartshell.ts @@ -93,6 +93,11 @@ export interface ISpawnOptions extends IExecRuntimeOptions { passthrough?: boolean; interactiveControl?: boolean; usePty?: boolean; + /** + * When set to `inherit`, the child process uses the parent terminal directly. + * This is useful for trusted interactive CLIs while still avoiding shell parsing. + */ + stdio?: 'pipe' | 'inherit'; } export type TExecCommandOptions = IExecRuntimeOptions; @@ -155,17 +160,23 @@ export class Smartshell { let stderrBuffer = ''; const maxBuffer = options.maxBuffer || 200 * 1024 * 1024; // Default 200MB let bufferExceeded = false; + const inheritStdio = options.stdio === 'inherit'; // 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.'); } + if (inheritStdio && (options.streaming || options.interactiveControl)) { + throw new Error('execSpawn stdio: inherit cannot be combined with streaming or interactiveControl.'); + } + const execChildProcess = cp.spawn(options.command, options.args || [], { shell: false, // SECURITY: Never use shell with untrusted input cwd: options.cwd || process.cwd(), env: options.env || process.env, - detached: true, // Own process group — immune to terminal SIGINT, managed by smartexit + stdio: inheritStdio ? 'inherit' : 'pipe', + detached: inheritStdio ? false : true, // Inherited TTY needs normal terminal signal handling. signal: options.signal, }); @@ -183,7 +194,7 @@ export class Smartshell { } // Connect stdin if passthrough is enabled (but not for interactive control) - if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) { + if (!inheritStdio && options.passthrough && !options.interactiveControl && execChildProcess.stdin) { process.stdin.pipe(execChildProcess.stdin); } @@ -192,11 +203,12 @@ export class Smartshell { if (!execChildProcess.stdin) { throw new Error('stdin is not available for this process'); } - if (execChildProcess.stdin.destroyed || !execChildProcess.stdin.writable) { + const childStdin = execChildProcess.stdin; + if (childStdin.destroyed || !childStdin.writable) { throw new Error('stdin has been destroyed or is not writable'); } return new Promise((resolve, reject) => { - execChildProcess.stdin.write(input, 'utf8', (error) => { + childStdin.write(input, 'utf8', (error) => { if (error) { reject(error); } else { @@ -217,7 +229,7 @@ export class Smartshell { }; // Capture stdout and stderr output - execChildProcess.stdout.on('data', (data) => { + execChildProcess.stdout?.on('data', (data) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } @@ -235,7 +247,7 @@ export class Smartshell { } }); - execChildProcess.stderr.on('data', (data) => { + execChildProcess.stderr?.on('data', (data) => { if (!options.silent) { shellLogInstance.writeToConsole(data); } @@ -265,7 +277,7 @@ export class Smartshell { this.smartexit.removeProcess(execChildProcess); // Safely unpipe stdin when process ends if passthrough was enabled - if (options.passthrough && !options.interactiveControl) { + if (!inheritStdio && options.passthrough && !options.interactiveControl) { try { if (execChildProcess.stdin && !execChildProcess.stdin.destroyed) { process.stdin.unpipe(execChildProcess.stdin); @@ -302,7 +314,7 @@ export class Smartshell { this.smartexit.removeProcess(execChildProcess); // Safely unpipe stdin when process errors if passthrough was enabled - if (options.passthrough && !options.interactiveControl && execChildProcess.stdin) { + if (!inheritStdio && options.passthrough && !options.interactiveControl && execChildProcess.stdin) { try { if (!execChildProcess.stdin.destroyed) { process.stdin.unpipe(execChildProcess.stdin);