execa vs shell-quote vs cross-env vs child_process vs node-cmd vs shelljs
Executing Shell Commands and Managing Environment Variables in Node.js
execashell-quotecross-envchild_processnode-cmdshelljsSimilar Packages:

Executing Shell Commands and Managing Environment Variables in Node.js

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.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
execa122,023,3317,461325 kB163 months agoMIT
shell-quote38,858,1615323.7 kB119 months agoMIT
cross-env13,746,0846,53420.2 kB15 months agoMIT
child_process386,874165-410 years agoISC
node-cmd32,678285-75 years agoMIT
shelljs014,421152 kB10210 months agoBSD-3-Clause

Executing Shell Commands in Node.js: A Practical Guide for Frontend Engineers

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.

⚙️ Core Purpose: What Each Package Actually Does

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-quote to safely build a command string, then execa to run it.

🖥️ Running Shell Commands: Direct Execution Compared

Let’s see how each execution-focused package actually runs a command like git log --oneline -5.

Using 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.

Using execa

execa 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.

Using node-cmd

A 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.

Using shelljs

shelljs 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.

🔒 Safety: Avoiding Shell Injection

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.

Safe approaches:

  • Use argument arrays (not shell strings): execa('grep', [userInput, 'file.txt'])
  • Escape inputs with 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.

🌍 Cross-Platform Compatibility

Frontend tooling must work on Windows, macOS, and Linux. Here’s how each package handles it.

child_process

Works everywhere, but you must handle platform differences:

  • Windows uses .exe, .bat, .cmd extensions
  • Path separators differ (\ vs /)
  • Commands like ls don’t exist on Windows

execa

Automatically 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-cmd

Relies on the system shell, so it inherits all platform quirks. No special handling for Windows.

shelljs

Explicitly 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 Case

This 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.

🛠️ When to Use Which Package

Need maximum control or streaming output?

→ Use child_process.spawn directly. Ideal for long-running processes like dev servers.

Building a CLI tool or dev script with clean async/await?

→ Use execa. It’s the best balance of power, safety, and ergonomics.

Just need to copy files, make directories, or check paths?

→ Use shelljs built-ins. Avoids shell entirely and works everywhere.

Must construct a shell command from dynamic parts?

→ Use shell-quote to escape inputs, then pass the string to execa({ shell: true }).

Setting environment variables in npm scripts?

→ Use cross-env — and only for that.

Avoid node-cmd in new projects

While 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.

🧪 Real-World Example: Safe Git Tagging Script

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.

📋 Summary Table

PackageExecutes Commands?Cross-PlatformPromise-BasedShell Injection Safe?Primary Use Case
child_process✅ Yes❌ Manual❌ (callbacks/events)✅ With array argsLow-level control, streaming
execa✅ Yes✅ Yes✅ Yes✅ With array argsGeneral-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✅ YesSafely formatting command strings
cross-env❌ No✅ Yes❌ N/A✅ YesSetting env vars in npm scripts

💡 Final Advice

  • Default to execa for running external commands in modern Node.js tooling.
  • Reach for shelljs when you’re doing file operations (cp, rm, mkdir) — it’s safer and more portable.
  • Always use argument arrays (not shell strings) unless you absolutely need shell features like pipes or globs.
  • Never concatenate untrusted input into shell commands — use shell-quote if you must build a string.
  • Use cross-env only in package.json scripts, not in application code.
  • Avoid 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.

How to Choose: execa vs shell-quote vs cross-env vs child_process vs node-cmd vs shelljs

  • execa:

    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.

  • shell-quote:

    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.

  • cross-env:

    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.

  • child_process:

    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.

  • node-cmd:

    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.

  • shelljs:

    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.

README for execa

execa logo

Coverage Status

Process execution for humans





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.

Features

Install

npm install execa

Documentation

Execution:

Input/output:

Advanced usage:

Examples

Execution

Simple syntax

import {execa} from 'execa';

const {stdout} = await execa`npm run build`;
// Print command's output
console.log(stdout);

Script

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}`;

Local binaries

$ npm install -D eslint
await execa({preferLocal: true})`eslint`;

Pipe multiple subprocesses

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);

Input/output

Interleaved output

const {all} = await execa({all: true})`npm run build`;
// stdout + stderr, interleaved
console.log(all);

Programmatic + terminal output

const {stdout} = await execa({stdout: ['pipe', 'inherit']})`npm run build`;
// stdout is also printed to the terminal
console.log(stdout);

Simple input

const getInputString = () => { /* ... */ };
const {stdout} = await execa({input: getInputString()})`sort`;
console.log(stdout);

File input

// Similar to: npm run build < input.txt
await execa({stdin: {file: 'input.txt'}})`npm run build`;

File output

// Similar to: npm run build > output.txt
await execa({stdout: {file: 'output.txt'}})`npm run build`;

Split into text lines

const {stdout} = await execa({lines: true})`npm run build`;
// Print first 10 lines
console.log(stdout.slice(0, 10).join('\n'));

Streaming

Iterate over text lines

for await (const line of execa`npm run build`) {
	if (line.includes('WARN')) {
		console.warn(line);
	}
}

Transform/filter output

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`;

Web streams

const response = await fetch('https://example.com');
await execa({stdin: response.body})`sort`;

Convert to Duplex stream

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'),
);

IPC

Exchange messages

// 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);

Any input type

// 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();

Any output type

// 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()});

Graceful termination

// 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});

Debugging

Detailed error

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' ]
		}
	}
	*/
}

Verbose mode

await execa`npm run build`;
await execa`npm run test`;
execa verbose output

Custom logging

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`;

Related

Maintainers