๐ Differences with Bash and zx
October 27, 2024 ยท View on GitHub
๐ Differences with Bash and zx
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
Flexibility
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.
Shell
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.
Simplicity
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.
Modularity
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.
Performance
Spawning a shell for every command comes at a performance cost, which Execa avoids.
Debugging
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.
Examples
Main binary
# 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
Global variables
// zx
await $`npm run build`;
// Execa
import {$} from 'execa';
await $`npm run build`;
Command execution
# Bash
npm run build
// zx
await $`npm run build`;
// Execa
await $`npm run build`;
Multiline commands
# 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`;
Concatenation
# Bash
tmpDirectory="/tmp"
mkdir "$tmpDirectory/filename"
// zx
const tmpDirectory = '/tmp'
await $`mkdir ${tmpDirectory}/filename`;
// Execa
const tmpDirectory = '/tmp'
await $`mkdir ${tmpDirectory}/filename`;
Variable substitution
# Bash
echo $LANG
// zx
await $`echo $LANG`;
// Execa
await $`echo ${process.env.LANG}`;
Escaping
# Bash
echo 'one two'
// zx
await $`echo ${'one two'}`;
// Execa
await $`echo ${'one two'}`;
Escaping multiple arguments
# Bash
echo 'one two' '$'
// zx
await $`echo ${['one two', '$']}`;
// Execa
await $`echo ${['one two', '$']}`;
Subcommands
# 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}`;
Serial commands
# Bash
npm run build && npm run test
// zx
await $`npm run build && npm run test`;
// Execa
await $`npm run build`;
await $`npm run test`;
Parallel commands
# 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`]);
Global/shared options
# 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`;
Environment variables
# Bash
EXAMPLE=1 npm run build
// zx
await $({env: {EXAMPLE: '1'}})`npm run build`;
// Execa
await $({env: {EXAMPLE: '1'}})`npm run build`;
Local binaries
# Bash
npx tsc --version
// zx
await $({preferLocal: true})`tsc --version`;
// Execa
await $({preferLocal: true})`tsc --version`;
Retrieve stdin
# Bash
read content
// zx
const content = await stdin();
// Execa
import getStdin from 'get-stdin';
const content = await getStdin();
Pass input to stdin
# Bash
cat <<<"example"
// zx
$({input: 'example'})`cat`;
// Execa
$({input: 'example'})`cat`;
Pass any input type
# 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();
Return any output type
# 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()});
Printing to stdout
# Bash
echo example
// zx
echo`example`;
// Execa
console.log('example');
Silent stdout
# Bash
npm run build > /dev/null
// zx
await $`npm run build`.quiet();
// Execa does not print stdout by default
await $`npm run build`;
Binary output
# 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`;
Verbose mode (single command)
# Bash
set -v
npm run build
set +v
// zx
await $`npm run build`.verbose();
// Execa
await $({verbose: 'full'})`npm run build`;
Verbose mode (global)
# 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)
Piping stdout to another command
# 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`;
Piping stdout and stderr to another command
# 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`;
Piping stdout to a file
# 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`;
Append stdout to a file
# 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`;
Piping interleaved stdout and stderr to a file
# 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`;
Piping stdin from a file
# Bash
cat < input.txt
// zx
const cat = $`cat`;
fs.createReadStream('input.txt').pipe(cat.stdin);
await cat;
// Execa
await $({inputFile: 'input.txt'})`cat`;
Web streams
// zx does not support web streams
// Execa
const response = await fetch('https://example.com');
await $({stdin: response.body})`npm run build`;
Convert to Duplex stream
// 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'),
);
Handle pipeline errors
# 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);
}
Return all pipeline results
# 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`
Split output into lines
# 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`;
Iterate over output lines
# 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);
}
}
Detailed errors
# 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: []
// }
Exit codes
# Bash
npm run build
echo $?
// zx
const {exitCode} = await $`npm run build`.nothrow();
// Execa
const {exitCode} = await $({reject: false})`npm run build`;
Timeouts
# Bash
timeout 5 npm run build
// zx
await $`npm run build`.timeout('5s');
// Execa
await $({timeout: 5000})`npm run build`;
Current filename
# Bash
echo "$(basename "\$0")"
// zx
await $`echo ${__filename}`;
// Execa
await $`echo ${import.meta.filename}`;
Current directory
# Bash
cd project
// zx
const $$ = $({cwd: 'project'});
// Or:
cd('project');
// Execa
const $$ = $({cwd: 'project'});
Background subprocess
# Bash
npm run build &
// zx
await $({detached: true})`npm run build`;
// Execa
await $({detached: true})`npm run build`;
IPC
# 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');
}
});
Transforms
# 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.'}`;
Signal termination
# Bash
kill $PID
// zx
subprocess.kill();
// Execa
subprocess.kill();
// Or with an error message and stack trace:
subprocess.kill(error);
Default signal
# 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'});
Cancelation
# 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`;
Graceful termination
# 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});
Interleaved output
# Bash prints stdout and stderr interleaved
// zx
const all = String(await $`node example.js`);
// Execa
const {all} = await $({all: true})`node example.js`;
PID
# Bash
npm run build &
echo $!
// zx does not return `subprocess.pid`
// Execa
const {pid} = $`npm run build`;
CLI arguments
// zx
const {myCliFlag} = argv;
// Execa
import {parseArgs} from 'node:util';
const {myCliFlag} = parseArgs({strict: false}).values;
CLI prompts
# Bash
read -p "Question? " answer
// zx
const answer = await question('Question? ');
// Execa
import input from '@inquirer/input';
const answer = await input({message: 'Question?'});
CLI spinner
# Bash does not provide with a builtin spinner
// zx
await spinner(() => $`node script.js`);
// Execa
import {oraPromise} from 'ora';
await oraPromise($`node script.js`);
Sleep
# Bash
sleep 5
// zx
await sleep(5000);
// Execa
import {setTimeout} from 'node:timers/promises';
await setTimeout(5000);
Globbing
# Bash
ls packages/*
// zx
const files = await glob(['packages/*']);
// Execa
import {glob} from 'node:fs/promises';
const files = await Array.fromAsync(glob('packages/*'));
Temporary file
// zx
const filePath = tmpfile();
// Execa
import tempfile from 'tempfile';
const filePath = tempfile();
HTTP requests
# Bash
curl https://github.com
// zx
await fetch('https://github.com');
// Execa
await fetch('https://github.com');
Retry on error
// 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