Shell-free scripts with Execa 7

ehmicky
ITNEXT
Published in
3 min readMar 11, 2023

--

Almost shell-free beach (photo by Aaron Burden)

While shell scripts are very handy, many developers enjoy the flexibility, expressiveness and ecosystem that come with programming languages like JavaScript. zx, a recent popular project from Google, combines the best of both worlds.

const branch = await $`git branch --show-current`
await $`deploy --branch=${branch}`

Execa is a 7-year-old Node module used by 15 million repositories that makes commands and processes easy to execute. Our 7.1 release features a mode similar to zx but with a simpler JavaScript-only approach. This article focuses on the main differences with zx.

import { $ } from 'execa'

const branch = await $`git branch --show-current`
await $`deploy --branch=${branch}`

Scripting without a shell

Why use both Node and Bash? With Execa, there is no shell syntax to remember: everything is just plain JavaScript. Almost all shell-specific features can be expressed in JavaScript. For the remaining edge cases, a shell option is available.

This is more:

  • Secure: shell injections are not technically feasible.
  • Cross-platform: not all Windows machines have Bash available.
  • Performant: spawning a shell for every command comes at a cost. Even npx can be skipped since Execa can directly execute locally installed binaries.
# In Bash
API_KEY="secret" npx deploy "$cluster" &> logs.txt && echo "done"
// In JavaScript
import { $ } from 'execa'

const options = { env: { API_KEY: 'secret' } }
await $(options)`deploy ${cluster}`.pipeAll(’logs.txt’)
console.log('done')

Simplicity

Execa does not require any special binary, inject global variables nor include any utility. It focuses on being small and modular instead. Any Node module can be used in your scripts.

import { $ } from 'execa'
import pRetry from 'p-retry'

await pRetry(
async () => await $`deploy dev_cluster`,
{ retries: 5 },
)

Options

The child_process core Node module includes many useful features: timeout, cancellation, background processes, IPC, PID, UID/GID, weak references, and more. Execa adds a few additional ones: graceful termination, cleanup, interleaved output, etc. Those can be set using $(options) for either one or multiple commands.

import { $ } from 'execa'

const $$ = $({ timeout: 5000, all: true })

// `all` retrieves both stdout and stderr
const { all } = $$`deploy dev_cluster`

Debugging

Child processes can be hard to debug. This is why Execa:

  • Includes a verbose option, which can be set using NODE_DEBUG=execa.
  • Reports detailed error messages and properties.
  • Is purely stateless, making it straightforward to figure out what is the current directory, or any other option.
> NODE_DEBUG=execa node deploy.js

[16:50:03.305] deploy dev_cluster
........dev_cluster successfully deployed.

[16:53:06.378] deploy prod_cluster
.............prod_cluster successfully deployed.

Piping

Redirecting input/output is a common shell pattern that’s available with Execa.

import { $ } from 'execa'

// Pipe input from a string or buffer, like <<< in Bash
await $({ input: 'dev_cluster' })`deploy`

// Pipe input from a file, like < in Bash
await $({ inputFile: 'clusters.txt' })`deploy`

// Pipe output to a file, like >, 2> and &> in Bash
await $`deploy dev_cluster`.pipeStdout('stdout.txt')
await $`deploy dev_cluster`.pipeStderr('stderr.txt')
await $({ all: true })`deploy dev_cluster`.pipeAll('logs.txt')

// Pipe output to another command, like | and |& in Bash
await $`deploy dev_cluster`.pipeStdout($`grep done`)

// Pipe output to a stream, like 2>&1 in Bash
const { stdout } = await $`deploy dev_cluster`.pipeStderr(process.stdout)

If you’re curious, please feel free to check our main documentation and the page dedicated to Node.js scripts. Huge thanks to Aaron Casanova, a new contributor to Execa, who added this feature, and to Sindre Sorhus, who reviewed it!

--

--