zx is a tool for writing better shell scripts in JavaScript and TypeScript. Bash is great for quick automation, but as scripts grow, you often want real loops, error handling, parallelism, and data structures. zx gives you exactly that: it wraps Node.js child_process in an ergonomic $`command` template literal, automatically escapes arguments, and ships sensible cross-platform defaults — so you keep the power of the shell while writing in a language you already know.
#!/usr/bin/env zx
await $`cat package.json | grep name`
const branch = await $`git branch --show-current`
await $`dep deploy --branch=${branch}`
await Promise.all([
$`sleep 1; echo 1`,
$`sleep 2; echo 2`,
$`sleep 3; echo 3`,
])
const name = 'foo bar'
await $`mkdir /tmp/${name}`
Note
zxis an open-source project published by Google under the Apache-2.0 license. It is not an officially supported Google product. The canonical online documentation lives at google.github.io/zx.
1. Quick Start
The fastest way to try zx is to run a script with npx — no install required.
npx (no install)
npx zx ./script.mjs
Global install
npm install -g zx
zx ./script.mjs
Project dependency
npm install zx
Create a file named hello.mjs:
#!/usr/bin/env zx
await $`echo "Hello from zx!"`
const files = await $`ls`
echo(`This directory has files:\n${files}`)
Run it:
zx hello.mjs
Hello from zx!
This directory has files:
hello.mjs
package.json
Tip
Use the
.mjsextension so you can use top-levelawaitwithout any boilerplate. Allzxhelpers ($,cd,echo,fetch, …) are injected as globals automatically when you run a script through thezxexecutable — you do not need anyimportstatements.
2. Installation
2.1 Requirements
Before installing, make sure your environment meets these requirements:
| Requirement | Details |
|---|---|
| Operating system | Linux, macOS, or Windows |
| JavaScript runtime | Node.js >= 12.17.0, Bun >= 1.0.0, Deno 1.x/2.x, or GraalVM Node.js |
| Shell | Some form of bash (default) or PowerShell |
| Modules | Works as both CommonJS (CJS) and ECMAScript Modules (ESM), in JavaScript or TypeScript |
Important
zxrelies on bash by default. On Windows, install Windows Subsystem for Linux (WSL) or Git Bash, or switch to PowerShell withusePowerShell()/usePwsh()(see §8 Choosing a shell).
2.2 Installation methods
zx can be installed through every major package manager and several distribution channels.
npm
npm install zx # add -g to install globally
npx
npx zx script.js # run without installing
npx zx@8.6.0 script.js # pin to a specific version
yarn
yarn add zx
pnpm
pnpm add zx
bun
bun install zx
deno
deno install -A npm:zx
# zx needs: --allow-read --allow-sys --allow-env --allow-run
jsr
npx jsr add @webpod/zx
deno add jsr:@webpod/zx
Docker
docker pull ghcr.io/google/zx:8.5.0
docker run -t ghcr.io/google/zx:8.5.0 -e="await \$({verbose: true})\`echo foo\`"
docker run -t -i -v ./:/script ghcr.io/google/zx:8.5.0 script/t.js
Homebrew
brew install zx
2.3 Installing directly from GitHub
# Install via git
npm i google/zx
npm i git@github.com:google/zx.git
# Fetch from the GitHub package registry
npm i --registry=https://npm.pkg.github.com @google/zx
Caution
Always verify the source you install from. Official distribution points are npmjs, the GitHub repo, the GitHub container registry, JSR, and Homebrew.
2.4 Distribution channels
zx ships in several channels, each tuned for a different use case:
| Channel | Description | Install |
|---|---|---|
latest |
Stable, full-featured releases (the default). | npm i zx |
lite |
A minimal core build (~7× smaller, no CLI). Ideal for custom toolkits. | npm i zx@lite |
dev |
Experimental snapshots and release candidates — may be unstable. | npm i zx@dev |
legacy |
Older major versions; bugfixes only, no new features. | npm i zx@<version> |
Note
The
litechannel omits the CLI,argv,echo,fetch,glob, and several other extras. See §13 Reference for the full feature matrix. Picklitewhen you embedzxinside another library rather than running scripts directly.
3. Writing Your First Script
3.1 Script files and the shebang
Write your script in a file with the .mjs extension so top-level await works out of the box. Add a shebang at the top to make the file directly executable:
#!/usr/bin/env zx
await $`whoami`
await $`date`
Make it executable and run it directly:
chmod +x ./script.mjs
./script.mjs
Or run it through the CLI:
zx ./script.mjs
Tip
If you prefer the
.jsextension in a project that is not ESM, wrap your code in an async IIFE:void async function () { /* ... */ }(). TypeScript (.ts,.mts,.cts) is also supported — see §11 TypeScript.
3.2 Globals vs. explicit imports
When you run a script through the zx executable, every helper is available globally. If you run your script with plain node (or want editor autocompletion), import the globals explicitly:
import 'zx/globals'
await $`echo "globals are now available"`
Or import only what you need:
import { $, cd, fetch, echo } from 'zx'
await $`echo "selective import"`
4. Running Commands with $
The $`...` template literal is the heart of zx. It spawns a command and returns a ProcessPromise that resolves to a ProcessOutput.
const list = await $`ls -la` // async → ProcessPromise → ProcessOutput
const dir = $.sync`pwd` // sync → ProcessOutput immediately
4.1 Interpolation is automatically escaped
Anything you place inside ${...} is automatically escaped and quoted. You never add your own quotes.
const name = 'foo & bar'
await $`mkdir ${name}` // safely creates a directory literally named "foo & bar"
Warning
Because
zxescapes interpolated values for you, wrapping them in extra quotes can actually re-enable injection. Do not write$`echo "$'${userInput}'"`. Letzxhandle the quoting. See §5 Working with Arguments and Quoting.
4.2 Passing arrays of arguments
Pass an array and each element is escaped individually, then joined with spaces:
const flags = ['--oneline', '--decorate', '--color']
await $`git log ${flags}`
4.3 Awaiting interpolated values
In async mode, zx awaits any thenable inside the template before running the command:
const a1 = $`echo foo`
const a2 = new Promise((resolve) => setTimeout(resolve, 20, ['bar', 'baz']))
await $`echo ${a1} ${a2}` // prints: foo bar baz
4.4 Handling failures
If the command exits with a non-zero code, the awaited promise throws a ProcessOutput:
try {
await $`exit 1`
} catch (p) {
console.log(`Exit code: ${p.exitCode}`)
console.log(`Error: ${p.stderr}`)
}
To opt out of throwing, use .nothrow().
4.5 Synchronous execution
For quick, blocking calls, use $.sync:
const branch = $.sync`git branch --show-current`
echo(`On branch ${branch}`)
Note
Sync mode cannot resolve async-interpolated values inside the template. If you interpolate a
ProcessPromiseorPromise, use async$instead.
5. Working with Arguments and Quoting
zx prefers bash C-style quoting ($'...') and escapes everything inside ${...}. This protects you from injection but changes a few habits you may carry over from bash.
5.1 The right way to build arguments
The following examples all produce the same, correct result:
await $`mkdir ${'path/to-dir/' + name}`
await $`mkdir path/to-dir/${name}`
5.2 Glob patterns
Because everything inside ${...} is escaped, glob syntax will not expand. Use the built-in glob() helper instead:
// ❌ Incorrect — the pattern is escaped literally
const files = './**/*.md'
await $`ls ${files}`
// ✅ Correct
const files = await glob('./**/*.md')
await $`ls ${files}`
5.3 The home directory ~
zx will not expand ~ inside ${...}. Use os.homedir():
// ❌ Incorrect
const dir = `~/Downloads`
await $`ls ${dir}`
// ✅ Correct
await $`ls ${os.homedir()}/Downloads`
// ✅ Also correct — ~ is outside ${...}
await $`ls ~/Downloads`
5.4 Assembling commands dynamically
You cannot build a command by concatenating a string and interpolating the whole thing — zx will escape the entire string and break the command. Build an array of arguments instead:
// ❌ Incorrect
let cmd = 'rm'
if (force) cmd += ' -f'
if (recursive) cmd += ' -r'
cmd += ' ' + file
await $`${cmd}`
// ✅ Correct
const args = []
if (force) args.push('-f')
if (recursive) args.push('-r')
args.push(file)
await $`rm ${args}`
5.5 PowerShell quoting
When using PowerShell, switch to the PowerShell quoting function:
import { quotePowerShell } from 'zx'
$.quote = quotePowerShell
Tip
The
useBash(),usePowerShell(), andusePwsh()helpers set both the shell and the matching quote function for you. Prefer those over setting$.quoteby hand. See §8 Choosing a shell.
6. Reading Command Output
Awaiting a command yields a ProcessOutput object. It behaves like a string (its toString() is the combined output) and exposes structured accessors.
const o = await $`echo "hello world"`
o.stdout // 'hello world\n'
o.stderr // ''
o.exitCode // 0
o.signal // null
String(o) // combined stdout + stderr
6.1 Output formatters
ProcessOutput (and ProcessPromise directly) provide convenient formatters:
| Method | Returns | Example result |
|---|---|---|
text(encoding?) |
The combined output as a string | 'foo\nbar\n' |
json<T>() |
The output parsed as JSON | { foo: 'bar' } |
lines(delimiter?) |
The output split into an array of lines | ['foo', 'bar'] |
buffer() |
The output as a Buffer |
Buffer.from('foo\n') |
blob(type?) |
The output as a Blob |
Blob (default text/plain) |
const p = $`echo 'foo\nbar'`
await p.text() // 'foo\nbar\n'
await p.text('hex') // hex-encoded output
await p.buffer() // Buffer
await p.lines() // ['foo', 'bar']
// Custom line delimiter (e.g. null-separated):
await $`find ./ -type f -print0`.lines('\0')
// Parse JSON output in place:
await $`echo '{"foo": "bar"}'`.json() // { foo: 'bar' }
Note
When you pass a
ProcessOutputas an argument into another$command,zxuses itsstdoutand trims the trailing newline automatically:const date = await $`date` await $`echo Current date is ${date}.`
6.2 Checking only the exit code
if (await $`[[ -d path ]]`.exitCode == 0) {
echo('directory exists')
}
7. The ProcessPromise Object
$`cmd` returns a ProcessPromise — a real Promise subclass with extra methods for controlling the running process. By default a process starts immediately, but you can delay it (see halt).
const p = $`command` // ProcessPromise (already running)
const o = await p // ProcessOutput
7.1 Streaming output
stdout and stderr are readable streams; stdin is writable. You can also iterate the process output line by line:
// Iterate stdout chunks
const p = $`npm init`
for await (const chunk of p.stdout) {
echo(chunk)
}
// Iterate line by line (uses $.delimiter)
for await (const line of $`echo "Line1\nLine2\nLine3"`) {
console.log(line)
}
Writing to stdin:
const p = $`while read; do echo $REPLY; done`
p.stdin.write('Hello, World!\n')
p.stdin.end()
Important
Always end the stdin stream (
p.stdin.end()) when you are done writing, or the process will wait forever.
7.2 Process metadata
| Getter | Description |
|---|---|
pid |
Process ID |
cwd |
Working directory of the process |
cmd |
The command as built (e.g. "sleep 1") |
fullCmd |
The command including prefix and postfix (e.g. "set -euo pipefail;sleep 1") |
stage |
Current lifecycle stage: initial, halted, running, fulfilled, or rejected |
exitCode |
A promise that resolves to the exit code |
7.3 Piping with pipe()
pipe() connects the output of one process to another process, a writable stream, or a file — like | in bash, but more powerful.
// Chain processes
const greeting = await $`printf "hello"`
.pipe($`awk '{printf $1", world!"}'`)
.pipe($`tr '[a-z]' '[A-Z]'`)
// Pipe to a file (string path or stream)
await $`echo "Hello, stdout!"`.pipe('/tmp/output.txt')
await $`echo "Hello, stdout!"`.pipe(fs.createWriteStream('/tmp/output.txt'))
// Show real-time output
await $`echo 1; sleep 1; echo 2`.pipe(process.stdout)
// Template-literal shorthand
await $`printf "hello"`
.pipe`awk '{printf $1", world!"}'`
.pipe`tr '[a-z]' '[A-Z]'`
Pipe a specific stream (stdout is the default; stderr and stdall are also available):
const p = $`echo foo >&2; echo bar`
const errOut = (await p.pipe.stderr`cat`).toString() // 'foo\n'
const stdOut = (await p.pipe.stdout`cat`).toString() // 'bar\n'
Tip
pipe()buffers and replays output, so you can attach a consumer at any point — even after the source process has finished:const p = $`echo 1; sleep 1; echo 2` await p await p.pipe`cat` // still works; all output is replayed in order
Use unpipe() to detach a destination from the pipeline:
p1.pipe(p3)
setTimeout(() => p1.unpipe(p3), 105)
7.4 Suppressing errors with nothrow()
nothrow() makes a command resolve (instead of throwing) on a non-zero exit code:
await $`grep something from-file`.nothrow()
// Inside a pipe
await $`find ./examples -type f -print0`
.pipe($`xargs -0 grep something`.nothrow())
.pipe($`wc -l`)
7.5 Controlling verbosity per command
await $`grep something from-file`.quiet() // suppress this command's output
await $`grep something from-file`.verbose() // print this command and its output
// When a global flag is on, turn it off for one command:
$.quiet = true
await $`echo foo`.quiet(false)
7.6 Deferring execution with halt
Create a process but do not start it until you call .run():
const p = $({ halt: true })`command`
// ... set things up ...
const o = await p.run()
This is useful for configuring stdio before launch or for merging multiple sources into one pipe.
7.7 Stopping a process
// kill() — sends SIGTERM by default (or a signal you choose)
const p = $`sleep 999`
setTimeout(() => p.kill('SIGINT'), 100)
await p
// abort() — terminate via an AbortController signal
const ac = new AbortController()
const p2 = $({ signal: ac.signal })`sleep 999`
setTimeout(() => ac.abort('reason'), 100)
await p2
Warning
Calling
kill()orabort()on a process that has already finished throws an error (Too late to kill the process./Too late to abort the process.). Guard with the processstageif you might be racing completion.
7.8 Setting timeouts
await $`sleep 999`.timeout('5s') // SIGTERM after 5 seconds
await $`sleep 999`.timeout('5s', 'SIGKILL') // custom signal
7.9 Configuring stdio
const h$ = $({ halt: true })
const p1 = h$`read`.stdio('inherit', 'pipe', null).run()
const p2 = h$`read`.stdio('pipe').run() // expands to ['pipe', 'pipe', 'pipe']
// Or via the preset, which is set before the process starts:
await $({ stdio: ['pipe', 'pipe', 'pipe'] })`read`
8. Choosing a Shell
zx defaults to bash. Switch shells with built-in helpers, which also set the correct quoting function:
import { useBash, usePowerShell, usePwsh } from 'zx'
usePowerShell() // Windows PowerShell (powershell.exe)
usePwsh() // PowerShell v7+ (pwsh binary)
useBash() // bash (the default)
You can also set the shell directly via JavaScript, the CLI, or an environment variable:
$.shell = '/bin/zsh'
zx --shell /bin/zsh script.js
ZX_SHELL=/bin/zsh zx script.js
Note
Since v8,
zxdoes not auto-detect PowerShell on Windows. If you need it, callusePowerShell()orusePwsh()explicitly. ThesshAPI was also removed in v8 — install the webpod package for remote execution.
9. Built-in Helpers
When running through the zx executable (or after import 'zx/globals'), these helpers are available globally.
9.1 cd() — change directory
cd('/tmp')
await $`pwd` // => /tmp
// Accepts a ProcessOutput and trims trailing newlines:
cd(await $`mktemp -d`)
Warning
cd()callsprocess.chdir()internally, so it changes the global working directory. To keepprocess.cwd()in sync across separate$calls, enablesyncProcessCwd().
9.2 fetch() — HTTP requests
A wrapper around node-fetch-native:
const r1 = await fetch('https://example.com')
const json = await r1.json()
const r2 = await fetch('https://example.com', {
signal: AbortSignal.timeout(5000),
})
For large responses, fetch() results are pipe-friendly:
const p1 = fetch('https://example.com').pipe($`cat`)
const p2 = fetch('https://example.com').pipe`cat`
9.3 question() — interactive prompts
A wrapper around the Node readline API:
const bear = await question('What kind of bear is best? ')
const selected = await question('Select an option: ', {
choices: ['A', 'B', 'C'],
})
9.4 Finding files with glob()
The globby package:
const packages = await glob(['package.json', 'packages/*/package.json'])
const markdowns = glob.sync('*.md') // synchronous shortcut
Note
globis also exported under the aliasglobby.
9.5 which() — locate a binary
const node = await which('node')
const maybe = await which('node', { nothrow: true }) // null if not found
9.6 sleep() and retry()
await sleep(1000) // milliseconds, or a duration string like '1s'
// Retry up to N times, returning the first success
const p1 = await retry(10, () => $`curl https://medv.io`)
// With a fixed delay between attempts
const p2 = await retry(20, '1s', () => $`curl https://medv.io`)
// With exponential backoff
const p3 = await retry(30, expBackoff(), () => $`curl https://medv.io`)
expBackoff(max?, delay?) yields increasing delays (defaults: max='60s', delay='100ms').
9.7 spinner() — progress indicator
await spinner(() => $`long-running command`)
await spinner('working...', () => $`sleep 99`)
Note
The spinner is automatically disabled in CI environments (when the
CIenvironment variable is set) and when$.quietis enabled.
9.8 echo() — log to stdout
A console.log() alternative that understands ProcessOutput:
const branch = await $`git branch --show-current`
echo`Current branch is ${branch}.`
echo('Current branch is', branch)
9.9 stdin() — read standard input
const content = JSON.parse(await stdin())
9.10 within() — scoped configuration
within() runs a callback in a new async context where changes to $ (and cd) are isolated:
await $`pwd` // => /home/path
$.foo = 'bar'
within(async () => {
$.cwd = '/tmp'
$.foo = 'baz'
await $`pwd` // => /tmp
})
await $`pwd` // => /home/path (unchanged)
$.foo // still 'bar'
A common use is loading a Node version manager just for one command:
const version = await within(async () => {
$.prefix += 'export NVM_DIR=$HOME/.nvm; source $NVM_DIR/nvm.sh; nvm use 16;'
return $`node --version`
})
echo(version) // => v16.x.x
9.11 syncProcessCwd()
Keeps process.cwd() in sync with the internal $ working directory when you use cd():
import { syncProcessCwd } from 'zx'
syncProcessCwd() // enable
syncProcessCwd(false) // disable
Note
This hook is disabled by default because of its performance overhead.
9.12 Temporary files and directories
tempdir() // /os/tmp/zx-<random>/
tempdir('foo') // /os/tmp/zx-<random>/foo/
tempfile() // empty temp file
tempfile('f.txt') // named temp file
tempfile('f.txt', 'contents') // with contents
tempfile('f.sh', 'echo "hi"', 0o744) // executable
Note
tempdir/tempfileare also exported under the aliasestmpdir/tmpfile.
9.13 Process listing and killing
const all = await ps.lookup()
const node = await ps.lookup({ command: 'node' })
const tree = await ps.tree({ pid: 123, recursive: true })
await kill(123)
await kill(123, 'SIGKILL')
9.14 Bundled utility packages
zx re-exports a curated set of popular packages so you don't have to install them yourself:
| Global | Package | Typical use |
|---|---|---|
chalk |
chalk | Terminal colors: chalk.blue('hi') |
fs |
fs-extra | File system: await fs.readJson('./package.json') |
os |
Node os |
os.homedir(), os.tmpdir() |
path |
Node path |
path.join(a, b) |
YAML |
yaml | YAML.parse('foo: bar') |
MAML |
maml | MAML.parse(text) (minimal, readable config format) |
dotenv |
envapi | .env parsing/loading (see below) |
minimist |
minimist | Manual argv parsing |
console.log(chalk.blue('Hello world!'))
const { version } = await fs.readJson('./package.json')
console.log(YAML.parse('foo: bar').foo) // 'bar'
Working with .env files via dotenv:
// Parse a raw string
const data = dotenv.parse('FOO=BAR\nBAZ=QUX') // { FOO: 'BAR', BAZ: 'QUX' }
// Load a file into an object and inject it into a single command
const env = dotenv.load('.env')
await $({ env })`echo $FOO`
// Or load into process.env globally
dotenv.config('.env')
process.env.FOO
9.15 Command-line arguments with argv
argv is a minimist-parsed version of process.argv:
if (argv.someFlag) {
echo('yes')
}
For custom parsing, call minimist (or parseArgv) yourself:
const myArgv = minimist(process.argv.slice(2), {
boolean: ['force', 'help'],
alias: { h: 'help' },
})
9.16 versions
Inspect the versions of zx and its bundled dependencies:
import { versions } from 'zx'
versions.zx // e.g. '8.9.0'
versions.chalk // e.g. '5.6.2'
10. Common Workflows
10.1 Run commands in parallel
import { spinner } from 'zx'
const tests = await glob('test/*.test.js')
await spinner('running tests', async () => {
const res = await Promise.all(tests.map((file) => $`npx uvu . ${file}`))
res.forEach((r) => console.log(r.toString()))
console.log(chalk.bgGreen.black(' SUCCESS '))
})
10.2 Drive an interactive program
const p = $`npm init`.stdio('pipe')
for await (const chunk of p.stdout) {
if (chunk.includes('package name:')) p.stdin.write('test\n')
if (chunk.includes('version:')) p.stdin.write('1.0.0\n')
if (chunk.includes('Is this OK?')) p.stdin.write('yes\n')
}
10.3 Manage a background process
const serve = $`npx serve`
for await (const chunk of serve.stdout) {
if (chunk.includes('Accepting connections')) break
}
await $`curl http://localhost:3000`
serve.kill('SIGINT')
10.4 Fetch and display remote data
const data = await spinner(`Fetching weather...`, async () => {
const res = await fetch('https://wttr.in/London?format=j1', {
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw `API error: ${res.status}`
return res.json()
})
const current = data.current_condition[0]
echo(chalk.yellow(`🌡️ ${current.temp_C}°C, humidity ${current.humidity}%`))
10.5 Clone a list of repositories
const username = await question('GitHub username? ')
const res = await fetch(`https://api.github.com/users/${username}/repos?per_page=100`)
const data = await res.json()
await $`mkdir -p backups`
cd('./backups')
for (const repo of data) {
await $`git clone ${repo.git_url}`
}
10.6 Apply a configuration preset with $({...})
The $ object holds the default configuration. Call it as a factory to create a customized, reusable runner. Presets are chainable:
const $$ = $({ verbose: false, env: { NODE_ENV: 'production' } })
await $$`node -e 'console.log(process.env.NODE_ENV)'`
// Chaining merges options
const $1 = $({ nothrow: true })
const $2 = $1({ sync: true }) // both nothrow + sync apply
Pipe stdin into a command:
const p = $({ input: 'foo' })`cat`
const p2 = $({ input: Buffer.from('baz') })`cat`
const p3 = $({ input: anotherProcessPromise })`cat`
11. TypeScript
zx is written in TypeScript and ships its own type definitions (compatible with TS 4.0+). Write scripts in .ts, .mts, or .cts.
// script.ts
import { $ } from 'zx'
const list = await $`ls -la`
To run TypeScript with Node.js, configure your project for ES modules ("type": "module" in package.json, "module": "ESNext" in tsconfig.json), then use one of:
node (strip types)
# Node.js v22.6.0+
node --experimental-strip-types script.ts
tsx
npm install tsx
tsx script.ts
ts-node
npm install ts-node
node --loader ts-node/esm script.ts
bun
bun script.ts
deno
deno run --allow-read --allow-sys --allow-env --allow-run script.ts
Important
The bundled type definitions require you to install
@types/nodeand@types/fs-extraon your side:npm i -D @types/fs-extra @types/node
The Options interface is exported for typing presets:
import { type Options } from 'zx'
const opts: Options = { quiet: true, timeout: '5s' }
12. Markdown Scripts
zx can execute a Markdown file directly, running its fenced code blocks as a single script. Blocks tagged js, javascript, ts, typescript, sh, shell, or bash are executed; all other languages (and prose) are treated as comments.
# Deploy script
First, check the current directory:
```js
const {stdout} = await $`ls -l`
console.log('contents:', stdout)
```
Then run a bash step:
```bash
ls -l
```
Run it:
zx script.md
Note
Inside a Markdown script,
__filenamepoints to the Markdown file itself. You can use dynamicimport()for additional packages.
13. Command-Line Interface
Run a script by passing it to the zx executable. npx and node invocations also work:
zx script.mjs
npx zx script.mjs
node -r zx/globals script.mjs
node --import zx/globals script.mjs
13.1 Input sources
zx can read a script from a file, a URL, standard input, or an inline string.
# Remote script (downloaded and executed)
zx https://raw.githubusercontent.com/google/zx/refs/heads/main/examples/hello.mjs
# From stdin
zx << 'EOF'
await $`pwd`
EOF
# Inline evaluation
cat package.json | zx --eval 'const v = JSON.parse(await stdin()).version; echo(v)'
Caution
When running a remote script, make sure you trust the source and have reviewed the code.
zxdownloads and executes it directly.
13.2 File extensions
- A script with no extension (e.g.
.git/hooks/pre-commit) is treated as ESM, unless--extis given. - For a non-JS-like extension, pass
--extto tellzxhow to load it:
zx script.zx # Error: Unknown file extension "\.zx"
zx --ext=mjs script.zx # OK
13.3 CLI options
| Option | Alias | Description |
|---|---|---|
--quiet |
Suppress all output | |
--verbose |
Enable verbose mode (print commands and output) | |
--shell=<path> |
Use a custom shell binary (default: bash) |
|
--prefix=<command> |
Prepend a command to every command run | |
--postfix=<command> |
Append a command to every command run | |
--prefer-local[=<dir>] |
-l |
Prefer locally installed packages and binaries |
--cwd=<path> |
Set the working directory | |
--eval=<js> |
-e |
Evaluate the argument as a script |
--ext=<.mjs> |
Override the default script extension (.mjs) |
|
--install |
-i |
Auto-install dependencies imported by the script |
--registry=<URL> |
npm registry (default https://registry.npmjs.org/) |
|
--env=<path> |
--env-file |
Path to an env file to load |
--repl |
Start an interactive REPL | |
--version |
-v |
Print the zx version |
--help |
-h |
Print help |
Note
The
--experimentalflag still parses but is deprecated and no longer required.
13.4 Auto-installing dependencies
With --install, zx reads the imports in your script and installs missing packages before running:
// script.mjs
import sh from 'tinysh'
sh.say('Hello, world!')
zx --install script.mjs
Pin a version with a trailing comment after the import:
import sh from 'tinysh' // @^1
Use a custom registry:
zx --registry=https://registry.yarnpkg.com script.mjs
13.5 Prefix, postfix, cwd, and env
zx --prefix='echo foo;' --postfix='; echo bar' script.mjs
zx --cwd=/foo/bar script.mjs
zx --env=/path/to/some.env script.mjs
Tip
When both
--cwdand a relative--envare given,--cwdis the base path:--cwd='/foo/bar' --env='../.env'resolves to/foo/.env.
13.6 Configuring via environment variables
Every option above can be set with a ZX_-prefixed environment variable — handy in CI:
ZX_VERBOSE=true ZX_SHELL='/bin/bash' zx script.mjs
steps:
- name: Run script
run: zx script.mjs
env:
ZX_VERBOSE: true
ZX_SHELL: '/bin/bash'
13.7 ESM conveniences: __filename, __dirname, require()
In ESM modules these globals are normally undefined. When you run a .mjs file through the zx executable, zx provides them:
const { version } = require('./package.json')
echo(`${__filename} in ${__dirname}`)
13.8 REPL mode
zx --repl
Starts an interactive Read-Eval-Print Loop with all zx globals available. History is stored in ~/.zx_repl_history (override with the ZX_REPL_HISTORY environment variable).
14. Troubleshooting
Error: Output gets truncated
Cause: Calling process.exit() can cut off buffered console.log() output, because terminal vs. file output buffering differs in Node.js (nodejs/node#6379).
Solution: Set process.exitCode = 1 and let the process exit on its own instead of calling process.exit(). As a workaround, write to a temp file and read it back:
const tmp = await $`mktemp`
const { stdout } = await $`cmd > ${tmp}; cat ${tmp}`
Prevention: Avoid process.exit() in scripts that print output right before exiting.
Error: Subprocess output has no colors
Cause: A subprocess detects it is not attached to a TTY and disables colors.
Solution: Force colors via an environment variable:
process.env.FORCE_COLOR = '1'
await $`cmd`
Prevention: Set FORCE_COLOR early in scripts whose subprocess output you display.
Error: Too late to kill the process. / Too late to abort the process.
Cause: You called kill() or abort() after the process had already settled.
Solution: Only kill/abort while the process is running. Check p.stage === 'running' before acting, or attach the kill inside a setTimeout shorter than the command's runtime.
Prevention: Design timeouts with .timeout() instead of manual kill races where possible.
Error: A command "doesn't work" with a glob, ~, or a built string
Cause: zx escapes everything inside ${...}, so glob patterns, ~, and pre-assembled command strings are taken literally.
Solution: Use glob() for patterns, os.homedir() for ~, and pass an array of arguments rather than a concatenated string. See §5 Working with Arguments and Quoting.
Prevention: Treat each interpolation as a single, pre-escaped value — never as raw shell syntax.
Error: PowerShell commands fail on Windows
Cause: Since v8, zx no longer auto-detects PowerShell; it defaults to bash and bash quoting.
Solution: Call usePowerShell() (Windows PowerShell) or usePwsh() (PowerShell v7+) at the top of your script.
Prevention: Set the shell explicitly when targeting Windows; consider WSL or Git Bash for bash compatibility.
Issue: child_process does not see my aliases or shell functions
Cause: Spawned processes do not load your interactive shell profile.
Solution: Append the needed setup to $.prefix:
$.prefix += 'export NVM_DIR=$HOME/.nvm; source $NVM_DIR/nvm.sh; '
await $`nvm -v`
15. Frequently Asked Questions
Q: Do I need to import anything to use $?
No — when you run a script through the zx executable, all helpers are global. To run with plain node (or get editor autocomplete), add import 'zx/globals', or import individual helpers from 'zx'.
Q: How do I pass environment variables to a command?
Set process.env.FOO = 'bar' before the call, or scope it with a preset: $({ env: { FOO: 'bar' } })\echo $FOO``.
Q: Why is there no output from my commands?
By default $.verbose is false, so successful command output is captured but not echoed to the terminal — only stderr is shown. Set $.verbose = true (or use .verbose() per command) to see everything. The output is always available on the returned ProcessOutput.
Q: How do I completely silence a script?
Set $.quiet = true (or use .quiet() per command). This suppresses stdout and stderr logging.
Q: How do I run a command without throwing on failure?
Use .nothrow() or the $({ nothrow: true }) preset, then inspect o.exitCode and o.ok.
Q: Can I use zx in GitHub Actions?
Yes. The default runner has npx, so you can run npx zx <<'EOF' ... EOF directly. Set FORCE_COLOR: 3 in env if you want colored output.
Q: What happened to the ssh API?
It was removed in v8. Install the webpod package and use its ssh() helper instead.
Q: Is zx@lite the same as zx?
It shares the core ($, ProcessPromise, ProcessOutput, cd, chalk, …) but drops the CLI, argv, echo, fetch, glob, and several extras. Use it for embedding zx in your own tooling. See the feature matrix in the versions docs.
Q: How do I migrate a v7 script to v8?
Set $.verbose = true to restore v7-style logging, enable syncProcessCwd() for the old cwd behavior, replace ssh with the webpod package, and call usePowerShell()/usePwsh() if you relied on Windows PowerShell. See the migration guide.
Q: Which runtimes are supported?
Node.js >= 12.17.0, Bun >= 1.0.0, Deno 1.x/2.x, and GraalVM Node.js, on Linux, macOS, and Windows.
Q: Can I try experimental builds?
Yes: npm i zx@dev. These are beta in every sense — don't use them in production.
16. Reference
16.1 Global functions and objects
| Name | Kind | Summary |
|---|---|---|
$ |
function/object | Run a command; holds default config; usable as a preset factory |
$.sync |
function | Run a command synchronously |
cd() |
function | Change the working directory |
within() |
function | Run a callback in an isolated config context |
syncProcessCwd() |
function | Keep process.cwd() in sync with cd() |
fetch() |
function | HTTP requests (pipe-friendly) |
question() |
function | Interactive prompt |
stdin() |
function | Read standard input as a string |
echo() |
function | Print to stdout (ProcessOutput-aware) |
sleep() |
function | Delay for a duration |
retry() |
function | Retry a callback N times |
expBackoff() |
generator | Exponential backoff delays for retry() |
spinner() |
function | CLI progress spinner |
glob() / globby |
function | File globbing |
which() |
function | Locate a binary |
kill() |
function | Kill a process by PID |
ps |
object | Cross-platform process listing |
tempdir() / tmpdir() |
function | Create a temp directory |
tempfile() / tmpfile() |
function | Create a temp file |
argv |
object | Parsed CLI arguments (minimist) |
minimist |
function | Argument parser |
chalk |
object | Terminal colors |
fs |
object | fs-extra |
os, path |
object | Node os / path |
YAML, MAML |
object | YAML / MAML parsers |
dotenv |
object | .env parsing and loading |
quote(), quotePowerShell() |
function | Argument quoting functions |
useBash(), usePowerShell(), usePwsh() |
function | Select shell + quoting |
versions |
object | Versions of zx and bundled deps |
nothrow(), quiet() |
function | Deprecated — use $\cmd`.nothrow()/.quiet()` |
16.2 ProcessPromise methods and getters
| Member | Description |
|---|---|
pipe() / .pipe.stdout / .pipe.stderr / .pipe.stdall |
Redirect output to a process, stream, or file |
unpipe() |
Remove a destination from the pipeline |
nothrow() |
Do not throw on non-zero exit |
quiet() |
Suppress output |
verbose() |
Print command and output |
timeout(duration, signal?) |
Kill after a duration |
kill(signal?) |
Kill the process (default SIGTERM) |
abort(reason?) |
Abort via AbortController |
stdio(in, out?, err?) |
Configure standard streams |
run() |
Start a halted process |
json(), text(), lines(), buffer(), blob() |
Output formatters |
stdin, stdout, stderr |
Process streams |
exitCode |
Promise resolving to the exit code |
pid, cwd, cmd, fullCmd, stage |
Process metadata |
[Symbol.asyncIterator] |
Iterate stdout line by line |
16.3 ProcessOutput members
| Member | Description |
|---|---|
stdout, stderr, stdall |
Captured output streams |
exitCode |
Exit code (0 = success) |
signal |
Terminating signal, or null |
ok |
true if exit code is 0 and no error |
json(), text(encoding?), lines(delimiter?), buffer(), blob(type?) |
Output formatters |
toString() |
Combined stdout + stderr |
valueOf() |
Trimmed toString() |
16.4 Duration format
Durations accept a number of milliseconds or a string: '500ms', '30s', '5m', or a bare number string like '1000'.
16.5 Useful environment variables
| Variable | Effect |
|---|---|
ZX_VERBOSE, ZX_QUIET, ZX_SHELL, ZX_PREFIX, ZX_POSTFIX, ZX_CWD, … |
Set the matching $ option (any CLI option has a ZX_-prefixed form) |
ZX_REPL_HISTORY |
Path to the REPL history file (default ~/.zx_repl_history) |
FORCE_COLOR |
Force colored output in subprocesses |
CI |
When set, disables the spinner() animation |