Jesse A. Green / notebook

zx User Guide

Generated June 2026 from google/zx at b6bc975

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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

zx is 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 .mjs extension so you can use top-level await without any boilerplate. All zx helpers ($, cd, echo, fetch, …) are injected as globals automatically when you run a script through the zx executable — you do not need any import statements.


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

zx relies on bash by default. On Windows, install Windows Subsystem for Linux (WSL) or Git Bash, or switch to PowerShell with usePowerShell() / 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 lite channel omits the CLI, argv, echo, fetch, glob, and several other extras. See §13 Reference for the full feature matrix. Pick lite when you embed zx inside 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 .js extension 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 zx escapes interpolated values for you, wrapping them in extra quotes can actually re-enable injection. Do not write $`echo "$'${userInput}'"`. Let zx handle 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 ProcessPromise or Promise, 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(), and usePwsh() helpers set both the shell and the matching quote function for you. Prefer those over setting $.quote by 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 ProcessOutput as an argument into another $ command, zx uses its stdout and 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() or abort() 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 process stage if 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, zx does not auto-detect PowerShell on Windows. If you need it, call usePowerShell() or usePwsh() explicitly. The ssh API 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() calls process.chdir() internally, so it changes the global working directory. To keep process.cwd() in sync across separate $ calls, enable syncProcessCwd().

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

glob is also exported under the alias globby.

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 CI environment variable is set) and when $.quiet is 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/tempfile are also exported under the aliases tmpdir/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/node and @types/fs-extra on 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, __filename points to the Markdown file itself. You can use dynamic import() 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. zx downloads and executes it directly.

13.2 File extensions

  • A script with no extension (e.g. .git/hooks/pre-commit) is treated as ESM, unless --ext is given.
  • For a non-JS-like extension, pass --ext to tell zx how 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 --experimental flag 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 --cwd and a relative --env are given, --cwd is 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