Bash scripts have a well-earned reputation: they are the fastest way to write a deploy script and the slowest way to debug one. Conditionals, quoting, arrays, and error handling all fight you past the tenth line. That is why a generation of developers moved scripting into Node.js — and why Google’s zx now sits at 45,668 GitHub stars, the most-starred tool in this category by a wide margin. The two other serious options, ShellJS (14,399 stars) and execa (7,586 stars), take completely different philosophies about what “scripting in Node” should mean.
This guide compares all three with real code from the official repositories and live GitHub data as of August 2026. By the end you will know which one to reach for when your next “quick bash script” quietly turns into a 200-line monster.
TL;DR: Quick Verdict
Use zx if you want a bash-like experience in JavaScript — backtick commands, pipes, globs, and a batteries-included API, all without fighting bash syntax. Use execa if you are writing application or library code that spawns processes programmatically — it is the correct, injection-safe way to run commands from a Node app, but it is not a scripting environment. Use ShellJS if you need a drop-in replacement for bash built-ins (cp, rm, cd, sed) that works identically on Windows, Linux, and macOS inside build scripts. The overlap is smaller than it looks: zx replaces the script, execa replaces the process spawn, and ShellJS replaces the shell commands.
Quick Comparison: Feature by Feature
| Feature | zx | ShellJS | execa |
|---|---|---|---|
| GitHub stars (2026-08) | 45,668 | 14,399 | 7,586 |
| Last push (2026-08) | 2026-08-14 | 2026-08-12 | 2026-07-31 |
| Primary use | Full scripting environment | Unix-command shim for Node | Process execution library |
Shell syntax (command) | Yes (tagged template) | No (shell.exec) | Yes (template string) |
| Quoting/escaping | Automatic | Manual (string building) | Automatic, injection-safe |
| Cross-platform commands | Via shell | Yes (built-ins) | Via shell |
| Designed for apps/libraries | No | No | Yes |
| Windows support | Good | Excellent (core goal) | Excellent |
| Async/await | Native | Callbacks/promises | Native |
| Exit-code handling | Throws on failure | Configurable | Throws by default |
The key insight: zx and execa look similar at first glance (both use $`...`-style templates), but they target different layers. zx is a complete scripting runtime — it runs your file with a #! shebang, gives you globs, chalk colors, and a fetch API. execa is a focused library that does one thing extremely well: spawn a process and give you its stdout, stderr, and exit code as structured data.
Use-Case Decision Matrix
| Use Case | Recommendation | Why |
|---|---|---|
| Replace a growing bash deploy script | zx | Real language features + shell ergonomics, no bash quoting hell |
| Spawn commands from a web app or library | execa | Injection-safe, structured results, no global runtime needed |
| Cross-platform build scripts (CI, npm scripts) | ShellJS | cp/rm/sed behave identically on Windows and Unix |
| CLI tool that shells out to other binaries | execa | stdout/stderr as data, shell: true when you need pipes |
| Quick one-off automation with network calls | zx | Built-in fetch, globs, and colored output |
| Node scripts that must run without dependencies | execa (or child_process) | Single tiny dependency, or zero with the core module |
zx: Google’s Batteries-Included Scripting Runtime
zx’s pitch, from the official README: “Bash is great, but when it comes to writing more complex scripts, many people prefer a more convenient programming language. JavaScript is a perfect choice, but the Node.js standard library requires additional hassle before using. No compromise, take the best of both.” The core of that is the tagged template literal — write commands with backticks, and zx handles escaping, quoting, and argument interpolation for you:
| |
Three things make this a scripting language, not a process-spawn helper. First, the #! shebang plus zx on the command line means you can chmod +x the file and run it like a script. Second, ${branch} and ${name} are interpolated safely — including 'foo bar', which becomes a single correctly-quoted argument instead of breaking the command. Third, concurrency is trivial: Promise.all runs the three sleeps in parallel, something bash would need background jobs and wait to express.
zx also ships the conveniences a script needs: a global fetch (no import), $-result objects with stdout/stderr/exitCode, glob support via glob() and fs promises, and chalk-colored output. zx@lite is the zero-dependency variant when you want the same scripting model without installing anything. It runs on Node 12.17+, Bun, Deno, and GraalVM, and works on Linux, macOS, and Windows — genuinely cross-platform for a tool that feels like Unix.
One caution: zx runs commands through a shell, so it is a scripting tool. Do not build an application that passes user-controlled strings into zx — for that, you want execa’s no-shell model (below). For a deploy script, a DB migration runner, or a code-gen pipeline, zx is the least-friction option in 2026.
ShellJS: Unix Commands That Work Everywhere
ShellJS is the oldest of the three and has a different goal entirely: portable Unix shell commands for Node.js. It implements cp, mv, rm, mkdir, sed, grep, cat, cd, and dozens of other built-ins in pure JavaScript, so scripts that use them behave identically on Windows, Linux, and macOS. The canonical example from the README:
| |
The crucial detail is shell.which('git') and shell.exit(1) — the library is designed to fail loudly and stop the script, which is exactly what you want in a build pipeline. Because the commands are real JavaScript functions, shell.ls('*.js').forEach(...) composes naturally with the rest of your code, and the result of every command is inspectable (via .code, .stdout, and .stderr properties).
Where ShellJS shines is CI and npm scripts. A package.json postinstall or prepublish step that needs rm -rf and cp -R fails on Windows runners if you write raw Unix commands — ShellJS does not, because it never invokes the shell for its built-ins. That is its unique value proposition and the reason it remains actively maintained (last push August 2026) despite zx’s popularity. If you pair it with shellcheck-style linting for the bash you still write, see our shell script linting guide.
The trade-off: ShellJS is not a process runner. shell.exec('git pull') exists, but it is string-based — no auto-quoting, no structured stdout — which is fine for fixed commands and a footgun for dynamic ones. For process spawning, you want execa.
execa: Process Execution for Humans
execa is the library every Node application should use to run external commands. Built on top of child_process, it fixes that module’s rough edges: promises instead of callbacks, automatic escaping so there is no shell injection risk, structured results, and sensible defaults for timeout, encoding, and error handling. From the README: “Execa runs commands in your script, application or library. Unlike shells, it is optimized for programmatic usage.”
The modern syntax mirrors zx’s template style:
| |
But the critical difference is what happens by default: no shell. execagit status`` spawns git directly with status as an argument — the arguments are never concatenated into a string, so there is no quoting, no glob expansion, and no injection surface. rm -rf $(user_input) in bash is a catastrophe; in execa it is simply a parameter. When you genuinely need a shell (pipes, redirects, globs), opt in explicitly:
| |
execa’s feature list is long because it solved real production problems: interleaved stdout/stderr output, splitting output into lines and iterating progressively, streaming results, graceful termination (it ensures subprocesses exit even when they intercept termination signals), improved Windows support with shebang handling, and the ability to run locally installed binaries without npx. For a Node backend that shells out to ffmpeg, git, or pg_dump, execa is the difference between robust code and a pile of string concatenation. It composes well with Node’s own tooling ecosystem — see our Node.js job queue comparison for background-process patterns, and the Node.js logging comparison for capturing subprocess output into structured logs.
Migration and Coexistence Strategies
Bash → zx migration. Port line by line: VAR=$(cmd) becomes const VAR = (await $cmd).stdout.trim(), if cmd; then becomes try/catch or exitCode checks, and for f in *.txt becomes for (const f of glob('*.txt')). The two biggest behavioral differences: zx throws on a failing command by default (bash continues), and zx’s cd is process-wide via cd() rather than per-command — scope your directory changes with $.cwd or subshells instead.
child_process → execa migration. This is a straight upgrade for application code: execFile('git', ['status']) becomes execa('git', ['status']), spawn with manual stdout.on('data') becomes for await (const line of subprocess.stdoutLines), and error handling collapses from error.code + error.signal checks into one typed ExecaError. Watch the shell: true default change — if your old code relied on exec('ls *.js') glob expansion, you must pass {shell: true} explicitly.
Coexistence: zx + execa in one project. Nothing stops you using both. zx scripts are usually top-level automation; the same repo’s application code can use execa to run those scripts or any other binary. If you notice zx’s shell-executed commands in app code paths, that is the smell telling you to move them to execa.
npm scripts and ShellJS. When your package.json scripts start accumulating && chains with platform-specific commands, extract them into a scripts/ directory as ShellJS modules invoked via node scripts/build.js. The commands stay portable across developer machines and CI runners.
Common Pitfalls and Performance Traps
1. Injecting user input into shell commands. The single most dangerous pattern: $rm -rf ${userInput}`` in zx, or string interpolation into shell.exec. zx escapes template interpolations, but if you build a command string with +, you have reintroduced injection. Never interpolate unvalidated input into a shell — use execa’s argument array model for untrusted data.
2. Assuming execa expands globs. execacat *.log`` runs cat with the literal argument *.log — no shell means no globbing. Either expand with glob() yourself or pass {shell: true} deliberately. This is the most common “why does this work in bash but not in my Node script” bug.
3. zx throwing on non-zero exits. zx treats a failing command as an exception. In a deploy script where you expect a command to fail sometimes, wrap it: await $cmd.nothrow() or catch and inspect .exitCode — otherwise the script aborts mid-way.
4. ShellJS in production servers. ShellJS’s exec is fine for build tooling, but using it from a long-running web server for per-request operations is an anti-pattern: it spawns a shell per call and its string-based interface encourages injection bugs. Use execa for server-side process spawning.
5. Cross-platform path separators. Scripts that hardcode / in paths work on Linux and break on Windows. With ShellJS, use the provided path handling and built-in commands; with zx and execa, prefer node:path and let the tools quote arguments rather than building path strings into shell syntax.
6. Forgetting to trim() stdout. Command output ends with a newline. (await $git rev-parse HEAD).stdout includes the trailing \n — .trim() it before using it as a value, or your strings silently mismatch.
7. Unbounded output buffering. execa and zx buffer full output in memory. For a command that streams megabytes (build logs, database dumps), use subprocess.stdout streaming or {stdout: 'pipe'} handling — otherwise a long run can exhaust memory.
FAQ
What is the difference between zx and execa?
zx is a complete scripting environment — it runs script files, provides a shell-like $ syntax, and ships conveniences like globs and fetch. execa is a focused library for spawning and controlling processes from Node application code, with a no-shell-by-default model that prevents injection. zx replaces the script; execa replaces child_process.
Does zx work on Windows? Yes. zx supports Linux, macOS, and Windows and runs on Node.js, Bun, Deno, and GraalVM. ShellJS and execa are also fully cross-platform — ShellJS’s core goal is identical command behavior across Windows and Unix.
Is ShellJS still maintained in 2026? Yes — the repository had commits in August 2026. It is in a mature state rather than fast-moving, but it remains the standard choice for portable build scripts, particularly where CI runs on both Windows and Linux runners.
Is it safe to run user-supplied commands with these tools? Only with execa’s default argument-array mode, which never concatenates into a shell string. zx and ShellJS execute through a shell, so treat any interpolated user input as an injection risk. The rule of thumb: application-facing code uses execa; automation with fixed, trusted commands can use zx.
Can I use zx without installing it globally?
Yes — npm install zx locally and run scripts with npx zx script.mjs, or add "zx": {"type": "module"} style project config. zx@lite is the zero-dependency variant for minimal installs.
Which one should I use for npm postinstall scripts?
ShellJS, because it does not depend on a shell being present and its built-ins behave identically on every platform — the exact scenario npm lifecycle scripts run into. zx is a heavier dependency for that narrow use case, and execa does not implement cp/rm built-ins at all.
💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com