From 529b33fda180f214845fa5ed625bc30bd01b588b Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 17 Aug 2025 15:20:26 +0000 Subject: [PATCH] feat(smartshell): Add secure spawn APIs, PTY support, interactive/streaming control, timeouts and buffer limits; update README and tests --- .../document_symbols_cache_v23-06-25.pkl | Bin 88853 -> 112052 bytes changelog.md | 12 + readme.md | 972 +++++++----------- test/test.errorHandling.ts | 222 ++++ test/test.interactiveControl.ts | 84 ++ test/test.pty.ts | 146 +++ test/test.simpleInteractive.ts | 54 + test/test.spawn.ts | 150 +++ ts/00_commitinfo_data.ts | 2 +- ts/classes.smartshell.ts | 721 ++++++++++++- 10 files changed, 1748 insertions(+), 615 deletions(-) create mode 100644 test/test.errorHandling.ts create mode 100644 test/test.interactiveControl.ts create mode 100644 test/test.pty.ts create mode 100644 test/test.simpleInteractive.ts create mode 100644 test/test.spawn.ts diff --git a/.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl b/.serena/cache/typescript/document_symbols_cache_v23-06-25.pkl index 7ab121dcfc0675ff84d0c4778d8ce72e198b738e..1e4cb669dfbc745fe11c9acf975d9bcbe3825a9b 100644 GIT binary patch literal 112052 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 literal 88853 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 bvM~^RcRu9yBd+6^;5xMbXyegFeenMS4|4`P diff --git a/changelog.md b/changelog.md index e8619f7..b648f2b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,17 @@ # Changelog +## 2025-08-17 - 3.3.0 - feat(smartshell) +Add secure spawn APIs, PTY support, interactive/streaming control, timeouts and buffer limits; update README and tests + +- Introduce execSpawn family (execSpawn, execSpawnStreaming, execSpawnInteractiveControl) for shell-free, secure execution of untrusted input (shell:false). +- Add PTY support (optional node-pty) with execInteractiveControlPty and execStreamingInteractiveControlPty; PTY is lazy-loaded and documented as an optional dependency. +- Expose interactive control primitives (sendInput, sendLine, endInput, finalPromise) for both spawn and shell-based executions, and streaming interfaces with process control (terminate, kill, keyboardInterrupt, customSignal). +- Implement timeouts, maxBuffer limits and onData callbacks to prevent OOM, stream output incrementally, and support early termination and debugging logs. +- Improve process lifecycle handling: safe unpipe/unpipe-on-error, smartexit integration, and safer signal-based tree-kill behavior. +- Enhance execAndWaitForLine with timeout and terminateOnMatch options to allow pattern-based waits with configurable behavior. +- Update README with a Security Guide recommending execSpawn for untrusted input, PTY usage guidance, and new feature documentation (timeouts, buffer limits, debug mode, environment control). +- Add extensive tests covering error handling, interactive control, passthrough, PTY behavior, spawn behavior, silent/streaming modes and environment propagation. + ## 2025-08-16 - 3.2.4 - fix(tests) Update tests & CI config, bump deps, add docs and project configs diff --git a/readme.md b/readme.md index d598fe4..d86f31c 100644 --- a/readme.md +++ b/readme.md @@ -4,6 +4,10 @@ [![npm version](https://img.shields.io/npm/v/@push.rocks/smartshell.svg)](https://www.npmjs.com/package/@push.rocks/smartshell) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +## ⚠️ Security Notice + +**IMPORTANT:** Please read the [Security Guide](#security-guide) below for critical information about command execution and input handling. Always use `execSpawn` methods for untrusted input. + ## Why smartshell? 🚀 Tired of wrestling with Node.js child processes? Meet `@push.rocks/smartshell` - your promise-based shell command companion that makes executing system commands feel like a breeze. Whether you're building automation scripts, CI/CD pipelines, or need fine-grained control over shell execution, smartshell has got you covered. @@ -13,10 +17,15 @@ Tired of wrestling with Node.js child processes? Meet `@push.rocks/smartshell` - - 🎯 **Promise-based API** - Async/await ready for modern codebases - 🔇 **Silent execution modes** - Control output verbosity - 📡 **Streaming support** - Real-time output for long-running processes -- 🎮 **Interactive commands** - Handle user input when needed +- 🎮 **Interactive commands** - Handle user input programmatically +- 🛡️ **Secure execution** - Shell-free methods for untrusted input - ⚡ **Smart execution modes** - Strict, silent, or streaming - 🔍 **Pattern matching** - Wait for specific output patterns - 🌍 **Environment management** - Custom env vars and PATH handling +- 💾 **Memory protection** - Built-in buffer limits prevent OOM +- ⏱️ **Timeout support** - Automatic process termination +- 🖥️ **PTY support** - Full terminal emulation (optional) +- 🎨 **Cross-platform** - Windows, macOS, and Linux ready - 🛡️ **TypeScript first** - Full type safety and IntelliSense ## Installation 📦 @@ -30,6 +39,9 @@ yarn add @push.rocks/smartshell # Using pnpm (recommended) pnpm add @push.rocks/smartshell + +# Optional: For PTY support (terminal emulation) +pnpm add --save-optional node-pty ``` ## Quick Start 🏃‍♂️ @@ -45,6 +57,256 @@ const shell = new Smartshell({ // Run a simple command const result = await shell.exec('echo "Hello, World!"'); console.log(result.stdout); // "Hello, World!" +console.log(result.signal); // undefined (no signal) +console.log(result.stderr); // "" (no errors) +``` + +## Security-First Execution 🔒 + +### Secure Command Execution with execSpawn + +When dealing with untrusted input, **always use execSpawn methods** which don't use shell interpretation: + +```typescript +// ❌ DANGEROUS with untrusted input +const userInput = "file.txt; rm -rf /"; +await shell.exec(`cat ${userInput}`); // Command injection! + +// ✅ SAFE with untrusted input +await shell.execSpawn('cat', [userInput]); // Arguments are properly escaped +``` + +### execSpawn Family Methods + +```typescript +// Basic secure execution +const result = await shell.execSpawn('ls', ['-la', '/home']); + +// Streaming secure execution +const streaming = await shell.execSpawnStreaming('npm', ['install']); +await streaming.finalPromise; + +// Interactive secure execution +const interactive = await shell.execSpawnInteractiveControl('cat', []); +await interactive.sendLine('Hello'); +interactive.endInput(); +await interactive.finalPromise; +``` + +## Production Features 🏭 + +### Resource Management + +Prevent memory issues with built-in buffer limits: + +```typescript +const result = await shell.exec('large-output-command', { + maxBuffer: 10 * 1024 * 1024, // 10MB limit + onData: (chunk) => { + // Process chunks as they arrive + console.log('Received:', chunk.toString()); + } +}); +``` + +### Timeout Support + +Automatically terminate long-running processes: + +```typescript +try { + const result = await shell.execSpawn('long-process', [], { + timeout: 5000 // 5 second timeout + }); +} catch (error) { + console.log('Process timed out'); +} +``` + +### Debug Mode + +Enable detailed logging for troubleshooting: + +```typescript +const result = await shell.exec('command', { + debug: true // Logs process lifecycle events +}); +``` + +### Custom Environment + +Control the execution environment precisely: + +```typescript +const result = await shell.execSpawn('node', ['script.js'], { + env: { + NODE_ENV: 'production', + PATH: '/usr/bin:/bin', + CUSTOM_VAR: 'value' + } +}); +``` + +## Interactive Control 🎮 + +### Programmatic Input Control + +Send input to processes programmatically: + +```typescript +const interactive = await shell.execInteractiveControl('cat'); + +// Send input line by line +await interactive.sendLine('Line 1'); +await interactive.sendLine('Line 2'); + +// Send raw input without newline +await interactive.sendInput('partial'); + +// Close stdin +interactive.endInput(); + +// Wait for completion +const result = await interactive.finalPromise; +``` + +### Passthrough Mode + +Connect stdin for real keyboard interaction: + +```typescript +// User can type directly +await shell.execPassthrough('vim file.txt'); +``` + +## PTY Support - Full Terminal Emulation 🖥️ + +Smartshell provides two modes for executing interactive commands: + +1. **Pipe Mode (Default)** - Fast, simple, no dependencies +2. **PTY Mode** - Full terminal emulation for advanced interactive programs + +### When to Use Each Mode + +#### Use Pipe Mode (Default) When: +- Running simple commands that read from stdin +- Using tools like `cat`, `grep`, `sed`, `awk` +- Running basic scripts that don't need terminal features +- You want maximum performance and simplicity +- You don't want native dependencies + +#### Use PTY Mode When: +- Running commands that require a real terminal: + - Password prompts (`sudo`, `ssh`, `su`) + - Interactive editors (`vim`, `nano`, `emacs`) + - Terminal UIs (`htop`, `less`, `more`) + - Programs with fancy prompts (`bash read -p`) + - Tab completion and readline features +- You need terminal features: + - ANSI colors and escape sequences + - Terminal size control + - Signal handling (Ctrl+C, Ctrl+Z) + - Line discipline and special key handling + +### Installing PTY Support + +PTY support requires the optional `node-pty` dependency: + +```bash +# Install as optional dependency +pnpm add --save-optional node-pty + +# Note: node-pty requires compilation and has platform-specific requirements +# - On Windows: Requires Visual Studio Build Tools +# - On macOS/Linux: Requires Python and build tools +``` + +### PTY Usage Examples + +```typescript +// Use PTY for commands that need terminal features +const ptyInteractive = await shell.execInteractiveControlPty( + "bash -c 'read -p \"Enter name: \" name && echo \"Hello, $name\"'" +); +await ptyInteractive.sendLine('John'); +const result = await ptyInteractive.finalPromise; +// With PTY, the prompt "Enter name: " will be visible in stdout + +// Streaming with PTY for real-time interaction +const ptyStreaming = await shell.execStreamingInteractiveControlPty('vim test.txt'); +await ptyStreaming.sendInput('i'); // Enter insert mode +await ptyStreaming.sendInput('Hello from PTY!'); +await ptyStreaming.sendInput('\x1b'); // ESC key +await ptyStreaming.sendInput(':wq\r'); // Save and quit +``` + +### PTY vs Pipe Mode Comparison + +| Feature | Pipe Mode | PTY Mode | +|---------|-----------|----------| +| Dependencies | None | node-pty | +| Terminal Detection | `isatty()` returns false | `isatty()` returns true | +| Prompt Display | May not show | Always shows | +| Colors | Often disabled | Enabled | +| Signal Handling | Basic | Full (Ctrl+C, Ctrl+Z, etc.) | +| Line Ending | `\n` | `\r` (carriage return) | +| EOF Signal | Stream end | `\x04` (Ctrl+D) | + +### Common PTY Patterns + +```typescript +// Password input (PTY required) +const sudo = await shell.execInteractiveControlPty('sudo ls /root'); +await sudo.sendLine('mypassword'); +const result = await sudo.finalPromise; + +// Interactive REPL with colors +const node = await shell.execStreamingInteractiveControlPty('node'); +await node.sendLine('console.log("PTY supports colors!")'); +await node.sendLine('.exit'); + +// Handling terminal colors +const ls = await shell.execInteractiveControlPty('ls --color=always'); +const result = await ls.finalPromise; +// result.stdout will contain ANSI color codes +``` + +### PTY Fallback Strategy + +Always provide a fallback for when PTY isn't available: + +```typescript +try { + // Try PTY mode first + const result = await shell.execInteractiveControlPty(command); + // ... +} catch (error) { + if (error.message.includes('node-pty')) { + // Fallback to pipe mode + console.warn('PTY not available, using pipe mode'); + const result = await shell.execInteractiveControl(command); + // ... + } +} +``` + +## Advanced Pattern Matching 🔍 + +### Enhanced execAndWaitForLine + +Wait for patterns with timeout and auto-termination: + +```typescript +// Wait with timeout +await shell.execAndWaitForLine( + 'npm start', + /Server listening on port/, + false, // silent + { + timeout: 30000, // 30 second timeout + terminateOnMatch: true // Kill process after match + } +); ``` ## Core Concepts 💡 @@ -70,6 +332,8 @@ Perfect for general commands where you want to see the output: const result = await shell.exec('ls -la'); console.log(result.stdout); // Directory listing console.log(result.exitCode); // 0 for success +console.log(result.signal); // Signal if terminated +console.log(result.stderr); // Error output ``` ### Silent Execution @@ -80,19 +344,8 @@ Run commands without printing to console - ideal for capturing output: const result = await shell.execSilent('cat /etc/hostname'); // Output is NOT printed to console but IS captured in result console.log(result.stdout); // Access the captured output here -console.log(result.exitCode); // Check exit code (0 = success) - -// Example: Process output programmatically -const files = await shell.execSilent('ls -la'); -const fileList = files.stdout.split(' -'); -fileList.forEach(file => { - // Process each file entry -}); ``` -**Key Point:** Silent methods (`execSilent`, `execStrictSilent`, `execStreamingSilent`) suppress console output but still capture everything in the result object for programmatic access. - ### Strict Execution Throws an error if the command fails - great for critical operations: @@ -102,7 +355,8 @@ try { await shell.execStrict('critical-command'); console.log('✅ Command succeeded!'); } catch (error) { - console.error('❌ Command failed:', error); + console.error('❌ Command failed:', error.message); + // Error includes exit code or signal information } ``` @@ -118,100 +372,15 @@ streaming.childProcess.stdout.on('data', (chunk) => { console.log('📦 Installing:', chunk.toString()); }); +// Control the process +await streaming.terminate(); // SIGTERM +await streaming.kill(); // SIGKILL +await streaming.keyboardInterrupt(); // SIGINT + // Wait for completion await streaming.finalPromise; ``` -### Interactive Execution - -When commands need user input: - -```typescript -// This will connect to your terminal for input -await shell.execInteractive('npm init'); -``` - -## Advanced Features 🔥 - -### Wait for Specific Output - -Perfect for waiting on services to start: - -```typescript -// Wait for a specific line before continuing -await shell.execAndWaitForLine( - 'npm run dev', - /Server started on port 3000/ -); -console.log('🚀 Server is ready!'); -``` - -### Silent Pattern Waiting - -Same as above, but without console output: - -```typescript -await shell.execAndWaitForLineSilent( - 'docker-compose up', - /database system is ready to accept connections/ -); -// The command output is suppressed from console but the pattern matching still works -``` - -### Environment Customization - -Smartshell provides powerful environment management: - -```typescript -// Add custom source files -shell.shellEnv.addSourceFiles([ - '/home/user/.custom_env', - './project.env.sh' -]); - -// Modify PATH -shell.shellEnv.pathDirArray.push('/custom/bin'); -shell.shellEnv.pathDirArray.push('/usr/local/special'); - -// Your custom environment is ready -const result = await shell.exec('my-custom-command'); -``` - -### Smart Execution Utility - -The `SmartExecution` class enables restartable streaming processes: - -```typescript -import { SmartExecution } from '@push.rocks/smartshell'; - -const execution = new SmartExecution(shell, 'npm run watch'); - -// Restart the process whenever needed -await execution.restart(); - -// Access the current streaming execution -if (execution.currentStreamingExecution) { - execution.currentStreamingExecution.childProcess.stdout.on('data', (data) => { - console.log(data.toString()); - }); -} -``` - -### Shell Detection - -Need to check if a command exists? We export the `which` utility: - -```typescript -import { which } from '@push.rocks/smartshell'; - -try { - const gitPath = await which('git'); - console.log(`Git found at: ${gitPath}`); -} catch (error) { - console.log('Git is not installed'); -} -``` - ## Real-World Examples 🌍 ### Build Pipeline @@ -232,17 +401,20 @@ await shell.execStrict('npm test'); console.log('✅ Build pipeline completed successfully'); ``` -### Development Server with Auto-Restart +### CI/CD with Security ```typescript -const shell = new Smartshell({ executor: 'bash' }); -const devServer = new SmartExecution(shell, 'npm run dev'); - -// Watch for file changes and restart -fs.watch('./src', async () => { - console.log('🔄 Changes detected, restarting...'); - await devServer.restart(); -}); +async function deployApp(branch: string, untrustedTag: string) { + const shell = new Smartshell({ executor: 'bash' }); + + // Use execSpawn for untrusted input + await shell.execSpawnStrict('git', ['checkout', branch]); + await shell.execSpawnStrict('git', ['tag', untrustedTag]); + + // Safe to use exec for hardcoded commands + await shell.execStrict('npm run build'); + await shell.execStrict('npm run deploy'); +} ``` ### Docker Compose Helper @@ -255,7 +427,11 @@ console.log('🐳 Starting Docker services...'); await shell.execAndWaitForLine( 'docker-compose up', /All services are ready/, - { timeout: 60000 } + false, + { + timeout: 60000, + terminateOnMatch: false // Keep running after match + } ); // Run migrations @@ -263,309 +439,147 @@ await shell.execStrict('docker-compose exec app npm run migrate'); console.log('✅ Environment ready!'); ``` -### CI/CD Integration +### Development Server with Auto-Restart ```typescript -const shell = new Smartshell({ executor: 'bash' }); +import { SmartExecution } from '@push.rocks/smartshell'; -async function runCIPipeline() { - // Install dependencies - await shell.execStrict('pnpm install --frozen-lockfile'); - - // Run linting - const lintResult = await shell.execSilent('npm run lint'); - if (lintResult.exitCode !== 0) { - throw new Error(`Linting failed: -${lintResult.stdout}`); - } - - // Run tests with coverage - const testResult = await shell.exec('npm run test:coverage'); - - // Build project - await shell.execStrict('npm run build'); - - // Deploy if on main branch - if (process.env.BRANCH === 'main') { - await shell.execStrict('npm run deploy'); - } -} +const shell = new Smartshell({ executor: 'bash' }); +const devServer = new SmartExecution(shell, 'npm run dev'); + +// Watch for file changes and restart +fs.watch('./src', async () => { + console.log('🔄 Changes detected, restarting...'); + await devServer.restart(); +}); ``` ## API Reference 📚 ### Smartshell Class -| Method | Description | Returns | -|--------|-------------|---------| -| `exec(command)` | Execute command with output | `Promise` | -| `execSilent(command)` | Execute without console output | `Promise` | -| `execStrict(command)` | Execute, throw on failure | `Promise` | -| `execStrictSilent(command)` | Strict + silent execution | `Promise` | -| `execStreaming(command)` | Stream output in real-time | `Promise` | -| `execStreamingSilent(command)` | Stream without console output | `Promise` | -| `execInteractive(command)` | Interactive terminal mode | `Promise` | -| `execAndWaitForLine(command, regex)` | Wait for pattern match | `Promise` | -| `execAndWaitForLineSilent(command, regex)` | Silent pattern waiting | `Promise` | +| Method | Description | Security | +|--------|-------------|----------| +| `exec(command)` | Execute with shell | ⚠️ Vulnerable to injection | +| `execSpawn(cmd, args)` | Execute without shell | ✅ Safe for untrusted input | +| `execSilent(command)` | Execute without console output | ⚠️ Vulnerable to injection | +| `execStrict(command)` | Execute, throw on failure | ⚠️ Vulnerable to injection | +| `execStreaming(command)` | Stream output in real-time | ⚠️ Vulnerable to injection | +| `execSpawnStreaming(cmd, args)` | Stream without shell | ✅ Safe for untrusted input | +| `execInteractive(command)` | Interactive terminal mode | ⚠️ Vulnerable to injection | +| `execInteractiveControl(command)` | Programmatic input control | ⚠️ Vulnerable to injection | +| `execSpawnInteractiveControl(cmd, args)` | Programmatic control without shell | ✅ Safe for untrusted input | +| `execPassthrough(command)` | Connect stdin passthrough | ⚠️ Vulnerable to injection | +| `execInteractiveControlPty(command)` | PTY with programmatic control | ⚠️ Vulnerable to injection | +| `execStreamingInteractiveControlPty(command)` | PTY streaming with control | ⚠️ Vulnerable to injection | +| `execAndWaitForLine(cmd, regex, silent, opts)` | Wait for pattern match | ⚠️ Vulnerable to injection | ### Result Interfaces ```typescript interface IExecResult { - exitCode: number; // Process exit code - stdout: string; // Standard output + exitCode: number; // Process exit code + stdout: string; // Standard output + signal?: NodeJS.Signals; // Termination signal + stderr?: string; // Error output } interface IExecResultStreaming { childProcess: ChildProcess; // Node.js ChildProcess instance - finalPromise: Promise; // Resolves when process exits + finalPromise: Promise; // Resolves when process exits + sendInput: (input: string) => Promise; + sendLine: (line: string) => Promise; + endInput: () => void; + kill: () => Promise; + terminate: () => Promise; + keyboardInterrupt: () => Promise; + customSignal: (signal: NodeJS.Signals) => Promise; +} + +interface IExecResultInteractive extends IExecResult { + sendInput: (input: string) => Promise; + sendLine: (line: string) => Promise; + endInput: () => void; + finalPromise: Promise; } ``` -## Understanding Silent Modes 🤫 - -Silent execution modes are perfect when you need to capture command output for processing without cluttering the console. Here's what you need to know: - -### How Silent Modes Work - -1. **Output is captured, not lost**: All stdout content is stored in the result object -2. **Console stays clean**: Nothing is printed during execution -3. **Full programmatic access**: Process the output however you need - -### Available Silent Methods +### Options Interface ```typescript -// Basic silent execution -const result = await shell.execSilent('ls -la'); -console.log(result.stdout); // Access captured output -console.log(result.exitCode); // Check success/failure - -// Strict + Silent (throws on error) -try { - const result = await shell.execStrictSilent('important-command'); - const output = result.stdout; // Process the output -} catch (error) { - // Handle failure +interface IExecOptions { + silent?: boolean; // Suppress console output + strict?: boolean; // Throw on non-zero exit + streaming?: boolean; // Return streaming interface + interactive?: boolean; // Interactive mode + passthrough?: boolean; // Connect stdin + interactiveControl?: boolean; // Programmatic input + usePty?: boolean; // Use pseudo-terminal + ptyShell?: string; // Custom PTY shell + ptyCols?: number; // PTY columns (default 120) + ptyRows?: number; // PTY rows (default 30) + ptyTerm?: string; // Terminal type (default 'xterm-256color') + maxBuffer?: number; // Max output buffer (bytes) + onData?: (chunk: Buffer | string) => void; // Data callback + timeout?: number; // Execution timeout (ms) + debug?: boolean; // Enable debug logging + env?: NodeJS.ProcessEnv; // Custom environment + signal?: AbortSignal; // Abort signal } - -// Streaming + Silent -const streaming = await shell.execStreamingSilent('long-running-process'); -streaming.childProcess.stdout.on('data', (chunk) => { - // Process chunks as they arrive - const data = chunk.toString(); -}); - -// Pattern matching + Silent -await shell.execAndWaitForLineSilent('server-start', /Ready on port/); ``` -### Common Use Cases for Silent Execution +## Security Guide + +### Command Injection Prevention + +The standard `exec` methods use `shell: true`, which can lead to command injection vulnerabilities: ```typescript -// Parse JSON output -const jsonResult = await shell.execSilent('aws s3 ls --output json'); -const buckets = JSON.parse(jsonResult.stdout); +// ❌ DANGEROUS - Never do this with untrusted input +const userInput = "file.txt; rm -rf /"; +await smartshell.exec(`cat ${userInput}`); // Will execute rm -rf / -// Count lines -const wcResult = await shell.execSilent('wc -l huge-file.txt'); -const lineCount = parseInt(wcResult.stdout.split(' ')[0]); +// ✅ SAFE - Arguments are properly escaped +await smartshell.execSpawn('cat', [userInput]); // Will look for literal filename +``` -// Check if command exists -const whichResult = await shell.execSilent('which docker'); -const dockerPath = whichResult.exitCode === 0 ? whichResult.stdout.trim() : null; +### Best Practices -// Collect system info -const unameResult = await shell.execSilent('uname -a'); -const systemInfo = unameResult.stdout.trim(); +1. **Always validate input**: Even with secure methods, validate user input +2. **Use execSpawn for untrusted data**: Never pass user input to shell-based methods +3. **Set resource limits**: Use `maxBuffer` and `timeout` for untrusted commands +4. **Control environment**: Don't inherit all env vars for sensitive operations +5. **Restrict signals**: Only allow specific signals from user input + +### Path Traversal Protection + +```typescript +// ❌ VULNERABLE +const file = userInput; // Could be "../../../etc/passwd" +await shell.exec(`cat ${file}`); + +// ✅ SECURE +const path = require('path'); +const safePath = path.join('/allowed/directory', path.basename(userInput)); +await shell.execSpawn('cat', [safePath]); ``` ## Tips & Best Practices 💎 -1. **Choose the right executor**: Use `bash` for full features, `sh` for minimal overhead -2. **Use strict mode for critical operations**: Ensures failures don't go unnoticed -3. **Stream long-running processes**: Better UX and memory efficiency -4. **Leverage silent modes**: When you only need to capture output -5. **Handle errors gracefully**: Always wrap strict executions in try-catch -6. **Clean up resources**: Streaming processes should be properly terminated +1. **Security first**: Use `execSpawn` for any untrusted input +2. **Set resource limits**: Always use `maxBuffer` and `timeout` for untrusted commands +3. **Choose the right executor**: Use `bash` for full features, `sh` for minimal overhead +4. **Use strict mode for critical operations**: Ensures failures don't go unnoticed +5. **Stream long-running processes**: Better UX and memory efficiency +6. **Leverage silent modes**: When you only need to capture output +7. **Handle errors gracefully**: Check both `exitCode` and `signal` +8. **Clean up resources**: Streaming processes should be properly terminated +9. **Control environment**: Don't inherit all env vars for sensitive operations +10. **Enable debug mode**: For development and troubleshooting +11. **Use PTY for terminal UIs**: When programs need real terminal features +12. **Provide fallbacks**: Always handle PTY unavailability gracefully -## License and Legal Information - -This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. - -**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file. - -### Trademarks - -This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH. - -### Company Information - -Task Venture Capital GmbH -Registered at District court Bremen HRB 35230 HB, Germany - -For any legal inquiries or if you require further information, please contact us via email at hello@task.vc. - -By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.# @push.rocks/smartshell 🐚 -**Execute shell commands with superpowers in Node.js** - -[![npm version](https://img.shields.io/npm/v/@push.rocks/smartshell.svg)](https://www.npmjs.com/package/@push.rocks/smartshell) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -## Why smartshell? 🚀 - -Tired of wrestling with Node.js child processes? Meet `@push.rocks/smartshell` - your promise-based shell command companion that makes executing system commands feel like a breeze. Whether you're building automation scripts, CI/CD pipelines, or need fine-grained control over shell execution, smartshell has got you covered. - -### ✨ Key Features - -- 🎯 **Promise-based API** - Async/await ready for modern codebases -- 🔇 **Silent execution modes** - Control output verbosity -- 📡 **Streaming support** - Real-time output for long-running processes -- 🎮 **Interactive commands** - Handle user input when needed -- ⚡ **Smart execution modes** - Strict, silent, or streaming -- 🔍 **Pattern matching** - Wait for specific output patterns -- 🌍 **Environment management** - Custom env vars and PATH handling -- 🛡️ **TypeScript first** - Full type safety and IntelliSense - -## Installation 📦 - -```bash -# Using npm -npm install @push.rocks/smartshell --save - -# Using yarn -yarn add @push.rocks/smartshell - -# Using pnpm (recommended) -pnpm add @push.rocks/smartshell -``` - -## Quick Start 🏃‍♂️ - -```typescript -import { Smartshell } from '@push.rocks/smartshell'; - -// Create your shell instance -const shell = new Smartshell({ - executor: 'bash' // or 'sh' for lighter shells -}); - -// Run a simple command -const result = await shell.exec('echo "Hello, World!"'); -console.log(result.stdout); // "Hello, World!" -``` - -## Core Concepts 💡 - -### The Smartshell Instance - -The heart of smartshell is the `Smartshell` class. Each instance maintains its own environment and configuration: - -```typescript -const shell = new Smartshell({ - executor: 'bash', // Choose your shell: 'bash' or 'sh' - sourceFilePaths: ['/path/to/env.sh'], // Optional: source files on init -}); -``` - -## Execution Modes 🎛️ - -### Standard Execution - -Perfect for general commands where you want to see the output: - -```typescript -const result = await shell.exec('ls -la'); -console.log(result.stdout); // Directory listing -console.log(result.exitCode); // 0 for success -``` - -### Silent Execution - -Run commands without printing to console - ideal for capturing output: - -```typescript -const result = await shell.execSilent('cat /etc/hostname'); -// Output is NOT printed to console but IS captured in result -console.log(result.stdout); // Access the captured output here -console.log(result.exitCode); // Check exit code (0 = success) - -// Example: Process output programmatically -const files = await shell.execSilent('ls -la'); -const fileList = files.stdout.split(' -'); -fileList.forEach(file => { - // Process each file entry -}); -``` - -**Key Point:** Silent methods (`execSilent`, `execStrictSilent`, `execStreamingSilent`) suppress console output but still capture everything in the result object for programmatic access. - -### Strict Execution - -Throws an error if the command fails - great for critical operations: - -```typescript -try { - await shell.execStrict('critical-command'); - console.log('✅ Command succeeded!'); -} catch (error) { - console.error('❌ Command failed:', error); -} -``` - -### Streaming Execution - -For long-running processes or when you need real-time output: - -```typescript -const streaming = await shell.execStreaming('npm install'); - -// Access the child process directly -streaming.childProcess.stdout.on('data', (chunk) => { - console.log('📦 Installing:', chunk.toString()); -}); - -// Wait for completion -await streaming.finalPromise; -``` - -### Interactive Execution - -When commands need user input: - -```typescript -// This will connect to your terminal for input -await shell.execInteractive('npm init'); -``` - -## Advanced Features 🔥 - -### Wait for Specific Output - -Perfect for waiting on services to start: - -```typescript -// Wait for a specific line before continuing -await shell.execAndWaitForLine( - 'npm run dev', - /Server started on port 3000/ -); -console.log('🚀 Server is ready!'); -``` - -### Silent Pattern Waiting - -Same as above, but without console output: - -```typescript -await shell.execAndWaitForLineSilent( - 'docker-compose up', - /database system is ready to accept connections/ -); -// The command output is suppressed from console but the pattern matching still works -``` - -### Environment Customization +## Environment Customization Smartshell provides powerful environment management: @@ -584,27 +598,7 @@ shell.shellEnv.pathDirArray.push('/usr/local/special'); const result = await shell.exec('my-custom-command'); ``` -### Smart Execution Utility - -The `SmartExecution` class enables restartable streaming processes: - -```typescript -import { SmartExecution } from '@push.rocks/smartshell'; - -const execution = new SmartExecution(shell, 'npm run watch'); - -// Restart the process whenever needed -await execution.restart(); - -// Access the current streaming execution -if (execution.currentStreamingExecution) { - execution.currentStreamingExecution.childProcess.stdout.on('data', (data) => { - console.log(data.toString()); - }); -} -``` - -### Shell Detection +## Shell Detection Need to check if a command exists? We export the `which` utility: @@ -619,182 +613,6 @@ try { } ``` -## Real-World Examples 🌍 - -### Build Pipeline - -```typescript -const shell = new Smartshell({ executor: 'bash' }); - -// Clean build directory -await shell.execSilent('rm -rf dist'); - -// Run TypeScript compiler -const buildResult = await shell.execStrict('tsc'); - -// Run tests -await shell.execStrict('npm test'); - -// Build succeeded! -console.log('✅ Build pipeline completed successfully'); -``` - -### Development Server with Auto-Restart - -```typescript -const shell = new Smartshell({ executor: 'bash' }); -const devServer = new SmartExecution(shell, 'npm run dev'); - -// Watch for file changes and restart -fs.watch('./src', async () => { - console.log('🔄 Changes detected, restarting...'); - await devServer.restart(); -}); -``` - -### Docker Compose Helper - -```typescript -const shell = new Smartshell({ executor: 'bash' }); - -// Start services and wait for readiness -console.log('🐳 Starting Docker services...'); -await shell.execAndWaitForLine( - 'docker-compose up', - /All services are ready/, - { timeout: 60000 } -); - -// Run migrations -await shell.execStrict('docker-compose exec app npm run migrate'); -console.log('✅ Environment ready!'); -``` - -### CI/CD Integration - -```typescript -const shell = new Smartshell({ executor: 'bash' }); - -async function runCIPipeline() { - // Install dependencies - await shell.execStrict('pnpm install --frozen-lockfile'); - - // Run linting - const lintResult = await shell.execSilent('npm run lint'); - if (lintResult.exitCode !== 0) { - throw new Error(`Linting failed: -${lintResult.stdout}`); - } - - // Run tests with coverage - const testResult = await shell.exec('npm run test:coverage'); - - // Build project - await shell.execStrict('npm run build'); - - // Deploy if on main branch - if (process.env.BRANCH === 'main') { - await shell.execStrict('npm run deploy'); - } -} -``` - -## API Reference 📚 - -### Smartshell Class - -| Method | Description | Returns | -|--------|-------------|---------| -| `exec(command)` | Execute command with output | `Promise` | -| `execSilent(command)` | Execute without console output | `Promise` | -| `execStrict(command)` | Execute, throw on failure | `Promise` | -| `execStrictSilent(command)` | Strict + silent execution | `Promise` | -| `execStreaming(command)` | Stream output in real-time | `Promise` | -| `execStreamingSilent(command)` | Stream without console output | `Promise` | -| `execInteractive(command)` | Interactive terminal mode | `Promise` | -| `execAndWaitForLine(command, regex)` | Wait for pattern match | `Promise` | -| `execAndWaitForLineSilent(command, regex)` | Silent pattern waiting | `Promise` | - -### Result Interfaces - -```typescript -interface IExecResult { - exitCode: number; // Process exit code - stdout: string; // Standard output -} - -interface IExecResultStreaming { - childProcess: ChildProcess; // Node.js ChildProcess instance - finalPromise: Promise; // Resolves when process exits -} -``` - -## Understanding Silent Modes 🤫 - -Silent execution modes are perfect when you need to capture command output for processing without cluttering the console. Here's what you need to know: - -### How Silent Modes Work - -1. **Output is captured, not lost**: All stdout content is stored in the result object -2. **Console stays clean**: Nothing is printed during execution -3. **Full programmatic access**: Process the output however you need - -### Available Silent Methods - -```typescript -// Basic silent execution -const result = await shell.execSilent('ls -la'); -console.log(result.stdout); // Access captured output -console.log(result.exitCode); // Check success/failure - -// Strict + Silent (throws on error) -try { - const result = await shell.execStrictSilent('important-command'); - const output = result.stdout; // Process the output -} catch (error) { - // Handle failure -} - -// Streaming + Silent -const streaming = await shell.execStreamingSilent('long-running-process'); -streaming.childProcess.stdout.on('data', (chunk) => { - // Process chunks as they arrive - const data = chunk.toString(); -}); - -// Pattern matching + Silent -await shell.execAndWaitForLineSilent('server-start', /Ready on port/); -``` - -### Common Use Cases for Silent Execution - -```typescript -// Parse JSON output -const jsonResult = await shell.execSilent('aws s3 ls --output json'); -const buckets = JSON.parse(jsonResult.stdout); - -// Count lines -const wcResult = await shell.execSilent('wc -l huge-file.txt'); -const lineCount = parseInt(wcResult.stdout.split(' ')[0]); - -// Check if command exists -const whichResult = await shell.execSilent('which docker'); -const dockerPath = whichResult.exitCode === 0 ? whichResult.stdout.trim() : null; - -// Collect system info -const unameResult = await shell.execSilent('uname -a'); -const systemInfo = unameResult.stdout.trim(); -``` - -## Tips & Best Practices 💎 - -1. **Choose the right executor**: Use `bash` for full features, `sh` for minimal overhead -2. **Use strict mode for critical operations**: Ensures failures don't go unnoticed -3. **Stream long-running processes**: Better UX and memory efficiency -4. **Leverage silent modes**: When you only need to capture output -5. **Handle errors gracefully**: Always wrap strict executions in try-catch -6. **Clean up resources**: Streaming processes should be properly terminated - ## License and Legal Information This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. diff --git a/test/test.errorHandling.ts b/test/test.errorHandling.ts new file mode 100644 index 0000000..d53a3d5 --- /dev/null +++ b/test/test.errorHandling.ts @@ -0,0 +1,222 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartshell from '../ts/index.js'; + +tap.test('should handle EPIPE errors gracefully', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const streaming = await testSmartshell.execStreamingInteractiveControl('head -n 1'); + + // Send more data after head exits (will cause EPIPE) + await streaming.sendLine('Line 1'); + + // This should not throw even though head has exited + let errorThrown = false; + try { + await streaming.sendLine('Line 2'); + await streaming.sendLine('Line 3'); + } catch (error) { + errorThrown = true; + // EPIPE or destroyed stdin is expected + } + + const result = await streaming.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Line 1'); +}); + +tap.test('should handle strict mode with non-zero exit codes', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + let errorThrown = false; + let errorMessage = ''; + + try { + await testSmartshell.execStrict('exit 42'); + } catch (error) { + errorThrown = true; + errorMessage = error.message; + } + + expect(errorThrown).toBeTrue(); + expect(errorMessage).toContain('exited with code 42'); +}); + +tap.test('should handle strict mode with signal termination', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + let errorThrown = false; + let errorMessage = ''; + + try { + // Use execSpawn with strict mode and kill it + const result = testSmartshell.execSpawn('sleep', ['10'], { + strict: true, + timeout: 100 // Will cause SIGTERM + }); + await result; + } catch (error) { + errorThrown = true; + errorMessage = error.message; + } + + expect(errorThrown).toBeTrue(); + expect(errorMessage).toContain('terminated by signal'); +}); + +tap.test('execAndWaitForLine with timeout should reject properly', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + let errorThrown = false; + let errorMessage = ''; + + try { + await testSmartshell.execAndWaitForLine( + 'sleep 5 && echo "Too late"', + /Too late/, + false, + { timeout: 100 } + ); + } catch (error) { + errorThrown = true; + errorMessage = error.message; + } + + expect(errorThrown).toBeTrue(); + expect(errorMessage).toContain('Timeout waiting for pattern'); +}); + +tap.test('execAndWaitForLine with terminateOnMatch should stop process', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const start = Date.now(); + await testSmartshell.execAndWaitForLine( + 'echo "Match this" && sleep 5', + /Match this/, + false, + { terminateOnMatch: true } + ); + const duration = Date.now() - start; + + // Should terminate immediately after match, not wait for sleep + expect(duration).toBeLessThan(2000); +}); + +tap.test('should handle process ending without matching pattern', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + let errorThrown = false; + let errorMessage = ''; + + try { + await testSmartshell.execAndWaitForLine( + 'echo "Wrong text"', + /Never appears/, + false + ); + } catch (error) { + errorThrown = true; + errorMessage = error.message; + } + + expect(errorThrown).toBeTrue(); + expect(errorMessage).toContain('Process ended without matching pattern'); +}); + +tap.test('passthrough unpipe should handle destroyed stdin gracefully', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // This should complete without throwing even if stdin operations fail + const result = await testSmartshell.execPassthrough('echo "Test passthrough unpipe"'); + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Test passthrough unpipe'); +}); + +tap.test('should handle write after stream destroyed', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const interactive = await testSmartshell.execInteractiveControl('true'); // Exits immediately + + // Wait for process to exit + await interactive.finalPromise; + + // Try to write after process has exited + let errorThrown = false; + try { + await interactive.sendLine('This should fail'); + } catch (error) { + errorThrown = true; + expect(error.message).toContain('destroyed'); + } + + expect(errorThrown).toBeTrue(); +}); + +tap.test('debug mode should log additional information', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Capture console.log output + const originalLog = console.log; + let debugOutput = ''; + console.log = (msg: string) => { + debugOutput += msg + '\n'; + }; + + try { + const streaming = await testSmartshell.execSpawnStreaming('echo', ['Debug test'], { + debug: true + }); + await streaming.terminate(); + await streaming.finalPromise; + } finally { + console.log = originalLog; + } + + // Should have logged debug messages + expect(debugOutput).toContain('[smartshell]'); +}); + +tap.test('custom environment variables should be passed correctly', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const result = await testSmartshell.execSpawn('bash', ['-c', 'echo $CUSTOM_VAR'], { + env: { + ...process.env, + CUSTOM_VAR: 'test_value_123' + } + }); + + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('test_value_123'); +}); + +export default tap.start(); \ No newline at end of file diff --git a/test/test.interactiveControl.ts b/test/test.interactiveControl.ts new file mode 100644 index 0000000..544b54c --- /dev/null +++ b/test/test.interactiveControl.ts @@ -0,0 +1,84 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartshell from '../ts/index.js'; + +tap.test('should handle programmatic input control with simple commands', async (tools) => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Use cat which works well with pipe mode + const interactive = await testSmartshell.execInteractiveControl('cat'); + + // Send input programmatically + await interactive.sendLine('TestUser'); + interactive.endInput(); + + // Wait for completion + const result = await interactive.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('TestUser'); +}); + +tap.test('should handle streaming interactive control with cat', async (tools) => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Use cat for reliable pipe mode operation + const streaming = await testSmartshell.execStreamingInteractiveControl('cat'); + + // Send multiple inputs + await streaming.sendLine('One'); + await streaming.sendLine('Two'); + streaming.endInput(); + + const result = await streaming.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('One'); + expect(result.stdout).toContain('Two'); +}); + +tap.test('should handle sendInput without newline', async (tools) => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Use cat for testing input without newline + const interactive = await testSmartshell.execInteractiveControl('cat'); + + // Send characters without newline, then newline, then EOF + await interactive.sendInput('ABC'); + await interactive.sendInput('DEF'); + await interactive.sendInput('\n'); + interactive.endInput(); + + const result = await interactive.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('ABCDEF'); +}); + +tap.test('should mix passthrough and interactive control modes', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Test that passthrough still works + const passthroughResult = await testSmartshell.execPassthrough('echo "Passthrough works"'); + expect(passthroughResult.exitCode).toEqual(0); + expect(passthroughResult.stdout).toContain('Passthrough works'); + + // Test that interactive control works + const interactiveResult = await testSmartshell.execInteractiveControl('echo "Interactive control works"'); + const finalResult = await interactiveResult.finalPromise; + expect(finalResult.exitCode).toEqual(0); + expect(finalResult.stdout).toContain('Interactive control works'); +}); + +// Note: Tests requiring bash read with prompts should use PTY mode +// See test.pty.ts for examples of testing commands that require terminal features + +export default tap.start(); \ No newline at end of file diff --git a/test/test.pty.ts b/test/test.pty.ts new file mode 100644 index 0000000..e497eae --- /dev/null +++ b/test/test.pty.ts @@ -0,0 +1,146 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartshell from '../ts/index.js'; + +// Helper to check if node-pty is available +const isPtyAvailable = async (): Promise => { + try { + await import('node-pty'); + return true; + } catch { + return false; + } +}; + +tap.test('PTY: should handle bash read with prompts correctly', async (tools) => { + const ptyAvailable = await isPtyAvailable(); + if (!ptyAvailable) { + console.log('Skipping PTY test - node-pty not installed'); + return; + } + + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // This test should work with PTY (bash read with prompt) + const interactive = await testSmartshell.execInteractiveControlPty("bash -c 'read -p \"Enter name: \" name && echo \"Hello, $name\"'"); + + // Send input programmatically + await interactive.sendLine('TestUser'); + + // Wait for completion + const result = await interactive.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Enter name:'); // Prompt should be visible with PTY + expect(result.stdout).toContain('Hello, TestUser'); +}); + +tap.test('PTY: should handle terminal colors and escape sequences', async (tools) => { + const ptyAvailable = await isPtyAvailable(); + if (!ptyAvailable) { + console.log('Skipping PTY test - node-pty not installed'); + return; + } + + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // ls --color=auto should produce colors in PTY mode + const result = await testSmartshell.execInteractiveControlPty('ls --color=always /tmp'); + const finalResult = await result.finalPromise; + + expect(finalResult.exitCode).toEqual(0); + // Check for ANSI escape sequences (colors) in output + const hasColors = /\x1b\[[0-9;]*m/.test(finalResult.stdout); + expect(hasColors).toEqual(true); +}); + +tap.test('PTY: should handle interactive password prompt simulation', async (tools) => { + const ptyAvailable = await isPtyAvailable(); + if (!ptyAvailable) { + console.log('Skipping PTY test - node-pty not installed'); + return; + } + + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Simulate a password prompt scenario + const interactive = await testSmartshell.execStreamingInteractiveControlPty( + "bash -c 'read -s -p \"Password: \" pass && echo && echo \"Got password of length ${#pass}\"'" + ); + + await tools.delayFor(100); + await interactive.sendLine('secretpass'); + + const result = await interactive.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Password:'); + expect(result.stdout).toContain('Got password of length 10'); +}); + +tap.test('PTY: should handle terminal size options', async (tools) => { + const ptyAvailable = await isPtyAvailable(); + if (!ptyAvailable) { + console.log('Skipping PTY test - node-pty not installed'); + return; + } + + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Check terminal size using stty + const result = await testSmartshell.execInteractiveControlPty('stty size'); + const finalResult = await result.finalPromise; + + expect(finalResult.exitCode).toEqual(0); + // Default size should be 30 rows x 120 cols as set in _execCommandPty + expect(finalResult.stdout).toContain('30 120'); +}); + +tap.test('PTY: should handle Ctrl+C (SIGINT) properly', async (tools) => { + const ptyAvailable = await isPtyAvailable(); + if (!ptyAvailable) { + console.log('Skipping PTY test - node-pty not installed'); + return; + } + + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Start a long-running process + const streaming = await testSmartshell.execStreamingInteractiveControlPty('sleep 10'); + + // Send interrupt after a short delay + await tools.delayFor(100); + await streaming.keyboardInterrupt(); + + const result = await streaming.finalPromise; + // Process should exit with non-zero code due to interrupt + expect(result.exitCode).not.toEqual(0); +}); + +tap.test('Regular pipe mode should still work alongside PTY', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Regular mode should work without PTY + const interactive = await testSmartshell.execInteractiveControl('echo "Pipe mode works"'); + const result = await interactive.finalPromise; + + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Pipe mode works'); +}); + +export default tap.start(); \ No newline at end of file diff --git a/test/test.simpleInteractive.ts b/test/test.simpleInteractive.ts new file mode 100644 index 0000000..58d9dc8 --- /dev/null +++ b/test/test.simpleInteractive.ts @@ -0,0 +1,54 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartshell from '../ts/index.js'; + +tap.test('should send input to cat command', async (tools) => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Use cat which simply echoes what it receives + const interactive = await testSmartshell.execInteractiveControl('cat'); + + // Send some text and close stdin + await interactive.sendLine('Hello World'); + interactive.endInput(); // Close stdin properly + + const result = await interactive.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Hello World'); +}); + +tap.test('should work with simple echo', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // This should work without any input + const interactive = await testSmartshell.execInteractiveControl('echo "Direct test"'); + const result = await interactive.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Direct test'); +}); + +tap.test('should handle streaming with input control', async (tools) => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Test with streaming and cat + const streaming = await testSmartshell.execStreamingInteractiveControl('cat'); + + await streaming.sendLine('Line 1'); + await streaming.sendLine('Line 2'); + streaming.endInput(); // Close stdin + + const result = await streaming.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Line 1'); + expect(result.stdout).toContain('Line 2'); +}); + +export default tap.start(); \ No newline at end of file diff --git a/test/test.spawn.ts b/test/test.spawn.ts new file mode 100644 index 0000000..7f71ed5 --- /dev/null +++ b/test/test.spawn.ts @@ -0,0 +1,150 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartshell from '../ts/index.js'; + +tap.test('execSpawn should execute commands with args array (shell:false)', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Test basic command with args + const result = await testSmartshell.execSpawn('echo', ['Hello', 'World']); + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Hello World'); +}); + +tap.test('execSpawn should handle command not found errors', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + let errorThrown = false; + try { + await testSmartshell.execSpawn('nonexistentcommand123', ['arg1']); + } catch (error) { + errorThrown = true; + expect(error.code).toEqual('ENOENT'); + } + expect(errorThrown).toBeTrue(); +}); + +tap.test('execSpawn should properly escape arguments', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Test that shell metacharacters are treated as literals + const result = await testSmartshell.execSpawn('echo', ['$HOME', '&&', 'ls']); + expect(result.exitCode).toEqual(0); + // Should output literal strings, not expanded/executed + expect(result.stdout).toContain('$HOME && ls'); +}); + +tap.test('execSpawn streaming should work', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const streaming = await testSmartshell.execSpawnStreaming('echo', ['Streaming test']); + const result = await streaming.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Streaming test'); +}); + +tap.test('execSpawn interactive control should work', async (tools) => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const interactive = await testSmartshell.execSpawnInteractiveControl('cat', []); + + await interactive.sendLine('Input line'); + interactive.endInput(); + + const result = await interactive.finalPromise; + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('Input line'); +}); + +tap.test('execSpawn should capture stderr', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // ls on non-existent directory should produce stderr + const result = await testSmartshell.execSpawn('ls', ['/nonexistent/directory/path']); + expect(result.exitCode).not.toEqual(0); + expect(result.stderr).toBeTruthy(); + expect(result.stderr).toContain('No such file or directory'); +}); + +tap.test('execSpawn with timeout should terminate process', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const start = Date.now(); + const result = await testSmartshell.execSpawn('sleep', ['10'], { timeout: 100 }); + const duration = Date.now() - start; + + // Process should be terminated by timeout + expect(duration).toBeLessThan(500); + expect(result.exitCode).not.toEqual(0); + expect(result.signal).toBeTruthy(); // Should have been killed by signal +}); + +tap.test('execSpawn with maxBuffer should truncate output', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + // Generate large output + const result = await testSmartshell.execSpawn('bash', ['-c', 'for i in {1..1000}; do echo "Line $i with some padding text to make it longer"; done'], { + maxBuffer: 1024, // Very small buffer + }); + + expect(result.exitCode).toEqual(0); + expect(result.stdout).toContain('[Output truncated - exceeded maxBuffer]'); +}); + +tap.test('execSpawn with onData callback should stream data', async (tools) => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + let dataReceived = ''; + const result = await testSmartshell.execSpawn('echo', ['Test data'], { + onData: (chunk) => { + dataReceived += chunk.toString(); + } + }); + + expect(result.exitCode).toEqual(0); + expect(dataReceived).toContain('Test data'); +}); + +tap.test('execSpawn with signal should report signal in result', async () => { + const testSmartshell = new smartshell.Smartshell({ + executor: 'bash', + sourceFilePaths: [], + }); + + const streaming = await testSmartshell.execSpawnStreaming('sleep', ['10']); + + // Send SIGTERM after a short delay + setTimeout(() => streaming.terminate(), 100); + + const result = await streaming.finalPromise; + expect(result.exitCode).not.toEqual(0); + expect(result.signal).toEqual('SIGTERM'); +}); + +export default tap.start(); \ No newline at end of file diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 8525d05..f5da19c 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartshell', - version: '3.2.4', + version: '3.3.0', description: 'A library for executing shell commands using promises.' } diff --git a/ts/classes.smartshell.ts b/ts/classes.smartshell.ts index a3c5c45..e1af848 100644 --- a/ts/classes.smartshell.ts +++ b/ts/classes.smartshell.ts @@ -8,6 +8,15 @@ import * as cp from 'child_process'; 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 { @@ -17,6 +26,9 @@ export interface IExecResultStreaming { terminate: () => Promise; keyboardInterrupt: () => Promise; customSignal: (signal: plugins.smartexit.TProcessSignal) => Promise; + sendInput: (input: string) => Promise; + sendLine: (line: string) => Promise; + endInput: () => void; } interface IExecOptions { @@ -26,6 +38,23 @@ interface IExecOptions { 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 { @@ -73,92 +102,441 @@ export class Smartshell { } /** - * Executes a command and returns either a non-streaming result or a streaming interface. + * Executes a command with args array (shell:false) for security */ - private async _execCommand(options: IExecOptions): Promise { - const commandToExecute = this.shellEnv.createEnvExecString(options.commandString); + 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(commandToExecute, [], { - shell: true, + const execChildProcess = cp.spawn(options.command, options.args || [], { + shell: false, // SECURITY: Never use shell with untrusted input cwd: process.cwd(), - env: process.env, + env: options.env || process.env, detached: false, + signal: options.signal, }); this.smartexit.addProcess(execChildProcess); - // Connect stdin if passthrough is enabled - if (options.passthrough && execChildProcess.stdin) { + // 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); } - // Capture stdout and stderr output. + // 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); } - shellLogInstance.addToBuffer(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); } - shellLogInstance.addToBuffer(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. + // Wrap child process termination into a Promise const childProcessEnded: Promise = new Promise((resolve, reject) => { - execChildProcess.on('exit', (code, signal) => { + const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } this.smartexit.removeProcess(execChildProcess); - // Unpipe stdin when process ends if passthrough was enabled - if (options.passthrough) { - process.stdin.unpipe(execChildProcess.stdin); + // 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: typeof code === 'number' ? code : (signal ? 1 : 0), + exitCode, stdout: shellLogInstance.logStore.toString(), + signal: signal || undefined, + stderr: stderrBuffer, }; - if (options.strict && code !== 0) { - reject(new Error(`Command "${options.commandString}" exited with code ${code}`)); + 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.on('error', (error) => { + execChildProcess.once('exit', handleExit); + execChildProcess.once('error', (error) => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } this.smartexit.removeProcess(execChildProcess); - // Unpipe stdin when process errors if passthrough was enabled - if (options.passthrough && execChildProcess.stdin) { - process.stdin.unpipe(execChildProcess.stdin); + + // 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 streaming mode is enabled, return a streaming interface immediately. + // 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 () => { - console.log(`Running tree kill with SIGKILL on process ${execChildProcess.pid}`); + 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 () => { - console.log(`Running tree kill with SIGTERM on process ${execChildProcess.pid}`); + 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 () => { - console.log(`Running tree kill with SIGINT on process ${execChildProcess.pid}`); + 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) => { - console.log(`Running tree kill with custom signal ${signal} on process ${execChildProcess.pid}`); + 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; @@ -169,7 +547,9 @@ export class Smartshell { } public async exec(commandString: string): Promise { - return (await this._exec({ commandString })) as IExecResult; + 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 { @@ -204,23 +584,290 @@ export class Smartshell { 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 + silent: boolean = false, + options: { timeout?: number; terminateOnMatch?: boolean } = {} ): Promise { const execStreamingResult = await this.execStreaming(commandString, silent); - return new Promise((resolve) => { - execStreamingResult.childProcess.stdout.on('data', (chunk: Buffer | string) => { + + 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 (regex.test(data)) { + 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): Promise { - return this.execAndWaitForLine(commandString, regex, true); + public async execAndWaitForLineSilent(commandString: string, regex: RegExp, options?: { timeout?: number; terminateOnMatch?: boolean }): Promise { + return this.execAndWaitForLine(commandString, regex, true, options); } -} \ No newline at end of file + + 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; + } +}