This page describes the differences between Bash, Execa, and zx. Execa intends to be more:
- Simple: minimalistic API, no globals, no binary, no builtin CLI utilities.
- Cross-platform: no shell is used, only JavaScript.
- Secure: no shell injection.
- Featureful: all Execa features are available (text lines iteration, advanced piping, simple IPC, passing any input type, returning any output type, transforms, web streams, convert to Duplex stream, cleanup on exit, graceful termination, forceful termination, and more).
- Easy to debug: verbose mode, detailed errors, messages and stack traces, stateless API.
- Performant
Unlike shell languages like Bash, libraries like Execa and zx enable you to write scripts with a more featureful programming language (JavaScript). This allows complex logic (such as parallel execution) to be expressed easily. This also lets you use any Node.js package.
The main difference between Execa and zx is that Execa does not require any shell. Shell-specific keywords and features are written in JavaScript instead.
This is more cross-platform. For example, your code works the same on Windows machines without Bash installed.
Also, there is no shell syntax to remember: everything is just plain JavaScript.
If you really need a shell though, the shell
option can be used.
Execa's scripting API mostly consists of only two methods: $`command`
and $(options)
.
No special binary is recommended, no global variable is injected: scripts are regular Node.js files.
Execa is a thin wrapper around the core Node.js child_process
module. It lets you use any of its native features.
zx includes many builtin utilities: fetch()
, question()
, sleep()
, echo()
, stdin()
, retry()
, spinner()
, globby
, chalk
, fs
, os
, path
, yaml
, which
, ps
, tmpfile()
, argv
, Markdown scripts, remote scripts.
Execa does not include any utility: it focuses on being small and modular instead. Any Node.js package can be used in your scripts.
Spawning a shell for every command comes at a performance cost, which Execa avoids.
Subprocesses can be hard to debug, which is why Execa includes a verbose
option. It includes more information than zx: timestamps, command completion and duration, interleaved commands, IPC messages.
Also, Execa's error messages and properties are very detailed to make it clear to determine why a subprocess failed. Error messages and stack traces can be set with subprocess.kill(error)
.
Finally, unlike Bash and zx, which are stateful (options, current directory, etc.), Execa is purely functional, which also helps with debugging.
# Bash
bash file.sh
// zx
zx file.js
// or a shebang can be used:
// #!/usr/bin/env zx
// Execa scripts are just regular Node.js files
node file.js
// zx
await $`npm run build`;
// Execa
import {$} from 'execa';
await $`npm run build`;
# Bash
npm run build
// zx
await $`npm run build`;
// Execa
await $`npm run build`;
# Bash
npm run build \
--example-flag-one \
--example-flag-two
// zx
await $`npm run build ${[
'--example-flag-one',
'--example-flag-two',
]}`;
// Execa
await $`npm run build
--example-flag-one
--example-flag-two`;
# Bash
tmpDirectory="/tmp"
mkdir "$tmpDirectory/filename"
// zx
const tmpDirectory = '/tmp'
await $`mkdir ${tmpDirectory}/filename`;
// Execa
const tmpDirectory = '/tmp'
await $`mkdir ${tmpDirectory}/filename`;
# Bash
echo $LANG
// zx
await $`echo $LANG`;
// Execa
await $`echo ${process.env.LANG}`;
# Bash
echo 'one two'
// zx
await $`echo ${'one two'}`;
// Execa
await $`echo ${'one two'}`;
# Bash
echo 'one two' '$'
// zx
await $`echo ${['one two', '$']}`;
// Execa
await $`echo ${['one two', '$']}`;
# Bash
echo "$(npm run build)"
// zx
const result = await $`npm run build`;
await $`echo ${result}`;
// Execa
const result = await $`npm run build`;
await $`echo ${result}`;
# Bash
npm run build && npm run test
// zx
await $`npm run build && npm run test`;
// Execa
await $`npm run build`;
await $`npm run test`;
# Bash
npm run build &
npm run test &
// zx
await Promise.all([$`npm run build`, $`npm run test`]);
// Execa
await Promise.all([$`npm run build`, $`npm run test`]);
# Bash
options="timeout 5"
$options npm run init
$options npm run build
$options npm run test
// zx
const $$ = $({verbose: true});
await $$`npm run init`;
await $$`npm run build`;
await $$`npm run test`;
// Execa
import {$ as $_} from 'execa';
const $ = $_({verbose: true});
await $`npm run init`;
await $`npm run build`;
await $`npm run test`;
# Bash
EXAMPLE=1 npm run build
// zx
await $({env: {EXAMPLE: '1'}})`npm run build`;
// Execa
await $({env: {EXAMPLE: '1'}})`npm run build`;
# Bash
npx tsc --version
// zx
await $({preferLocal: true})`tsc --version`;
// Execa
await $({preferLocal: true})`tsc --version`;
# Bash
read content
// zx
const content = await stdin();
// Execa
import getStdin from 'get-stdin';
const content = await getStdin();
# Bash
cat <<<"example"
// zx
$({input: 'example'})`cat`;
// Execa
$({input: 'example'})`cat`;
# Bash only allows passing strings as input
// zx only allows passing specific input types
// Execa - main.js
const ipcInput = [
{task: 'lint', ignore: /test\.js/},
{task: 'copy', files: new Set(['main.js', 'index.js']),
}];
await $({ipcInput})`node build.js`;
// Execa - build.js
import {getOneMessage} from 'execa';
const ipcInput = await getOneMessage();
# Bash only allows returning strings as output
// zx only allows returning specific output types
// Execa - main.js
const {ipcOutput} = await $({ipc: true})`node build.js`;
console.log(ipcOutput[0]); // {kind: 'start', timestamp: date}
console.log(ipcOutput[1]); // {kind: 'stop', timestamp: date}
// Execa - build.js
import {sendMessage} from 'execa';
await sendMessage({kind: 'start', timestamp: new Date()});
await runBuild();
await sendMessage({kind: 'stop', timestamp: new Date()});
# Bash
echo example
// zx
echo`example`;
// Execa
console.log('example');
# Bash
npm run build > /dev/null
// zx
await $`npm run build`.quiet();
// Execa does not print stdout by default
await $`npm run build`;
# Bash usually requires redirecting binary output
zip -r - input.txt > output.txt
// zx
const stdout = await $`zip -r - input.txt`.buffer();
// Execa
const {stdout} = await $({encoding: 'buffer'})`zip -r - input.txt`;
# Bash
set -v
npm run build
set +v
// zx
await $`npm run build`.verbose();
// Execa
await $({verbose: 'full'})`npm run build`;
# Bash
set -v
npm run build
// zx
$ zx --verbose file.js
$ npm run build
Building...
Done.
$ NODE_DEBUG=execa node file.js
[19:49:00.360] [0] $ npm run build
[19:49:00.360] [0] Building...
[19:49:00.360] [0] Done.
[19:49:00.383] [0] √ (done in 23ms)
# Bash
echo npm run build | sort | head -n2
// zx
await $`npm run build`
.pipe($`sort`)
.pipe($`head -n2`);
// Execa
await $`npm run build`
.pipe`sort`
.pipe`head -n2`;
# Bash
npm run build |& cat
// zx
const subprocess = $`npm run build`;
const cat = $`cat`;
subprocess.pipe(cat);
subprocess.stderr.pipe(cat.stdin);
await Promise.all([subprocess, cat]);
// Execa
await $({all: true})`npm run build`
.pipe({from: 'all'})`cat`;
# Bash
npm run build > output.txt
// zx
import {createWriteStream} from 'node:fs';
await $`npm run build`.pipe(createWriteStream('output.txt'));
// Execa
await $({stdout: {file: 'output.txt'}})`npm run build`;
# Bash
npm run build >> output.txt
// zx
import {createWriteStream} from 'node:fs';
await $`npm run build`.pipe(createWriteStream('output.txt', {flags: 'a'}));
// Execa
await $({stdout: {file: 'output.txt', append: true}})`npm run build`;
# Bash
npm run build &> output.txt
// zx
import {createWriteStream} from 'node:fs';
const subprocess = $`npm run build`;
const fileStream = createWriteStream('output.txt');
subprocess.pipe(fileStream);
subprocess.stderr.pipe(fileStream);
await subprocess;
// Execa
const output = {file: 'output.txt'};
await $({stdout: output, stderr: output})`npm run build`;
# Bash
cat < input.txt
// zx
const cat = $`cat`;
fs.createReadStream('input.txt').pipe(cat.stdin);
await cat;
// Execa
await $({inputFile: 'input.txt'})`cat`;
// zx does not support web streams
// Execa
const response = await fetch('https://example.com');
await $({stdin: response.body})`npm run build`;
// zx does not support converting subprocesses to streams
// Execa
import {pipeline} from 'node:stream/promises';
import {createReadStream, createWriteStream} from 'node:fs';
await pipeline(
createReadStream('./input.txt'),
$`node ./transform.js`.duplex(),
createWriteStream('./output.txt'),
);
# Bash
set -e
npm run crash | sort | head -n2
// zx
try {
await $`npm run crash`
.pipe($`sort`)
.pipe($`head -n2`);
// This is never reached.
// The process crashes instead.
} catch (error) {
console.error(error);
}
// Execa
try {
await $`npm run build`
.pipe`sort`
.pipe`head -n2`;
} catch (error) {
console.error(error);
}
# Bash only allows returning each command's exit code
npm run crash | sort | head -n2
# 1 0 0
echo "${PIPESTATUS[@]}"
// zx only returns the last command's result
// Execa
const destinationResult = await execa`npm run build`
.pipe`head -n 2`;
console.log(destinationResult.stdout); // First 2 lines of `npm run build`
const sourceResult = destinationResult.pipedFrom[0];
console.log(sourceResult.stdout); // Full output of `npm run build`
# Bash
npm run build | IFS='\n' read -ra lines
// zx
const lines = await $`npm run build`.lines();
// Execa
const lines = await $({lines: true})`npm run build`;
# Bash
while read
do
if [[ "$REPLY" == *ERROR* ]]
then
echo "$REPLY"
fi
done < <(npm run build)
// zx does not allow easily iterating over output lines.
// Also, the iteration does not handle subprocess errors.
// Execa
for await (const line of $`npm run build`) {
if (line.includes('ERROR')) {
console.log(line);
}
}
# Bash communicates errors only through the exit code and stderr
timeout 1 sleep 2
echo $?
// zx
await $`sleep 2`.timeout('1ms');
// Error:
// at file:///home/me/Desktop/example.js:6:12
// exit code: null
// signal: SIGTERM
// Execa
await $({timeout: 1})`sleep 2`;
// ExecaError: Command timed out after 1 milliseconds: sleep 2
// at file:///home/me/Desktop/example.js:2:20
// at ... {
// shortMessage: 'Command timed out after 1 milliseconds: sleep 2\nTimed out',
// originalMessage: '',
// command: 'sleep 2',
// escapedCommand: 'sleep 2',
// cwd: '/path/to/cwd',
// durationMs: 19.95693,
// failed: true,
// timedOut: true,
// isCanceled: false,
// isTerminated: true,
// isMaxBuffer: false,
// signal: 'SIGTERM',
// signalDescription: 'Termination',
// stdout: '',
// stderr: '',
// stdio: [undefined, '', ''],
// pipedFrom: []
// }
# Bash
npm run build
echo $?
// zx
const {exitCode} = await $`npm run build`.nothrow();
// Execa
const {exitCode} = await $({reject: false})`npm run build`;
# Bash
timeout 5 npm run build
// zx
await $`npm run build`.timeout('5s');
// Execa
await $({timeout: 5000})`npm run build`;
# Bash
echo "$(basename "$0")"
// zx
await $`echo ${__filename}`;
// Execa
await $`echo ${import.meta.filename}`;
# Bash
cd project
// zx
const $$ = $({cwd: 'project'});
// Or:
cd('project');
// Execa
const $$ = $({cwd: 'project'});
# Bash
npm run build &
// zx
await $({detached: true})`npm run build`;
// Execa
await $({detached: true})`npm run build`;
# Bash does not allow simple IPC
// zx does not allow simple IPC
// Execa
const subprocess = $({node: true})`script.js`;
for await (const message of subprocess.getEachMessage()) {
if (message === 'ping') {
await subprocess.sendMessage('pong');
}
});
# Bash does not allow transforms
// zx does not allow transforms
// Execa
const transform = function * (line) {
if (!line.includes('secret')) {
yield line;
}
};
await $({stdout: [transform, 'inherit']})`echo ${'This is a secret.'}`;
# Bash
kill $PID
// zx
subprocess.kill();
// Execa
subprocess.kill();
// Or with an error message and stack trace:
subprocess.kill(error);
# Bash does not allow changing the default termination signal
// zx only allows changing the signal used for timeouts
const $$ = $({timeoutSignal: 'SIGINT'});
// Execa
const $ = $_({killSignal: 'SIGINT'});
# Bash
kill $PID
// zx
const controller = new AbortController();
await $({signal: controller.signal})`node long-script.js`;
// Execa
const controller = new AbortController();
await $({cancelSignal: controller.signal})`node long-script.js`;
# Bash
trap cleanup SIGTERM
// zx
// This does not work on Windows
process.on('SIGTERM', () => {
// ...
});
// Execa - main.js
const controller = new AbortController();
await $({
cancelSignal: controller.signal,
gracefulCancel: true,
})`node build.js`;
// Execa - build.js
import {getCancelSignal} from 'execa';
const cancelSignal = await getCancelSignal();
await fetch('https://example.com', {signal: cancelSignal});
# Bash prints stdout and stderr interleaved
// zx
const all = String(await $`node example.js`);
// Execa
const {all} = await $({all: true})`node example.js`;
# Bash
npm run build &
echo $!
// zx does not return `subprocess.pid`
// Execa
const {pid} = $`npm run build`;
// zx
const {myCliFlag} = argv;
// Execa
import {parseArgs} from 'node:util';
const {myCliFlag} = parseArgs({strict: false}).values;
# Bash
read -p "Question? " answer
// zx
const answer = await question('Question? ');
// Execa
import input from '@inquirer/input';
const answer = await input({message: 'Question?'});
# Bash does not provide with a builtin spinner
// zx
await spinner(() => $`node script.js`);
// Execa
import {oraPromise} from 'ora';
await oraPromise($`node script.js`);
# Bash
sleep 5
// zx
await sleep(5000);
// Execa
import {setTimeout} from 'node:timers/promises';
await setTimeout(5000);
# Bash
ls packages/*
// zx
const files = await glob(['packages/*']);
// Execa
import {glob} from 'node:fs/promises';
const files = await Array.fromAsync(glob('packages/*'));
// zx
const filePath = tmpfile();
// Execa
import tempfile from 'tempfile';
const filePath = tempfile();
# Bash
curl https://github.com
// zx
await fetch('https://github.com');
// Execa
await fetch('https://github.com');
// zx
await retry(
5,
() => $`curl -sSL https://sindresorhus.com/unicorn`,
)
// Execa
import pRetry from 'p-retry';
await pRetry(
() => $`curl -sSL https://sindresorhus.com/unicorn`,
{retries: 5},
);
Next: 🐭 Small packages
Previous: 📎 Windows
Top: Table of contents