exec-sh
exec-sh is a CommonJS helper for running shell command strings from Node. It launches `cmd /C` on Windows and `sh -c` elsewhere, inherits the parent terminal by default, can collect stdout and stderr when asked, accepts one command or a semicolon-joined list, returns the ChildProcess from its callback API, and provides a separate Promise interface. It is shell execution made shorter, not a safer replacement for spawning an executable with an argument array.
Useful only for trusted, compact shell scripts where terminal forwarding is the feature. For application code, CI helpers that handle dynamic values, or any new TypeScript tool, direct spawn or execa gives clearer and safer control.
Use it if
- You are maintaining a small CommonJS build script that already works in shell-command strings and needs Windows `cmd` versus Unix `sh` selection
- You need to launch an interactive command with stdin, stdout, and stderr inherited from the current terminal
- You want a callback API that returns the ChildProcess so existing code can send signals or inspect its PID
- Your command text is completely controlled by the application and does not include user, file, branch, URL, or environment-derived values
- Any part of the command comes from an untrusted or merely awkward value; the implementation always passes one string to `cmd /C` or `sh -c`, so quoting mistakes become command injection
- You think an array means executable plus arguments; exec-sh treats an array as multiple command strings and joins them with semicolons before invoking the shell
- You need the same syntax and failure behavior on Windows and Unix; `cmd` and POSIX `sh` have different quoting, variables, built-ins, pipelines, and command separators
- You need cancellation while using promises, timeouts, AbortSignal, output limits, structured errors, or streaming iteration; version 0.4.0 does not provide them
- You want current maintenance and modern packaging; the last npm release was March 2021, the last repository push was February 2024, and the package has no ESM export map
Setup reality
`npm install exec-sh` adds no runtime dependencies or native build. The package is CommonJS and ships a small declaration file whose default export works most naturally with TypeScript interop settings. Runtime behavior has two modes that are easy to confuse. With no options, stdio defaults to `inherit`: output appears in the terminal, input remains interactive, and callback output strings are empty because nothing is piped. Passing literal `true`, or an options object with `stdio: null`, creates pipes and aggregates stdout and stderr as strings. The callback interface returns a ChildProcess, but the Promise interface returns only `{ stdout, stderr }`, so it gives you no child handle for cancellation. Nonzero exits call back with an Error carrying `code`, or reject the Promise with `code`, `stdout`, and `stderr`. The implementation does not listen for the ChildProcess `error` event; it only catches synchronous exceptions from `spawn()` and handles `close`, which is a thin error model compared with modern runners. Every command is shell text. On Unix it is executed by `/bin/sh`, not necessarily Bash, while Windows uses `cmd.exe`; Bash arrays, `[[ ... ]]`, brace expansion, quoting, redirection, environment assignment, and variable expansion are not portable assumptions. An input array is joined with `;`, so later commands run even if an earlier command fails and only the shell's final exit code is reported. Prefer `spawn(command, args, { shell: false })` or execa whenever values need to be passed as data rather than parsed again as code.
Patterns
Run a trusted command in the current terminalrun-interactive-command
const execSh = require('exec-sh');
const child = execSh('npm init', (error) => {
if (error) process.exitCode = error.code || 1;
});
console.log('child pid:', child.pid);Stdio is inherited by default, so the command can read from the terminal but the callback receives empty output strings.
Collect stdout and stderr with a callbackcapture-command-output
execSh('git status --short', true, (error, stdout, stderr) => {
if (error) {
console.error(stderr);
return;
}
console.log(stdout);
});Passing literal `true` changes stdio from inherited streams to pipes. The complete output is retained in memory as strings.
Use the Promise interfaceawait-command-output
const execShPromise = require('exec-sh').promise;
try {
const { stdout, stderr } = await execShPromise('pwd', true);
console.log(stdout.trim(), stderr);
} catch (error) {
console.error(error.code, error.stderr);
}Rejected errors receive `code`, `stdout`, and `stderr`; the Promise result does not expose the ChildProcess, so it cannot be killed through this API.
Execute inside a chosen directoryset-working-directory
execSh('npm test', {
cwd: '/srv/project',
stdio: 'inherit',
}, (error) => {
if (error) process.exitCode = error.code || 1;
});The options object is forwarded to `spawn()`. Explicit stdio makes the otherwise implicit inherited-terminal behavior easier to review.
Pass environment variables to the shellextend-command-environment
execSh('node build.js', {
env: { ...process.env, NODE_ENV: 'production' },
stdio: 'inherit',
}, done);Preserve `process.env` so the selected shell and commands retain PATH. Do not interpolate an untrusted value into the command string.
Terminate a callback-style childstop-running-command
const child = execSh('node server.js', { stdio: 'inherit' }, (error) => {
if (error && error.code !== null) console.error(error.message);
});
setTimeout(() => child.kill('SIGTERM'), 10_000);Only the callback API returns the ChildProcess. Signal behavior differs on Windows, and killing a shell does not always terminate every process it started.
Run several fixed shell commandsrun-command-sequence
execSh([
'npm run lint',
'npm test',
], { stdio: 'inherit' }, (error) => {
if (error) console.error('final shell exit:', error.code);
});The array is joined with semicolons, not executed as command and arguments. Later entries run after earlier failures, and only the final shell status is reported.
Stop a trusted Unix sequence after failurestop-sequence-on-failure
execSh('npm run lint && npm test && npm run build', {
stdio: 'inherit',
}, (error) => {
if (error) process.exitCode = error.code || 1;
});This uses POSIX shell syntax and is not a portable Windows command. For cross-platform sequencing, await separate direct process calls in JavaScript.
Read failure output from a rejected Promisehandle-nonzero-exit
try {
await execSh.promise('node -e "process.stderr.write(\"bad\"); process.exit(2)"', true);
} catch (error) {
console.error({ code: error.code, stdout: error.stdout, stderr: error.stderr });
}The error reports the shell's exit code, which may be the final command's status rather than the command you care about in a semicolon-separated sequence.
Use built-in spawn for dynamic valuesspawn-dynamic-arguments-safely
const { spawn } = require('node:child_process');
const child = spawn('git', ['show', '--stat', '--', userBranch], {
shell: false,
stdio: 'inherit',
});This deliberately does not use exec-sh. Dynamic values belong in an argument array so a shell never interprets spaces, quotes, substitutions, or separators inside them.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| execa | npm | Choose it for typed ESM, argument-safe execution, rich errors, cancellation, timeouts, and controlled output collection |
| zx | npm | Choose it for readable shell-style automation with escaped template substitutions and useful scripting helpers |
| shelljs | npm | Choose it when you want portable JavaScript functions for common shell operations rather than platform-specific command syntax |
| cross-spawn | npm | Choose it for Windows-compatible direct process spawning while retaining argument arrays and the complete ChildProcess interface |