@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/. 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/ account to submit Pull Requests directly.

Install

pnpm add @push.rocks/smartshell

PTY support is optional and only needed when a command requires a real terminal:

pnpm add --save-optional node-pty

Quick Start

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:

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.
  • Bounded timeout termination with configurable graceful-to-forced escalation.
  • cwd, env, and AbortSignal support.
  • Bounded output buffering with maxBuffer.
  • Separate stdout and stderr capture plus Node-observed combinedOutput ordering.
  • 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.

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:

await shell.exec('mkdir -p dist && cp assets/*.json dist/');

Silent Execution

execSilent() captures output without writing it to the current process stdout.

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, is terminated by a signal, or reaches its configured timeout. A timed-out command remains a strict failure even if its SIGTERM handler exits with code 0.

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);
    console.error(error.timedOut);
    console.error(error.timeoutMs);
  }
}

Use execStrictSilent() for strict behavior without console output:

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.

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:

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.

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.

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:

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.

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.

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':

await shell.execSpawn('opencode', ['run', '--dir', process.cwd(), prompt], {
  stdio: 'inherit',
});

Inherited stdio returns an IExecResult with the exit code, but output cannot be captured because the child process writes directly to the terminal. Its stdout, stderr, and combinedOutput fields are therefore all empty strings.

Why Spawn Matters

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

const streaming = await shell.execSpawnStreaming('pnpm', ['test'], {
  cwd: '/path/to/package',
});

const result = await streaming.finalPromise;

Spawn Interactive Control

const interactive = await shell.execSpawnInteractiveControl('cat', []);

await interactive.sendLine('hello');
interactive.endInput();

const result = await interactive.finalPromise;

PTY mode supports both shell-command and direct argv-based interactive-control methods.

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:

pnpm add --save-optional node-pty

Then use the PTY-specific methods:

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:

const nodeRepl = await shell.execStreamingInteractiveControlPty('node');

await nodeRepl.sendLine('console.log("PTY ready")');
await nodeRepl.sendLine('.exit');

await nodeRepl.finalPromise;

For shell-free argv preservation, probe the optional peer and spawn directly:

await shell.ensurePtySupport();
const terminal = await shell.execSpawnStreamingInteractiveControlPty(
  '/bin/bash',
  ['-l'],
  {
    cwd: '/workspace/project',
    ptyCols: 120,
    ptyRows: 30,
    onData: (chunk) => renderTerminalChunk(chunk),
  },
);

await terminal.resize(160, 40);
await terminal.sendInput('pwd\r');

The direct argv API is always silent: output is available through onData and finalPromise, but is never mirrored to the parent process stdout.

Its options are intentionally limited to the direct PTY surface:

interface IPtyDirectSpawnOptions {
  ptyCols?: number;
  ptyRows?: number;
  ptyTerm?: string;
  maxBuffer?: number;
  onData?: (chunk: string) => void;
  timeout?: number;
  timeoutKillGraceMs?: number;
  debug?: boolean;
  env?: NodeJS.ProcessEnv;
  cwd?: string;
}

General execution options such as signal, ptyShell, silent, and strict are not supported by the direct argv API.

PTY output is a single terminal data stream rather than two pipes. PTY results therefore set stdout === combinedOutput and stderr === ''; timedOut still reports whether Smartshell initiated timeout termination. PTY timeouts signal the PTY root with SIGTERM and use timeoutKillGraceMs for a bounded direct SIGKILL escalation. maxBuffer bounds the one terminal stream, with truncation represented in stdout and combinedOutput while stderr stays empty. Streaming PTY execution exposes the real node-pty object as ptyProcess; it does not claim to be a Node.js ChildProcess. Smartshell does not promise process-tree semantics for PTY termination, and rejects an AbortSignal option before spawning because node-pty has no equivalent abort contract.

Runtime Options

Most execution methods accept these options:

interface IExecRuntimeOptions {
  ptyCols?: number;
  ptyRows?: number;
  ptyTerm?: string;
  ptyShell?: string;
  maxBuffer?: number;
  onData?: (chunk: Buffer | string) => void;
  timeout?: number;
  timeoutKillGraceMs?: number;
  debug?: boolean;
  env?: NodeJS.ProcessEnv;
  cwd?: string;
  signal?: AbortSignal;
}

type TExecCommandOptions = IExecRuntimeOptions;

Working Directory

Prefer the cwd option over embedding cd ... && ... into command strings:

await shell.execStrict('pnpm build', {
  cwd: '/path/to/package',
});

await shell.execSpawn('git', ['status', '--short'], {
  cwd: '/path/to/repo',
});

Environment Variables

await shell.execSpawn('node', ['server.js'], {
  env: {
    ...process.env,
    NODE_ENV: 'production',
    PORT: '3000',
  },
});

Timeouts

For piped shell and spawn execution on POSIX, reaching timeout atomically marks the result as timed out and sends SIGTERM to the detached process tree. If the owned process group remains after timeoutKillGraceMs, Smartshell sends SIGKILL even when the direct child exited during the grace period. The grace period defaults to 1_000 milliseconds. The configured termination window is therefore timeout + timeoutKillGraceMs; when descendants outlive the direct child, the result stays pending until bounded force escalation succeeds or its error is surfaced.

On Windows, @push.rocks/smartexit implements process-tree termination with taskkill /T /F, so the first timeout termination request is already forced. The POSIX graceful-then-forced tree guarantee applies only to piped shell and spawn execution, not inherited stdio or PTY execution.

const result = await shell.execSpawn('sleep', ['10'], {
  timeout: 500,
  timeoutKillGraceMs: 1_000,
});

console.log(result.timedOut); // true

timeout must be an integer between 1 and 2_147_483_647. timeoutKillGraceMs must be an integer between 0 and 2_147_483_647, so 0 requests immediate force escalation after the initial termination signal.

Aborting a piped execution through AbortSignal still rejects with Node's AbortError. Smartshell additionally applies the same bounded tree termination so a child that ignores the abort signal cannot retain the execution indefinitely. A tree-signal failure is surfaced instead of silently degrading to a root-only signal. Inherited stdio has only direct-child signal ownership, and Windows uses the immediate forced behavior described above.

Bounded Output Buffers

Piped shell and spawn executions use one aggregate maxBuffer byte budget across stdout and stderr. If the next chunk would exceed the budget, Smartshell stops retaining both streams and returns the exact marker [Output truncated: aggregate maxBuffer exceeded] in stdout, stderr, and combinedOutput. Streaming to the console and onData continues. maxBuffer must be a positive safe integer.

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

const streaming = await shell.execSpawnStreaming('sleep', ['30'], {
  debug: true,
});

await streaming.terminate();
await streaming.finalPromise;

Results and Errors

IExecResult

interface IExecResult {
  exitCode: number;
  stdout: string;
  stderr: string;
  combinedOutput: string;
  timedOut: boolean;
  signal?: NodeJS.Signals;
}

For piped execution, stdout contains only stdout and stderr contains only stderr. combinedOutput contains both streams in the order their chunks were observed by Node; separate OS pipes cannot provide a stronger global byte-write ordering guarantee. timedOut is true only when Smartshell's configured timeout fired.

SmartshellError

Strict methods reject with SmartshellError and expose the command result details directly:

class SmartshellError extends Error {
  command: string;
  result: IExecResult;
  exitCode: number;
  stdout: string;
  stderr: string;
  combinedOutput: string;
  timedOut: boolean;
  timeoutMs?: number;
  signal?: NodeJS.Signals;
}

timeoutMs contains the configured timeout when one was supplied, while timedOut distinguishes an actual timeout from another strict failure. The full result remains available through error.result.

Streaming and Interactive Results

interface IExecResultStreaming {
  childProcess: import('child_process').ChildProcess;
  finalPromise: Promise<IExecResult>;
  kill: () => Promise<void>;
  terminate: () => Promise<void>;
  keyboardInterrupt: () => Promise<void>;
  customSignal: (signal: import('@push.rocks/smartexit').TProcessSignal) => Promise<void>;
  sendInput: (input: string) => Promise<void>;
  sendLine: (line: string) => Promise<void>;
  endInput: () => void;
}

interface IPtyProcess {
  readonly pid: number;
  write(data: string | Buffer): void;
  kill(signal?: string): void;
  resize(cols: number, rows: number): void;
  onData(listener: (data: string) => void): { dispose: () => void };
  onExit(listener: (event: { exitCode: number; signal?: number }) => void): { dispose: () => void };
}

interface IExecResultPtyStreaming {
  ptyProcess: IPtyProcess;
  finalPromise: Promise<IExecResult>;
  kill: () => Promise<void>;
  terminate: () => Promise<void>;
  keyboardInterrupt: () => Promise<void>;
  customSignal: (signal: import('@push.rocks/smartexit').TProcessSignal) => Promise<void>;
  sendInput: (input: string) => Promise<void>;
  sendLine: (line: string) => Promise<void>;
  endInput: () => void;
  resize: (cols: number, rows: number) => Promise<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.

await shell.execAndWaitForLine(
  'pnpm dev',
  /Server listening on port 3000/,
  false,
  {
    timeout: 30_000,
    timeoutKillGraceMs: 1_000,
    terminateOnMatch: true,
    cwd: '/path/to/app',
  }
);

Use execAndWaitForLineSilent() to suppress automatic output:

await shell.execAndWaitForLineSilent('node server.js', /ready/, {
  timeout: 10_000,
});

If the process ends before a match or the timeout expires, the promise rejects. A wait timeout and terminateOnMatch both reuse Smartshell's owned-tree termination path: SIGTERM is followed by bounded SIGKILL escalation after timeoutKillGraceMs, and any termination failure rejects the helper promise.

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.

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.

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.

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 timeout, non-zero exit, or signal Yes
execStrictSilent(command, options?) The same strict timeout/exit/signal behavior without automatic output 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
ensurePtySupport() Validate that the optional node-pty peer can load No process
execSpawnStreamingInteractiveControlPty(command, args?, options?) Shell-free, silent streaming PTY control No
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

// 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

// 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:

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:

await shell.execSpawn('node', ['worker.js'], {
  env: {
    PATH: process.env.PATH,
    NODE_ENV: 'production',
  },
});

Real-World Recipes

Build Pipeline

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

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

const server = shell.execAndWaitForLine(
  'pnpm dev',
  /ready|listening/i,
  false,
  {
    cwd: '/path/to/app',
    timeout: 30_000,
  }
);

await server;

Restart on File Changes

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();
});

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in license.md.

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.

S
Description
A library for executing shell commands using promises.
Readme
1.4 MiB
Languages
TypeScript 100%