child_process is Node.js's built-in module for spawning child processes and executing system commands. cross-env is a utility for setting environment variables in a cross-platform way, commonly used in npm scripts. execa is a popular, promise-based wrapper around child_process that simplifies command execution with better defaults and cross-platform compatibility. node-cmd provides a minimal promise/callback interface for running shell commands but lacks active maintenance and advanced features. shell-quote safely escapes and formats command arguments as shell strings to prevent injection vulnerabilities. shelljs emulates Unix shell commands (like cp, ls, grep) in JavaScript with cross-platform support, allowing shell-like scripting without relying on the system shell.
When building frontend tooling, CI scripts, or development workflows in Node.js, you’ll often need to run shell commands — whether it’s launching a build, checking Git status, or interacting with system utilities. The JavaScript ecosystem offers several packages for this, each with different trade-offs in safety, cross-platform support, and ease of use. Let’s compare the real-world differences between child_process, cross-env, execa, node-cmd, shell-quote, and shelljs.
It’s critical to understand that not all these packages do the same thing. Some execute commands, others just help format them, and one solves a completely different problem.
child_process: Built into Node.js. Lets you spawn child processes (e.g., run ls, git, npm). Low-level but powerful.cross-env: Doesn’t run commands at all. It sets environment variables in a way that works on Windows, macOS, and Linux.execa: A user-friendly wrapper around child_process with better defaults, promise support, and cross-platform fixes.node-cmd: A minimal promise-based wrapper for running shell commands via child_process.exec.shell-quote: Doesn’t execute anything. It safely escapes and formats command arguments as shell strings.shelljs: Emulates Unix shell commands (like cp, mkdir, grep) in JavaScript, with cross-platform support.💡 Key insight: You might use multiple of these together — e.g.,
shell-quoteto safely build a command string, thenexecato run it.
Let’s see how each execution-focused package actually runs a command like git log --oneline -5.
child_process (Node.js built-in)You have three methods: spawn, exec, and execFile. spawn is preferred for long-running or large-output processes.
import { spawn } from 'child_process';
const git = spawn('git', ['log', '--oneline', '-5']);
git.stdout.on('data', (data) => {
console.log(`stdout: ${data}`);
});
git.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});
git.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});
This gives full control but requires manual stream handling and error management.
execaexeca wraps child_process with promises, better defaults, and automatic stdout/stderr buffering.
import { execa } from 'execa';
try {
const { stdout } = await execa('git', ['log', '--oneline', '-5']);
console.log(stdout);
} catch (error) {
console.error('Command failed:', error.stderr || error.message);
}
Much cleaner. Also supports shell: true if you need shell features like pipes.
node-cmdA simpler promise wrapper, but only uses child_process.exec (which buffers all output in memory).
import cmd from 'node-cmd';
cmd.get(
'git log --oneline -5',
(err, data, stderr) => {
if (err) {
console.error('Error:', stderr || err);
return;
}
console.log(data);
}
);
Note: Uses callbacks by default, though you can promisify it. Also, because it uses exec, it’s risky for commands with large output.
shelljsshelljs doesn’t run arbitrary shell commands by default — it provides JavaScript equivalents of common Unix tools.
import shell from 'shelljs';
// This runs `git log --oneline -5` using shell.exec()
const result = shell.exec('git log --oneline -5', { silent: true });
if (result.code === 0) {
console.log(result.stdout);
} else {
console.error(result.stderr);
}
But for many tasks, you’d use its built-ins instead:
// Instead of `cp src dest`, use:
shell.cp('src', 'dest');
// Instead of `mkdir -p build`, use:
shell.mkdir('-p', 'build');
This avoids shell injection risks and works consistently across platforms.
Passing untrusted input to shell commands is dangerous. Consider this naive approach:
// DANGEROUS — don't do this!
execa(`grep ${userInput} file.txt`, { shell: true });
If userInput is '; rm -rf /', you’re in trouble.
execa('grep', [userInput, 'file.txt'])shell-quote when you must build a shell string:import quote from 'shell-quote';
const safeCmd = quote(['grep', userInput, 'file.txt']);
// Produces: "grep 'safe\ input' file.txt"
await execa(safeCmd, { shell: true });
shelljs avoids this entirely by not using the shell for its built-in commands (cp, ls, etc.). But if you use shell.exec(), you’re back in shell territory — so escape inputs there too.
Frontend tooling must work on Windows, macOS, and Linux. Here’s how each package handles it.
child_processWorks everywhere, but you must handle platform differences:
.exe, .bat, .cmd extensions\ vs /)ls don’t exist on WindowsexecaAutomatically appends common extensions (.cmd, .bat) on Windows when needed. Also normalizes signals and exit codes.
// Works on Windows and Unix
await execa('npm', ['run', 'build']);
node-cmdRelies on the system shell, so it inherits all platform quirks. No special handling for Windows.
shelljsExplicitly designed for cross-platform scripting. Its built-in commands (cp, mv, cat, which, etc.) work identically everywhere.
// This works on Windows, macOS, Linux
shell.cp('src/*.js', 'dist/');
cross-env: The Special CaseThis package solves one specific problem: setting environment variables in package.json scripts.
On Unix:
{
"scripts": {
"build": "NODE_ENV=production webpack"
}
}
On Windows, that fails. So you use cross-env:
{
"scripts": {
"build": "cross-env NODE_ENV=production webpack"
}
}
It doesn’t run your main command — it just ensures the env vars are set correctly before handing off to webpack.
You cannot use cross-env to run arbitrary commands or replace execa. It’s a script-line utility, not a general-purpose execution library.
→ Use child_process.spawn directly. Ideal for long-running processes like dev servers.
→ Use execa. It’s the best balance of power, safety, and ergonomics.
→ Use shelljs built-ins. Avoids shell entirely and works everywhere.
→ Use shell-quote to escape inputs, then pass the string to execa({ shell: true }).
→ Use cross-env — and only for that.
node-cmd in new projectsWhile functional, node-cmd hasn’t seen active maintenance in recent years and lacks the safety and feature set of execa. It also uses child_process.exec, which can hang on large outputs. Prefer execa instead.
Imagine you want to create a script that tags the current Git commit with a version from package.json.
Unsafe (don’t do this):
// Vulnerable to injection if pkg.version is malicious
execa(`git tag ${pkg.version}`, { shell: true });
Safe with execa + array args:
import { execa } from 'execa';
import { readFileSync } from 'fs';
const pkg = JSON.parse(readFileSync('package.json'));
// Validate version format here if needed
await execa('git', ['tag', pkg.version]);
Safe with shell-quote + shell:
import quote from 'shell-quote';
import { execa } from 'execa';
const cmd = quote(['git', 'tag', pkg.version]);
await execa(cmd, { shell: true });
Both are safe, but the first is simpler and avoids the shell entirely.
| Package | Executes Commands? | Cross-Platform | Promise-Based | Shell Injection Safe? | Primary Use Case |
|---|---|---|---|---|---|
child_process | ✅ Yes | ❌ Manual | ❌ (callbacks/events) | ✅ With array args | Low-level control, streaming |
execa | ✅ Yes | ✅ Yes | ✅ Yes | ✅ With array args | General-purpose command execution |
node-cmd | ✅ Yes | ❌ No | ⚠️ Callbacks | ❌ (uses shell by default) | Legacy/simple scripts (avoid in new code) |
shelljs | ⚠️ Via .exec() | ✅ Yes | ❌ (sync by default) | ✅ For built-ins, ❌ for .exec() | Cross-platform file/system ops |
shell-quote | ❌ No | ✅ Yes | ❌ N/A | ✅ Yes | Safely formatting command strings |
cross-env | ❌ No | ✅ Yes | ❌ N/A | ✅ Yes | Setting env vars in npm scripts |
execa for running external commands in modern Node.js tooling.shelljs when you’re doing file operations (cp, rm, mkdir) — it’s safer and more portable.shell-quote if you must build a string.cross-env only in package.json scripts, not in application code.node-cmd — it’s outdated and less safe than alternatives.By understanding what each tool actually does — and where their boundaries lie — you’ll write more secure, maintainable, and cross-platform frontend infrastructure.
Choose execa for most command execution needs in modern Node.js tooling. It provides a clean promise-based API, automatic cross-platform executable resolution (e.g., .cmd on Windows), safe defaults, and robust error handling while wrapping child_process effectively.
Choose shell-quote when you must dynamically construct shell command strings from user input or variables and need to escape arguments safely to prevent shell injection. It does not execute commands — pair it with execa({ shell: true }) or similar when shell features are required.
Choose cross-env exclusively for setting environment variables in npm scripts that must run on both Windows and Unix-like systems. It does not execute arbitrary commands and should not be used programmatically in application code — only in package.json script definitions.
Choose child_process when you need low-level control over process spawning, such as streaming large outputs, managing long-running processes, or fine-tuning stdin/stdout/stderr pipes. It's part of Node.js core, so no dependencies are needed, but you'll handle cross-platform quirks and error management manually.
Avoid node-cmd in new projects. While it offers a simple callback-based interface for running shell commands, it relies on child_process.exec (which buffers all output in memory), lacks active maintenance, and provides no cross-platform safeguards or shell injection protection.
Choose shelljs when performing common file system and shell-like operations (e.g., cp, mkdir, which, grep) in a cross-platform way. Its built-in commands avoid the system shell entirely, making scripts more portable and secure, though its exec() method still requires careful input handling.
Process execution for humans
Sindre's open source work is supported by the community
Special thanks to:
Execa runs commands in your script, application or library. Unlike shells, it is optimized for programmatic usage. Built on top of the child_process core module.
zx.npx.PATHEXT, graceful termination, and more.Uint8Arrays, iterables, objects and almost any other type.stdout and stderr similar to what is printed on the terminal.npm install execa
Execution:
Input/output:
Advanced usage:
import {execa} from 'execa';
const {stdout} = await execa`npm run build`;
// Print command's output
console.log(stdout);
import {$} from 'execa';
const {stdout: name} = await $`cat package.json`.pipe`grep name`;
console.log(name);
const branch = await $`git branch --show-current`;
await $`dep deploy --branch=${branch}`;
await Promise.all([
$`sleep 1`,
$`sleep 2`,
$`sleep 3`,
]);
const directoryName = 'foo bar';
await $`mkdir /tmp/${directoryName}`;
$ npm install -D eslint
await execa({preferLocal: true})`eslint`;
const {stdout, pipedFrom} = await execa`npm run build`
.pipe`sort`
.pipe`head -n 2`;
// Output of `npm run build | sort | head -n 2`
console.log(stdout);
// Output of `npm run build | sort`
console.log(pipedFrom[0].stdout);
// Output of `npm run build`
console.log(pipedFrom[0].pipedFrom[0].stdout);
const {all} = await execa({all: true})`npm run build`;
// stdout + stderr, interleaved
console.log(all);
const {stdout} = await execa({stdout: ['pipe', 'inherit']})`npm run build`;
// stdout is also printed to the terminal
console.log(stdout);
const getInputString = () => { /* ... */ };
const {stdout} = await execa({input: getInputString()})`sort`;
console.log(stdout);
// Similar to: npm run build < input.txt
await execa({stdin: {file: 'input.txt'}})`npm run build`;
// Similar to: npm run build > output.txt
await execa({stdout: {file: 'output.txt'}})`npm run build`;
const {stdout} = await execa({lines: true})`npm run build`;
// Print first 10 lines
console.log(stdout.slice(0, 10).join('\n'));
for await (const line of execa`npm run build`) {
if (line.includes('WARN')) {
console.warn(line);
}
}
let count = 0;
// Filter out secret lines, then prepend the line number
const transform = function * (line) {
if (!line.includes('secret')) {
yield `[${count++}] ${line}`;
}
};
await execa({stdout: transform})`npm run build`;
const response = await fetch('https://example.com');
await execa({stdin: response.body})`sort`;
import {execa} from 'execa';
import {pipeline} from 'node:stream/promises';
import {createReadStream, createWriteStream} from 'node:fs';
await pipeline(
createReadStream('./input.txt'),
execa`node ./transform.js`.duplex(),
createWriteStream('./output.txt'),
);
// parent.js
import {execaNode} from 'execa';
const subprocess = execaNode`child.js`;
await subprocess.sendMessage('Hello from parent');
const message = await subprocess.getOneMessage();
console.log(message); // 'Hello from child'
// child.js
import {getOneMessage, sendMessage} from 'execa';
const message = await getOneMessage(); // 'Hello from parent'
const newMessage = message.replace('parent', 'child'); // 'Hello from child'
await sendMessage(newMessage);
// main.js
import {execaNode} from 'execa';
const ipcInput = [
{task: 'lint', ignore: /test\.js/},
{task: 'copy', files: new Set(['main.js', 'index.js']),
}];
await execaNode({ipcInput})`build.js`;
// build.js
import {getOneMessage} from 'execa';
const ipcInput = await getOneMessage();
// main.js
import {execaNode} from 'execa';
const {ipcOutput} = await execaNode`build.js`;
console.log(ipcOutput[0]); // {kind: 'start', timestamp: date}
console.log(ipcOutput[1]); // {kind: 'stop', timestamp: date}
// build.js
import {sendMessage} from 'execa';
const runBuild = () => { /* ... */ };
await sendMessage({kind: 'start', timestamp: new Date()});
await runBuild();
await sendMessage({kind: 'stop', timestamp: new Date()});
// main.js
import {execaNode} from 'execa';
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 5000);
await execaNode({
cancelSignal: controller.signal,
gracefulCancel: true,
})`build.js`;
// build.js
import {getCancelSignal} from 'execa';
const cancelSignal = await getCancelSignal();
const url = 'https://example.com/build/info';
const response = await fetch(url, {signal: cancelSignal});
import {execa, ExecaError} from 'execa';
try {
await execa`unknown command`;
} catch (error) {
if (error instanceof ExecaError) {
console.log(error);
}
/*
ExecaError: Command failed with ENOENT: unknown command
spawn unknown ENOENT
at ...
at ... {
shortMessage: 'Command failed with ENOENT: unknown command\nspawn unknown ENOENT',
originalMessage: 'spawn unknown ENOENT',
command: 'unknown command',
escapedCommand: 'unknown command',
cwd: '/path/to/cwd',
durationMs: 28.217566,
failed: true,
timedOut: false,
isCanceled: false,
isTerminated: false,
isMaxBuffer: false,
code: 'ENOENT',
stdout: '',
stderr: '',
stdio: [undefined, '', ''],
pipedFrom: []
[cause]: Error: spawn unknown ENOENT
at ...
at ... {
errno: -2,
code: 'ENOENT',
syscall: 'spawn unknown',
path: 'unknown',
spawnargs: [ 'command' ]
}
}
*/
}
await execa`npm run build`;
await execa`npm run test`;
import {execa as execa_} from 'execa';
import {createLogger, transports} from 'winston';
// Log to a file using Winston
const transport = new transports.File({filename: 'logs.txt'});
const logger = createLogger({transports: [transport]});
const LOG_LEVELS = {
command: 'info',
output: 'verbose',
ipc: 'verbose',
error: 'error',
duration: 'info',
};
const execa = execa_({
verbose(verboseLine, {message, ...verboseObject}) {
const level = LOG_LEVELS[verboseObject.type];
logger[level](message, verboseObject);
},
});
await execa`npm run build`;
await execa`npm run test`;