Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

zx Admin & Architecture 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.

This guide is for engineers and architects who need to understand how zx is built, how to configure and operate it reliably, how to secure scripts that use it, and how to extend or embed it. For end-user task documentation (installing, writing scripts, the helper API), see the companion User Guide.


1. Executive Summary

zx is a Node.js library and CLI for writing shell scripts in JavaScript/TypeScript. It wraps child_process in a tagged-template API ($`cmd`) that automatically escapes interpolated arguments, captures output, and provides ergonomic helpers for piping, retries, parallelism, HTTP, and file operations.

Attribute Value
Project zx
Version 8.9.0
License Apache-2.0
Author Anton Medvedev
Publisher Google (not an officially supported Google product)
Language TypeScript 5 (ESM-first, hybrid CJS/ESM output)
Runtimes Node.js >= 12.17.0, Bun >= 1.0.0, Deno 1.x/2.x, GraalVM Node.js
Platforms Linux, macOS, Windows
Binary zx → build/cli.js
Homepage https://google.github.io/zx/
Repository https://github.com/google/zx
Execution engine zurk

Design philosophy. zx does not replace bash — it augments it with JavaScript's control flow, data handling, exception handling, and parallelism, while delegating actual command execution to a real shell (bash by default). The library is deliberately small and bundled, with a strong emphasis on supply-chain auditability and broad runtime compatibility.

Note

zx is distributed in multiple channels (latest, lite, dev, legacy) and through many registries (npm, JSR @webpod/zx, GitHub Container Registry, Homebrew). The lite channel is the core engine without the CLI or extra helpers.


2. Architecture

2.1 Component overview

                         ┌──────────────────────────────────────┐
   zx CLI (bin)          │  src/cli.ts                           │
   zx script.mjs ──────► │  parseArgv → main() → readScript()    │
                         │            → runScript() (import())    │
                         └───────────────┬──────────────────────┘
                                         │ injects globals, presets
                                         ▼
   import {$} from 'zx'   ┌──────────────────────────────────────┐
   import 'zx/globals' ─► │  src/index.ts (public barrel)         │
                         │   ├── core.ts  ($, ProcessPromise,     │
                         │   │             ProcessOutput, cd,     │
                         │   │             kill, Options)         │
                         │   ├── goods.ts (echo, fetch, question, │
                         │   │             retry, spinner, glob…) │
                         │   ├── error.ts (Fail, exit/errno maps) │
                         │   ├── log.ts   (LogEntry, formatters)  │
                         │   ├── md.ts    (markdown → script)     │
                         │   └── vendor*  (chalk, which, ps, fs,  │
                         │                 yaml, globby, minimist)│
                         └───────────────┬──────────────────────┘
                                         │ exec()
                                         ▼
                         ┌──────────────────────────────────────┐
                         │  zurk/spawn  (execution engine)       │
                         │  → child_process.spawn / spawnSync    │
                         └──────────────────────────────────────┘

2.2 Source module map (src/)

Module Responsibility
core.ts The heart: the $ factory, Options, defaults, Snapshot, ProcessPromise, ProcessOutput, cd(), kill(), within(), resolveDefaults(), shell selectors (useBash/usePwsh/usePowerShell), syncProcessCwd()
goods.ts High-level helpers: echo, fetch, question, stdin, sleep, retry, expBackoff, spinner, tempdir/tempfile, parseArgv/updateArgv/argv
cli.ts CLI entry point and script preprocessor: argv parsing, main(), readScript(), runScript(), printUsage(), injectGlobalRequire(), isMain()
deps.ts Dependency analysis and installation (parseDeps, installDeps) for the --install flag
error.ts Fail error class; EXIT_CODES and ERRNO_CODES maps; stack/caller-location parsing; message formatting
log.ts LogEntry union type, the default log function, and per-kind formatters (including the formatCmd syntax highlighter)
md.ts transformMarkdown() — converts a Markdown file into a runnable script by extracting js/ts/bash code fences
repl.ts startRepl() — interactive REPL with history
util.ts Generic helpers: quote/quotePowerShell, parseDuration, toCamelCase, parseBool, getLines, preferLocalBin, once, proxyOverride, etc.
internals.ts The internal bus (a lockable proxy registry used to override bundled dependencies)
globals.ts / globals-jsr.ts Populate globalThis with all exports and declare global types
index.ts Public API barrel; re-exports core + goods + selected vendor packages; defines VERSION
vendor-core.ts / vendor-extra.ts / vendor.ts Wrapped re-exports of third-party packages (chalk, which, ps, zurk/spawn, globby, yaml, maml, fs-extra, minimist, envapi, node-fetch-native, depseek, create-require)
versions.ts Pinned version strings for zx and its bundled dependencies

2.3 The $ factory and async context

$ is a Proxy-wrapped function backed by an AsyncLocalStorage<Options> store. This is what lets configuration be both global and locally scoped:

const storage = new AsyncLocalStorage<Options>()
const getStore = () => storage.getStore() || defaults

export function within<R>(callback: () => R): R {
  return storage.run({ ...getStore() }, callback)
}
  • Calling $`cmd` reads the current store, builds a Snapshot of the effective options, and constructs a ProcessPromise.
  • Calling $({...}) returns a new $ whose store is merged with the supplied options — this is how presets and chaining work.
  • within() runs a callback inside a forked store, so mutations to $ (and cd()) inside it do not leak out.

2.4 ProcessPromise lifecycle

ProcessPromise extends Promise<ProcessOutput>. It moves through five stages:

Stage Meaning
initial Constructed but not yet armed
halted Created with {halt: true}; awaits an explicit .run()
running Process spawned; output streaming
fulfilled Completed with exit code 0 (or nothrow)
rejected Completed with a non-zero exit code or spawn error

Three internal gears drive it:

Method Role
build() Builds the final cmd string from the template pieces and args, applying the quote function; validates shell/quote availability and sync constraints
run() Calls zurk's exec() to spawn the process, wires up start/stdout/stderr/end event listeners, and arms the timeout
finalize() Constructs the ProcessOutput, resolves or rejects the promise based on exit status and nothrow

Important

ProcessPromise instances must be created through $, not via new ProcessPromise(). Direct construction is disarmed (disarm()) and accessing its methods throws "Inappropriate usage. Apply $ instead of direct instantiation."

2.5 The pipe bus

Piping is implemented with a static bus (a Map of source→destinations plus a WeakMap of promisified streams). Two properties make zx piping unusual:

  1. Stream-kind selection — pipe.stdout, pipe.stderr, and pipe.stdall let you pipe a specific stream.
  2. Replay ("time machine") — output chunks are buffered, so you can attach a consumer to a process after it has already produced output or even settled. The bus replays buffered chunks in order, then forwards live data.

This also enables fan-out (one source → many consumers) and merge (many sources → one consumer via halted processes).

2.6 The dependency bus and bundling

internals.ts exposes a small bus that wraps each bundled third-party package in a Proxy. Wrapped packages can be overridden at runtime before the bus is locked (index.ts calls bus.lock() on import). This indirection lets zx ship a single static bundle while still allowing the lite build and tests to swap implementations.


3. Build & Packaging

zx uses a bespoke esbuild-based toolchain (the authors documented why). The package is a hybrid distribution: it provides both CJS and ESM entry points and bundles its dependencies for reproducibility and auditability.

3.1 Entry points

package.json exports define four public subpaths:

Subpath Purpose
zx Main entry — all features
zx/globals Populate globalThis with zx functions
zx/cli The CLI runner
zx/core The template spawner core, for building 3rd-party tools on top of zx

The binary is declared as "bin": { "zx": "build/cli.js" } and the man page as "man": "./man/zx.1".

3.2 Build scripts

npm script Action
build Runs versions → js → dts → tests sub-builds
build:js esbuild hybrid CJS bundle for cli, core, deps, globals, index, internals, util, vendor*
build:vendor Bundles vendor-*.ts with all deps inlined (--bundle=all)
build:dts tsc then build-dts.mjs (merges 3rd-party types via dts-bundle-generator)
build:tests Generates export-consistency autotests
build:dcr Builds the Docker image (dcr/Dockerfile, base node:24-alpine)
build:jsr Builds JSR publishing assets
build:lite / build:pkgjson / build:manifest Produce the lite channel and package manifests

Note

Bundling solves four goals at once: smaller install/footprint, reproducible npx/dlx/bunx invocations, support for Node 12–25, and easier auditing (all code in one place).

3.3 Runtime dependencies (bundled)

These versions are pinned in src/versions.ts and bundled into the published artifacts:

Package Version Role
zurk 0.11.10 Process execution engine (exec, buildCmd, VoidStream)
chalk 5.6.2 Terminal colors
which 7.0.0 Locate binaries
@webpod/ps 1.2.1 Cross-platform process listing/tree
globby 16.2.0 Glob matching
yaml 2.8.4 YAML parsing
maml ^1.0.0 MAML parsing
fs-extra 11.3.5 Extended fs API
create-require 1.1.1 require() shim for ESM
node-fetch-native 1.6.7 fetch implementation
depseek 0.4.6 Import scanning for --install
minimist 1.2.8 Argument parsing
envapi 0.2.3 .env parsing/loading (dotenv)

Tip

versions is exported at runtime, so an operator can assert the exact bundled dependency versions in a health check: import { versions } from 'zx'; assert(versions.zx === '8.9.0').


4. Configuration Reference

All configuration lives on the $ object (and its defaults). Options can be set globally ($.option = …), per-invocation via a preset ($({option})), through CLI flags, or via ZX_-prefixed environment variables.

4.1 The Options interface

Option Type Default Description
cwd string process.cwd() Working directory for spawned processes
ac AbortController auto Abort controller for the process
signal AbortSignal from ac Abort signal
input string | Buffer | Readable | ProcessOutput | ProcessPromise — stdin to feed the command
timeout Duration — Auto-kill after this duration
timeoutSignal NodeJS.Signals SIGTERM Signal used on timeout
stdio StdioOptions 'pipe' Standard stream configuration
verbose boolean false Print commands and their output
sync boolean false Synchronous execution
env NodeJS.ProcessEnv process.env Environment variables
shell string | true which bash Shell binary (or true for default)
nothrow boolean false Do not throw on non-zero exit
prefix string set -euo pipefail; (bash) Prepended to every command
postfix string ; exit $LastExitCode (pwsh) Appended to every command
quote typeof quote bash quote Argument-escaping function
quiet boolean false Suppress all output
detached boolean false Spawn detached
preferLocal boolean | string | string[] false Prefer node_modules/.bin binaries
spawn typeof child_process.spawn native Custom spawn implementation
spawnSync typeof child_process.spawnSync native Custom sync spawn implementation
store TSpawnStore — Custom output store
log typeof log built-in Logging function
kill typeof kill built-in Process killer
killSignal NodeJS.Signals SIGTERM Default kill signal
halt boolean false Create without auto-running
delimiter string | RegExp /\r?\n/ Line split delimiter for lines()/iteration

4.2 Key configuration knobs

$.shell — Path to the shell binary. Default is which bash. CLI: --shell=/bin/bash.

$.prefix / $.postfix — Wrap every command. Default prefix is set -euo pipefail; (fail-fast bash). The postfix ; exit $LastExitCode is used for PowerShell compatibility.

$.preferLocal — Prepend node_modules/.bin to PATH so local binaries win over global ones. Accepts a boolean or one/many directories.

$.kill — The default implements a half-graceful shutdown by walking the process tree via ps.tree(). Override for custom logic (e.g. tree-kill).

$.log — A function receiving LogEntry objects (cmd, stdout, stderr, end, cd, fetch, retry, kill, custom). Override $.log.output to change the stream and $.log.formatters to customize rendering. Useful for masking secrets in logged commands.

$.delimiter — Controls how lines() and async iteration split output. Set to /\0/ for null-delimited tools (find -print0).

import { LogEntry, log } from 'zx/core'

$.log = (entry: LogEntry) => {
  if (entry.kind === 'cmd') process.stderr.write(masker(entry.cmd))
  else log(entry)
}

4.3 Environment-variable configuration

resolveDefaults() reads process.env for ZX_-prefixed keys and maps the allowed subset onto $. The allow-list (ENV_OPTS) is:

cwd, preferLocal, detached, verbose, quiet, timeout,
timeoutSignal, killSignal, prefix, postfix, shell

Keys are camel-cased after stripping the ZX_ prefix, and values are coerced with parseBool. The CLI additionally honors ZX_-prefixed forms of all its flags.

Variable Effect
ZX_VERBOSE, ZX_QUIET, ZX_SHELL, ZX_PREFIX, ZX_POSTFIX, ZX_CWD, ZX_PREFER_LOCAL, ZX_TIMEOUT, … Set the corresponding $ option
ZX_REPL_HISTORY Path to REPL history (default ~/.zx_repl_history)
FORCE_COLOR Force subprocess colors
CI Disables the spinner() animation

5. Deployment & Operations

5.1 Running in containers

The official image is published to the GitHub Container Registry, built FROM node:24-alpine (see dcr/Dockerfile):

docker pull ghcr.io/google/zx:8.5.0

# Inline eval
docker run -t ghcr.io/google/zx:8.5.0 -e="await \$({verbose: true})\`echo foo\`"

# Mount a script directory
docker run -t -i -v ./:/script ghcr.io/google/zx:8.5.0 script/t.js

5.2 CI usage

The default GitHub Actions runner ships npx, so no install step is needed:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build with zx
        env:
          FORCE_COLOR: 3
        run: |
          npx zx <<'EOF'
          await $`...`
          EOF

Tip

Prefer ZX_-prefixed env vars in CI (e.g. ZX_VERBOSE: true) over editing each script. They apply across every command the runner executes.

5.3 Operational checklist

  • [ ] Bash (or PowerShell) is available on the target host; on Windows, decide between WSL/Git Bash and usePowerShell()/usePwsh().
  • [ ] The chosen runtime meets the version floor (Node >= 12.17.0, Bun >= 1.0.0, Deno 1.x/2.x).
  • [ ] For Deno, grant --allow-read --allow-sys --allow-env --allow-run.
  • [ ] FORCE_COLOR set if colored subprocess output is expected.
  • [ ] Scripts avoid process.exit() immediately after console.log() (see Troubleshooting in the User Guide).
  • [ ] Timeouts ($.timeout / .timeout()) set for any network or long-running command.
  • [ ] For embedding inside another tool, evaluate zx@lite or zx/core.

5.4 Behavioral defaults to know

  • $.verbose is false by default — successful command output is captured but not echoed; only stderr is shown.
  • Process cwd synchronization is off by default; enable syncProcessCwd() if cd() must affect process.cwd() globally.
  • The spinner() is disabled under CI.
  • Sync mode cannot resolve async-interpolated values inside a template.

6. Security Analysis

zx executes shell commands, so it is inherently powerful and must be treated as a privileged tool. The following analysis reflects the actual code paths.

6.1 Argument escaping (defense)

The primary safety mechanism is automatic escaping of interpolated values. quote() (bash) emits C-style $'...' quoting; quotePowerShell() emits single-quote-doubled strings. This neutralizes shell metacharacters in user-supplied data:

const name = 'foo & bar'
await $`mkdir ${name}`   // safe; '&' is not interpreted

Warning

Do not defeat the escaper. Wrapping an interpolation in your own quotes can reintroduce injection. The documented anti-pattern:

const args = ['param && echo bar']
const p = $`echo --foo=$'${args}'`
(await p).stdout // '--foo=$param\nbar\n'  ← command injection

Always interpolate a bare ${value} (or an array of values) and let zx quote it.

6.2 Command-injection surface via env-driven prefix/postfix

$.prefix and $.postfix are prepended/appended to every command without escaping (they are intended to carry shell directives). Because ZX_PREFIX and ZX_POSTFIX environment variables map onto these options via resolveDefaults(), an attacker who controls the environment can inject arbitrary commands into every invocation (tracked upstream as issue #1435, "ZX_PREFIX / ZX_POSTFIX allow implicit global command injection via environment variables").

Caution

Treat the process environment as a trust boundary. In multi-tenant or untrusted-input contexts, sanitize or strip ZX_* variables before running zx, and never derive $.prefix/$.postfix from user input.

6.3 Remote and dynamic code execution

  • zx <url> downloads and executes a remote script. Only run trusted URLs.
  • --eval/-e and stdin scripts execute arbitrary code by design.
  • --install parses imports (via depseek) and runs npm install for missing packages. The dependency parser inherits depseek's limitations; note the upstream bug where node:-prefixed built-in imports were mis-resolved to node@latest (issue #1462, fixed).

Important

--install shells out to the package manager with --no-save --no-audit --no-fund. In hardened environments, prefer committing dependencies explicitly over relying on --install, and pin the --registry.

6.4 Secrets handling

Commands are logged through $.log when verbose. To avoid leaking secrets into logs, override the cmd formatter to mask sensitive substrings (see §4.2). Avoid passing secrets as command arguments where a child process or ps listing could expose them; prefer $.env injection.

6.5 Supply-chain posture

The project bundles its dependencies into a single auditable artifact, runs npm audit checks (test:audit), enforces a license check (test:license), and runs the zizmor GitHub Actions security linter (test:workflow, configured by zizmor.yml). The lite channel reduces the audited surface further.

6.6 Known security-relevant issues

Issue Status Note
#1435 ZX_PREFIX/ZX_POSTFIX injection Open Env-driven prefix/postfix is unescaped by design; sanitize env
#1462 --install treats node: imports as node@latest Closed Dependency-parse edge case
#1443 Unhandled rejection in fetch().pipe() on stream error Open Wrap piped fetches in error handling
#1461 $ throws synchronously on build failure Closed Build-time errors now surface as rejected promises

7. Error Handling Internals

The Fail class (src/error.ts) centralizes error handling:

  • EXIT_CODES — maps shell exit codes to human-readable meanings (e.g. 2 → "Misuse of shell builtins", 127 → "Command not found", 137 → "Kill (terminate immediately)").
  • ERRNO_CODES — maps libc errno values to descriptions (e.g. -2 → "No such file or directory").
  • formatExitMessage() / formatErrorMessage() / formatErrorDetails() — build the human-readable ProcessOutput.message, including the caller location, exit code annotation, signal, and a sampled extract of error lines.
  • getCallerLocation() — parses the stack (supporting both V8 and JSC stack formats) to attribute the failing command to its source line.

ProcessOutput extends Error, so a rejected command is a throwable that also carries exitCode, signal, stdout, stderr, and ok.


8. Logging & Observability

The log.ts module defines a discriminated-union LogEntry and a pluggable log function. Each entry has a kind:

Kind Emitted when Gated by
cmd A command starts verbose
stdout stdout data (unless piped) verbose
stderr stderr data not quiet
end A command finishes verbose
cd cd() is called verbose, not quiet
fetch fetch() is called verbose, not quiet
retry A retry() attempt fails verbose, not quiet
kill kill() is called verbose, not quiet
custom User-defined verbose

The verbose/quiet matrix (only stderr is shown by default):

Event Verbose Quiet
stdout shown hidden
stderr shown hidden
cmd shown hidden
fetch / cd / retry / custom shown hidden

formatCmd() provides syntax highlighting for logged commands (greens for the command, yellow for quotes/$, red for operators, cyan for reserved words). Output defaults to process.stderr; redirect via $.log.output.


9. Testing Strategy

zx invests heavily in correctness across runtimes. The pretest step builds the bundles first, because tests run against production artifacts, not raw source.

Category Scripts
Unit test:unit (node --experimental-transform-types ./test/all.test.js)
Coverage test:coverage (c8, 98% threshold per the contribution guide)
Artifact integrity test:npm, test:jsr, test:dcr (verify built npm/JSR/Docker artifacts)
Size test:size (size-limit, catches bundle duplication)
Static analysis fmt:check (prettier), test:circular (madge, no circular deps)
Types test:types (tsd)
Smoke / runtime matrix test:smoke:* for strip-types, tsx, tsc, ts-node, bun, win32, deno, cjs, mjs
Supply chain test:license (test/extra.test.js), test:audit, test:workflow (zizmor)

The full runtime × OS matrix is exercised in CI (.github/workflows/test.yml), covering Node 12–25, Bun, Deno, and Windows.


10. Extending & Embedding

10.1 Custom shells

Use useBash(), usePowerShell(), or usePwsh(), or set $.shell + $.quote manually. These helpers set both the binary and the matching quote function (and bash sets the set -euo pipefail; prefix).

10.2 Custom spawn / kill

Override $.spawn, $.spawnSync, and $.kill to integrate with alternative process managers or sandboxes. Example: replacing kill with tree-kill:

import treekill from 'tree-kill'
$.kill = (pid, signal = 'SIGTERM') =>
  new Promise((resolve, reject) =>
    treekill(pid, signal, (err) => (err ? reject(err) : resolve())))

10.3 Building tools on zx/core

Import from zx/core to reuse the template spawner without the extra helpers — ideal for libraries that want the $ ergonomics with their own utility set. The lite channel is the packaged form of this approach.

10.4 Custom output store and delimiters

Provide $.store (a TSpawnStore) to capture output differently, and $.delimiter to control line splitting for binary-safe tools.


11. Migration & Compatibility

Breaking changes from v7 → v8, with restoration paths:

Change Restore v7 behavior
$.verbose defaults to false $.verbose = true (or $.quiet = true to fully silence)
ssh API removed import { ssh } from 'webpod'
No PowerShell auto-detect on Windows usePowerShell() or usePwsh()
cwd sync disabled by default syncProcessCwd()

Note

v7 is in maintenance mode (bugfixes only). v8 is substantially smaller, faster, and safer. The --experimental CLI flag is deprecated and no longer required.


12. Repository Structure

zx/
├── src/                 Core library (TypeScript)
│   ├── core.ts          $, ProcessPromise, ProcessOutput, Options, cd, kill
│   ├── goods.ts         echo, fetch, question, retry, spinner, glob, temp*
│   ├── cli.ts           CLI entry + script preprocessor
│   ├── deps.ts          --install dependency analysis
│   ├── error.ts         Fail, EXIT_CODES, ERRNO_CODES
│   ├── log.ts           LogEntry, log, formatters
│   ├── md.ts            Markdown → script transform
│   ├── repl.ts          REPL
│   ├── util.ts          quote, parseDuration, helpers
│   ├── internals.ts     dependency bus
│   ├── globals*.ts      global injection + type decls
│   ├── vendor*.ts       wrapped 3rd-party deps
│   ├── versions.ts      pinned dep versions
│   └── index.ts         public API barrel
├── scripts/             esbuild-based build toolchain
├── test/                unit, smoke (bun/deno/win32/cjs/mjs), it (artifact)
├── test-d/              type tests (tsd)
├── examples/            runnable sample scripts
├── man/zx.1             man page
├── dcr/Dockerfile       container image (node:24-alpine)
├── docs/                VitePress documentation site
├── package.json         hybrid exports, bin, build scripts
└── tsconfig.json

13. Quick Reference

13.1 Defaults at a glance

Setting Default
Shell which bash
Prefix (bash) set -euo pipefail;
verbose false
quiet false
nothrow false
sync false
detached false
preferLocal false
stdio 'pipe'
killSignal / timeoutSignal SIGTERM
delimiter /\r?\n/
cwd sync disabled

13.2 Entry points

Import Use
zx Everything
zx/globals Inject globals
zx/cli CLI runner
zx/core Spawner core for embedding

13.3 Operational commands

# Run a script
zx script.mjs

# Verbose via env (CI-friendly)
ZX_VERBOSE=true zx script.mjs

# Pin registry, auto-install deps
zx --install --registry=https://registry.npmjs.org/ script.mjs

# Container
docker run -t -i -v ./:/script ghcr.io/google/zx:8.5.0 script/t.js