How To Write Shell Scripts in Node with Google’s zx Library

In this article, we’ll learn what Google’s zx library provides, and how we can use it to write shell scripts with Node.js. We’ll then learn how to use the features of zx by building a command-line tool that helps us bootstrap configuration for new Node.js projects.

Writing Shell Scripts: the Problem

Creating a shell script — a script that’s executed by a shell such as Bash or zsh — can be a great way of automating repetitive tasks. Node.js seems like an ideal choice for writing a shell script, as it provides us with a number of core modules, and allows us to import any library we choose. It also gives us access to the language features and built-in functions provided by JavaScript.

But if you’ve tried writing a shell script to run under Node.js, you’ve probably found it’s not quite as smooth as you’d like. You need to write special handling for child processes, take care of escaping command line arguments, and then end up messing around with stdout (standard output) and stderr (standard error). It’s not especially intuitive, and can make shell scripting quite awkward.

The Bash shell scripting language is a popular choice for writing shell scripts. There’s no need to write code to handle child processes, and it has built-in language features for working with stdout and stderr. But it isn’t so easy to write shell scripts with Bash either. The syntax can be quite confusing, making it difficult to implement logic, or to handle things like prompting for user input.

The Google’s zx library helps make shell scripting with Node.js efficient and enjoyable.

Requirements for following along

There are a few requirements for following along with this article:

Ideally, you should be familiar with the basics of JavaScript and Node.js.
You’ll need to be comfortable running commands in a terminal.
You’ll need to have Node.js >= v14.13.1 installed.

All of the code in this article is available on GitHub.

How Does Google’s zx Work?

Google’s zx provides functions that wrap up the creation of child processes, and the handling of stdout and stderr from those processes. The primary function we’ll be working with is the $ function. Here’s an example of it in action:

import { $ } from “zx”;

await $`ls`;

And here’s the output from executing that code:

$ ls
bootstrap-tool
hello-world
node_modules
package.json
README.md
typescript

The JavaScript syntax in the example above might look a little funky. It’s using a language feature called tagged template literals. It’s functionally the same as writing await $(“ls”).

Google’s zx provides several other utility functions to make shell scripting easier, such as:

cd(). This allows us to change our current working directory.
question(). This is a wrapper around the Node.js readline module. It makes it straightforward to prompt for user input.

As well as the utility functions that zx provides, it also makes several popular libraries available to us, such as:

chalk. This library allows us to add color to the output from our scripts.
minimist. A library that parses command-line arguments. They’re then exposed under an argv object.
fetch. A popular Node.js implementation of the Fetch API. We can use it to make HTTP requests.
fs-extra. A library that exposes the Node.js core fs module, as well as a number of additional methods to make it easier to work with a file system.

Now that we know what zx gives us, let’s create our first shell script with it.

Hello World with Google’s zx

First, let’s create a new project:

mkdir zx-shell-scripts
cd zx-shell-scripts

npm init –yes

Then we can install the zx library:

npm install –save-dev zx

Note: the zx documentation suggests installing the library globally with npm. By installing it as a local dependency of our project instead, we can ensure that zx is always installed, as well as control the version that our shell scripts use.

Top-level await

In order to use top-level await in Node.js — await outside of an async function — we need to write our code in ECMAScript (ES) Modules, which support top-level await. We can indicate that all modules in a project are ES modules by adding “type”: “module” in our package.json, or we can set the file extension of individual scripts to .mjs. We’ll be using the .mjs file extension for the examples in this article.

Running a command and capturing its output

Let’s create a new script named hello-world.mjs. We’ll add a shebang line, which tells the operating system (OS) kernel to run the script with the node program:

#! /usr/bin/env node

Now we’ll add some code that uses zx to run a command.

In the following code, we’re running a command to execute the ls program. The ls program will list the files in the current working directory (the directory which the script is in). We’ll capture the standard output from the command’s process, store it in a variable and then log it out to the terminal:

// hello-world.mjs

import { $ } from “zx”;

const output = (await $`ls`).stdout;

console.log(output);

Note: the zx documentation suggests putting /usr/bin/env zx in the shebang line of our scripts, but we’re using /usr/bin/env node instead. This is because we’ve installed zx as a local dependency of our project. We’re then explicitly importing the functions and objects that we want to use from the zx package. This helps make it clear where the dependencies used in our script are coming from.

We’ll then use chmod to make the script executable:

chmod u+x hello-world.mjs

Let’s run our script:

./hello-world.mjs

We should now see the following output:

$ ls
hello-world.mjs
node_modules
package.json
package-lock.json
README.md
hello-world.mjs
node_modules
package.json
package-lock.json
README.md

You’ll notice a few things in the output from our shell script:

The command we ran (ls) is included in the output.
The output from the command is displayed twice.
There’s an extra new line at the end of the output.

zx operates in verbose mode by default. It will output the command you pass to the $ function and also output the standard output from that command. We can change this behavior by adding in the following line of code before we run the ls command:

$.verbose = false;

Most command line programs, such as ls, will output a new line character at the end of their output to make the output more readable in the terminal. This is good for readability, but as we’re storing the output in a variable, we don’t want this extra new line. We can get rid of it with the JavaScript String#trim() function:

– const output = (await $`ls`).stdout;
+ const output = (await $`ls`).stdout.trim();

If we run our script again, we’ll see things look much better:

hello-world.mjs
node_modules
package.json
package-lock.json

Using Google’s zx with TypeScript

If we want to write shell scripts that use zx in TypeScript, there are a couple of minor differences we need to account for.

Note: the TypeScript compiler provides a number of configuration options that allow us to adjust how it compiles our TypeScript code. With that in mind, the following TypeScript configuration and code are designed to work under most versions of TypeScript.

First, let’s install the dependencies we’ll need to run our TypeScript code:

npm install –save-dev typescript ts-node

The ts-node package provides a TypeScript execution engine, allowing us to transpile and run TypeScript code.

We need to create a tsconfig.json file containing the following configuration:

{
“compilerOptions”: {
“target”: “es2017”,
“module”: “commonjs”
}
}

Let’s now create a new script named hello-world-typescript.ts. First, we’ll add a shebang line that tells our OS kernel to run the script with the ts-node program:

#! ./node_modules/.bin/ts-node

In order to use the await keyword in our TypeScript code, we need to wrap it in an immediately invoked function expression (IIFE), as recommended in the zx documentation:

// hello-world-typescript.ts

import { $ } from “zx”;

void (async function () {
await $`ls`;
})();

We then need to make the script executable so that we can execute it directly:

chmod u+x hello-world-typescript.ts

When we run the script:

./hello-world-typescript.ts

… we should see the following output:

$ ls
hello-world-typescript.ts
node_modules
package.json
package-lock.json
README.md
tsconfig.json

Writing scripts with zx in TypeScript is similar to using JavaScript, but requires a little extra configuration and wrapping of our code.

Continue reading
How To Write Shell Scripts in Node with Google’s zx Library
on SitePoint.

Leave a Comment

Your email address will not be published.

Scroll to Top