640 lines
19 KiB
Markdown
640 lines
19 KiB
Markdown
# @push.rocks/smartshell
|
|
|
|
`@push.rocks/smartshell` is a TypeScript-first Node.js library for running shell commands with modern async APIs. It wraps `child_process` in promises, adds strict and silent execution modes, supports streaming and programmatic stdin control, exposes safer shell-free spawn methods for untrusted arguments, and gives you practical process controls like timeouts, process-tree termination, custom environments, working directories, and optional PTY-backed terminal emulation.
|
|
|
|
## Issue Reporting and Security
|
|
|
|
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
|
|
|
## Install
|
|
|
|
```bash
|
|
pnpm add @push.rocks/smartshell
|
|
```
|
|
|
|
PTY support is optional and only needed when a command requires a real terminal:
|
|
|
|
```bash
|
|
pnpm add --save-optional node-pty
|
|
```
|
|
|
|
## Quick Start
|
|
|
|
```typescript
|
|
import { Smartshell } from '@push.rocks/smartshell';
|
|
|
|
const shell = new Smartshell({
|
|
executor: 'bash',
|
|
});
|
|
|
|
const result = await shell.exec('echo "hello from smartshell"');
|
|
|
|
console.log(result.exitCode); // 0
|
|
console.log(result.stdout); // hello from smartshell
|
|
```
|
|
|
|
Use `execSpawn()` whenever command arguments include user input or any other untrusted value:
|
|
|
|
```typescript
|
|
const filenameFromUser = 'report.txt; rm -rf /';
|
|
|
|
// Safe: no shell is used, so metacharacters are treated as argument text.
|
|
const result = await shell.execSpawn('cat', [filenameFromUser], {
|
|
silent: true,
|
|
});
|
|
```
|
|
|
|
## What It Does
|
|
|
|
- Promise-based command execution for `async`/`await` code.
|
|
- Shell-based methods for trusted command strings.
|
|
- Shell-free spawn methods for safer argument handling.
|
|
- Silent, strict, streaming, passthrough, and interactive-control modes.
|
|
- Process-tree cleanup through `@push.rocks/smartexit`.
|
|
- Timeout support for terminating long-running process trees.
|
|
- `cwd`, `env`, and `AbortSignal` support.
|
|
- Bounded output buffering with `maxBuffer`.
|
|
- Separate `stderr` capture plus the historical combined output buffer.
|
|
- Optional PTY support through `node-pty` for terminal-native programs.
|
|
- A small `SmartExecution` helper for restarting long-running commands.
|
|
- The `which` utility re-exported for command discovery.
|
|
|
|
## Execution Modes
|
|
|
|
### Standard Execution
|
|
|
|
`exec()` runs a trusted command string through the configured shell and resolves with an `IExecResult`.
|
|
|
|
```typescript
|
|
const result = await shell.exec('git --version');
|
|
|
|
console.log(result.exitCode);
|
|
console.log(result.stdout);
|
|
```
|
|
|
|
Shell execution is convenient for hardcoded command strings, pipes, redirects, globbing, and shell syntax:
|
|
|
|
```typescript
|
|
await shell.exec('mkdir -p dist && cp assets/*.json dist/');
|
|
```
|
|
|
|
### Silent Execution
|
|
|
|
`execSilent()` captures output without writing it to the current process stdout.
|
|
|
|
```typescript
|
|
const result = await shell.execSilent('node --version');
|
|
console.log(result.stdout.trim());
|
|
```
|
|
|
|
### Strict Execution
|
|
|
|
`execStrict()` rejects with `SmartshellError` when the command exits with a non-zero code or is terminated by a signal.
|
|
|
|
```typescript
|
|
import { SmartshellError } from '@push.rocks/smartshell';
|
|
|
|
try {
|
|
await shell.execStrict('pnpm test');
|
|
} catch (error) {
|
|
if (error instanceof SmartshellError) {
|
|
console.error(error.message);
|
|
console.error(error.exitCode);
|
|
console.error(error.stderr);
|
|
}
|
|
}
|
|
```
|
|
|
|
Use `execStrictSilent()` for strict behavior without console output:
|
|
|
|
```typescript
|
|
await shell.execStrictSilent('pnpm build');
|
|
```
|
|
|
|
### Streaming Execution
|
|
|
|
`execStreaming()` starts a command and returns immediately with the child process, a final result promise, and process-control helpers.
|
|
|
|
```typescript
|
|
const streaming = await shell.execStreaming('pnpm install');
|
|
|
|
streaming.childProcess.stdout?.on('data', (chunk) => {
|
|
process.stdout.write(`[install] ${chunk}`);
|
|
});
|
|
|
|
const result = await streaming.finalPromise;
|
|
console.log(result.exitCode);
|
|
```
|
|
|
|
Streaming executions can be controlled explicitly:
|
|
|
|
```typescript
|
|
const server = await shell.execStreaming('pnpm dev', false, {
|
|
cwd: '/path/to/app',
|
|
});
|
|
|
|
await server.keyboardInterrupt(); // SIGINT
|
|
await server.terminate(); // SIGTERM
|
|
await server.kill(); // SIGKILL
|
|
await server.customSignal('SIGHUP');
|
|
```
|
|
|
|
`execStreamingSilent()` starts a streaming command without printing output automatically.
|
|
|
|
### Passthrough Execution
|
|
|
|
`execPassthrough()` pipes the current process stdin into the command. This is useful for commands that should receive real user input while still returning a result.
|
|
|
|
```typescript
|
|
await shell.execPassthrough('read name && echo "hello $name"');
|
|
```
|
|
|
|
`execStreamingPassthrough()` combines passthrough stdin with the streaming interface.
|
|
|
|
### Programmatic Input Control
|
|
|
|
`execInteractiveControl()` returns methods for sending stdin manually.
|
|
|
|
```typescript
|
|
const interactive = await shell.execInteractiveControl('cat');
|
|
|
|
await interactive.sendLine('first line');
|
|
await interactive.sendInput('second line without newline');
|
|
await interactive.sendInput('\n');
|
|
interactive.endInput();
|
|
|
|
const result = await interactive.finalPromise;
|
|
console.log(result.stdout);
|
|
```
|
|
|
|
`execStreamingInteractiveControl()` gives you both programmatic input and streaming process controls:
|
|
|
|
```typescript
|
|
const repl = await shell.execStreamingInteractiveControl('node');
|
|
|
|
await repl.sendLine('console.log(21 * 2)');
|
|
await repl.sendLine('.exit');
|
|
|
|
await repl.finalPromise;
|
|
```
|
|
|
|
### Interactive Shell Execution
|
|
|
|
`execInteractive()` runs a trusted shell command with inherited stdio. It is meant for fully interactive terminal use and returns `void`; in CI environments it intentionally does nothing.
|
|
|
|
```typescript
|
|
await shell.execInteractive('vim readme.md');
|
|
```
|
|
|
|
## Secure Spawn APIs
|
|
|
|
The `execSpawn()` family uses `child_process.spawn()` with `shell: false`. That means shell metacharacters are not interpreted.
|
|
|
|
```typescript
|
|
const result = await shell.execSpawn('git', ['status', '--short'], {
|
|
cwd: '/path/to/repo',
|
|
silent: true,
|
|
});
|
|
```
|
|
|
|
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
|
|
const userInput = 'file.txt; rm -rf /';
|
|
|
|
// Dangerous: shell syntax in userInput can be interpreted.
|
|
await shell.exec(`cat ${userInput}`);
|
|
|
|
// Safe: userInput is passed as one literal argument.
|
|
await shell.execSpawn('cat', [userInput]);
|
|
```
|
|
|
|
### Spawn Streaming
|
|
|
|
```typescript
|
|
const streaming = await shell.execSpawnStreaming('pnpm', ['test'], {
|
|
cwd: '/path/to/package',
|
|
});
|
|
|
|
const result = await streaming.finalPromise;
|
|
```
|
|
|
|
### Spawn Interactive Control
|
|
|
|
```typescript
|
|
const interactive = await shell.execSpawnInteractiveControl('cat', []);
|
|
|
|
await interactive.sendLine('hello');
|
|
interactive.endInput();
|
|
|
|
const result = await interactive.finalPromise;
|
|
```
|
|
|
|
PTY mode is currently available for shell-based interactive-control methods, not for `execSpawn()`.
|
|
|
|
## PTY Support
|
|
|
|
Some tools behave differently when they are connected to pipes instead of a real terminal. Editors, REPLs, password prompts, full-screen terminal UIs, readline prompts, and programs that depend on ANSI terminal behavior often need a PTY.
|
|
|
|
Install the optional dependency first:
|
|
|
|
```bash
|
|
pnpm add --save-optional node-pty
|
|
```
|
|
|
|
Then use the PTY-specific methods:
|
|
|
|
```typescript
|
|
const prompt = await shell.execInteractiveControlPty(
|
|
'bash -c \'read -p "Name: " name && echo "Hello $name"\''
|
|
);
|
|
|
|
await prompt.sendLine('Ada');
|
|
|
|
const result = await prompt.finalPromise;
|
|
console.log(result.stdout);
|
|
```
|
|
|
|
Streaming PTY control works the same way, with PTY-backed process controls:
|
|
|
|
```typescript
|
|
const nodeRepl = await shell.execStreamingInteractiveControlPty('node');
|
|
|
|
await nodeRepl.sendLine('console.log("PTY ready")');
|
|
await nodeRepl.sendLine('.exit');
|
|
|
|
await nodeRepl.finalPromise;
|
|
```
|
|
|
|
PTY output combines stdout and stderr because terminal sessions expose a single terminal data stream.
|
|
|
|
## Runtime Options
|
|
|
|
Most execution methods accept these options:
|
|
|
|
```typescript
|
|
interface TExecCommandOptions {
|
|
ptyCols?: number;
|
|
ptyRows?: number;
|
|
ptyTerm?: string;
|
|
ptyShell?: string;
|
|
maxBuffer?: number;
|
|
onData?: (chunk: Buffer | string) => void;
|
|
timeout?: number;
|
|
debug?: boolean;
|
|
env?: NodeJS.ProcessEnv;
|
|
cwd?: string;
|
|
signal?: AbortSignal;
|
|
}
|
|
```
|
|
|
|
### Working Directory
|
|
|
|
Prefer the `cwd` option over embedding `cd ... && ...` into command strings:
|
|
|
|
```typescript
|
|
await shell.execStrict('pnpm build', {
|
|
cwd: '/path/to/package',
|
|
});
|
|
|
|
await shell.execSpawn('git', ['status', '--short'], {
|
|
cwd: '/path/to/repo',
|
|
});
|
|
```
|
|
|
|
### Environment Variables
|
|
|
|
```typescript
|
|
await shell.execSpawn('node', ['server.js'], {
|
|
env: {
|
|
...process.env,
|
|
NODE_ENV: 'production',
|
|
PORT: '3000',
|
|
},
|
|
});
|
|
```
|
|
|
|
### Timeouts
|
|
|
|
Timeouts terminate the process tree with `SIGTERM`.
|
|
|
|
```typescript
|
|
const result = await shell.execSpawn('sleep', ['10'], {
|
|
timeout: 500,
|
|
});
|
|
|
|
console.log(result.signal); // SIGTERM on typical POSIX platforms
|
|
```
|
|
|
|
### Bounded Output Buffers
|
|
|
|
Regular shell and spawn executions keep a combined output buffer. `maxBuffer` limits that buffer and replaces it with a truncation marker if the limit is exceeded.
|
|
|
|
```typescript
|
|
const result = await shell.exec('long-running-output-command', {
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
onData: (chunk) => {
|
|
// Stream chunks elsewhere while smartshell protects its own buffer.
|
|
process.stdout.write(chunk);
|
|
},
|
|
});
|
|
```
|
|
|
|
### Debug Logging
|
|
|
|
```typescript
|
|
const streaming = await shell.execSpawnStreaming('sleep', ['30'], {
|
|
debug: true,
|
|
});
|
|
|
|
await streaming.terminate();
|
|
await streaming.finalPromise;
|
|
```
|
|
|
|
## Results and Errors
|
|
|
|
### `IExecResult`
|
|
|
|
```typescript
|
|
interface IExecResult {
|
|
exitCode: number;
|
|
stdout: string;
|
|
combinedOutput?: string;
|
|
signal?: NodeJS.Signals;
|
|
stderr?: string;
|
|
}
|
|
```
|
|
|
|
Important: `stdout` intentionally preserves smartshell's legacy behavior and contains the combined stdout/stderr buffer. Use `stderr` when you need stderr separately, and use `combinedOutput` when you want to make that combined-buffer behavior explicit in your own code.
|
|
|
|
### `SmartshellError`
|
|
|
|
Strict methods reject with `SmartshellError` and expose the command result details directly:
|
|
|
|
```typescript
|
|
class SmartshellError extends Error {
|
|
command: string;
|
|
result: IExecResult;
|
|
exitCode: number;
|
|
stdout: string;
|
|
combinedOutput?: string;
|
|
stderr?: string;
|
|
signal?: NodeJS.Signals;
|
|
}
|
|
```
|
|
|
|
### Streaming and Interactive Results
|
|
|
|
```typescript
|
|
interface IExecResultStreaming {
|
|
childProcess: import('child_process').ChildProcess;
|
|
finalPromise: Promise<IExecResult>;
|
|
kill: () => Promise<void>;
|
|
terminate: () => Promise<void>;
|
|
keyboardInterrupt: () => Promise<void>;
|
|
customSignal: (signal: string) => Promise<void>;
|
|
sendInput: (input: string) => Promise<void>;
|
|
sendLine: (line: string) => Promise<void>;
|
|
endInput: () => void;
|
|
}
|
|
|
|
interface IExecResultInteractive extends IExecResult {
|
|
sendInput: (input: string) => Promise<void>;
|
|
sendLine: (line: string) => Promise<void>;
|
|
endInput: () => void;
|
|
finalPromise: Promise<IExecResult>;
|
|
}
|
|
```
|
|
|
|
## Waiting for Output
|
|
|
|
`execAndWaitForLine()` starts a streaming command and resolves when stdout matches a regular expression.
|
|
|
|
```typescript
|
|
await shell.execAndWaitForLine(
|
|
'pnpm dev',
|
|
/Server listening on port 3000/,
|
|
false,
|
|
{
|
|
timeout: 30_000,
|
|
terminateOnMatch: true,
|
|
cwd: '/path/to/app',
|
|
}
|
|
);
|
|
```
|
|
|
|
Use `execAndWaitForLineSilent()` to suppress automatic output:
|
|
|
|
```typescript
|
|
await shell.execAndWaitForLineSilent('node server.js', /ready/, {
|
|
timeout: 10_000,
|
|
});
|
|
```
|
|
|
|
If the process ends before a match or the timeout expires, the promise rejects.
|
|
|
|
## Environment Customization
|
|
|
|
`ShellEnv` is available through each `Smartshell` instance as `shell.shellEnv`. It lets you source files and add PATH directories before shell-based command strings execute.
|
|
|
|
```typescript
|
|
const shell = new Smartshell({
|
|
executor: 'bash',
|
|
sourceFilePaths: ['/opt/project/env.sh'],
|
|
pathDirectories: ['/opt/project/bin'],
|
|
});
|
|
|
|
shell.shellEnv.addSourceFiles(['./local.env.sh']);
|
|
shell.shellEnv.pathDirArray.push('/custom/bin');
|
|
|
|
await shell.exec('my-tool --version');
|
|
```
|
|
|
|
`ShellEnv` also imports the current `PATH` and appends `SMARTSHELL_PATH` when that environment variable is set. On WSL it filters Windows path entries that commonly break POSIX shell execution.
|
|
|
|
## SmartExecution
|
|
|
|
`SmartExecution` is a tiny restart helper for long-running commands such as development servers. It lazily creates its own bash-backed `Smartshell` and keeps one streaming execution active.
|
|
|
|
```typescript
|
|
import { SmartExecution } from '@push.rocks/smartshell';
|
|
|
|
const devServer = new SmartExecution('pnpm dev');
|
|
|
|
await devServer.restart(); // starts the command
|
|
await devServer.restart(); // kills the current process tree and starts it again
|
|
```
|
|
|
|
If multiple restarts are requested while a restart is already in progress, they are collapsed into one additional restart.
|
|
|
|
## Command Discovery
|
|
|
|
The package re-exports `which` for checking whether an executable is available.
|
|
|
|
```typescript
|
|
import { which } from '@push.rocks/smartshell';
|
|
|
|
const gitPath = await which('git');
|
|
console.log(gitPath);
|
|
```
|
|
|
|
## API Overview
|
|
|
|
| API | Purpose | Shell interpretation |
|
|
| --- | --- | --- |
|
|
| `new Smartshell({ executor })` | Create an execution context using `bash` or `sh` | Depends on method |
|
|
| `exec(command, options?)` | Run a trusted shell command | Yes |
|
|
| `execSilent(command, options?)` | Run a trusted shell command without automatic output | Yes |
|
|
| `execStrict(command, options?)` | Reject on non-zero exit or signal | Yes |
|
|
| `execStrictSilent(command, options?)` | Strict and silent shell execution | Yes |
|
|
| `execStreaming(command, silent?, options?)` | Return streaming process controls | Yes |
|
|
| `execStreamingSilent(command, options?)` | Streaming shell execution without automatic output | Yes |
|
|
| `execInteractive(command, options?)` | Inherit stdio for fully interactive terminal use | Yes |
|
|
| `execPassthrough(command, options?)` | Pipe current stdin into the command | Yes |
|
|
| `execStreamingPassthrough(command, options?)` | Streaming plus stdin passthrough | Yes |
|
|
| `execInteractiveControl(command, options?)` | Send stdin programmatically | Yes |
|
|
| `execStreamingInteractiveControl(command, options?)` | Streaming plus programmatic stdin | Yes |
|
|
| `execInteractiveControlPty(command, options?)` | Programmatic stdin through a PTY | Yes |
|
|
| `execStreamingInteractiveControlPty(command, options?)` | Streaming PTY control | Yes |
|
|
| `execSpawn(command, args?, options?)` | Run an executable with literal args | No |
|
|
| `execSpawnStreaming(command, args?, options?)` | Streaming shell-free spawn | No |
|
|
| `execSpawnInteractiveControl(command, args?, options?)` | Programmatic stdin with shell-free spawn | No |
|
|
| `execAndWaitForLine(command, regex, silent?, options?)` | Resolve when stdout matches | Yes |
|
|
| `execAndWaitForLineSilent(command, regex, options?)` | Silent output wait | Yes |
|
|
| `new SmartExecution(command)` | Restartable streaming command helper | Yes |
|
|
| `which(command)` | Resolve executable path | No command execution |
|
|
|
|
## Security Guide
|
|
|
|
Command execution is powerful and dangerous when untrusted input is involved. The rule is simple: use shell-based APIs for trusted command strings, and use spawn APIs for untrusted arguments.
|
|
|
|
### Prefer Spawn for Untrusted Data
|
|
|
|
```typescript
|
|
// Do not do this with untrusted values.
|
|
await shell.exec(`git checkout ${branchFromRequest}`);
|
|
|
|
// Do this instead.
|
|
await shell.execSpawn('git', ['checkout', branchFromRequest]);
|
|
```
|
|
|
|
### Avoid Shell-Built Paths
|
|
|
|
```typescript
|
|
// Risky if pathFromUser contains shell syntax.
|
|
await shell.exec(`cat ${pathFromUser}`);
|
|
|
|
// Safer: validate the path and pass it as a literal argument.
|
|
await shell.execSpawn('cat', [pathFromUser]);
|
|
```
|
|
|
|
### Set Resource Limits
|
|
|
|
For user-triggered commands, set a timeout and a sensible buffer limit:
|
|
|
|
```typescript
|
|
await shell.execSpawn('convert', [inputPath, outputPath], {
|
|
timeout: 60_000,
|
|
maxBuffer: 20 * 1024 * 1024,
|
|
silent: true,
|
|
});
|
|
```
|
|
|
|
### Control the Environment
|
|
|
|
Pass an explicit `env` when secrets or inherited environment variables matter:
|
|
|
|
```typescript
|
|
await shell.execSpawn('node', ['worker.js'], {
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
NODE_ENV: 'production',
|
|
},
|
|
});
|
|
```
|
|
|
|
## Real-World Recipes
|
|
|
|
### Build Pipeline
|
|
|
|
```typescript
|
|
const shell = new Smartshell({ executor: 'bash' });
|
|
|
|
await shell.execStrict('rm -rf dist');
|
|
await shell.execStrict('pnpm build');
|
|
await shell.execStrict('pnpm test');
|
|
```
|
|
|
|
### Safe Git Automation
|
|
|
|
```typescript
|
|
async function checkoutAndTag(branch: string, tag: string) {
|
|
const shell = new Smartshell({ executor: 'bash' });
|
|
|
|
await shell.execSpawn('git', ['checkout', branch], { strict: true });
|
|
await shell.execSpawn('git', ['tag', tag], { strict: true });
|
|
}
|
|
```
|
|
|
|
### Wait for a Development Server
|
|
|
|
```typescript
|
|
const server = shell.execAndWaitForLine(
|
|
'pnpm dev',
|
|
/ready|listening/i,
|
|
false,
|
|
{
|
|
cwd: '/path/to/app',
|
|
timeout: 30_000,
|
|
}
|
|
);
|
|
|
|
await server;
|
|
```
|
|
|
|
### Restart on File Changes
|
|
|
|
```typescript
|
|
import { watch } from 'node:fs';
|
|
import { SmartExecution } from '@push.rocks/smartshell';
|
|
|
|
const execution = new SmartExecution('pnpm dev');
|
|
|
|
await execution.restart();
|
|
|
|
watch('./src', { recursive: true }, async () => {
|
|
await execution.restart();
|
|
});
|
|
```
|
|
|
|
## License and Legal Information
|
|
|
|
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
|
|
|
|
**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 or third parties, 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 or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
|
|
|
### Company Information
|
|
|
|
Task Venture Capital GmbH
|
|
Registered at District Court Bremen HRB 35230 HB, Germany
|
|
|
|
For any legal inquiries or 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.
|