Skip to content

Back to Releasing JavaScript

Building a command-line tool

Build a command-line app that, following the Unix philosophy, does one thing well and plays well with others via text.

Reading command-line arguments

POSIX (Portable Operating System Interface) guidelines: Utility conventions. GNU-style long options. Can be ambiguous.

I wrote opsh to do as little as possible.

Node 18.3 added a native solution with util.parseArgs(), which has more features out of the box.

Some general options: -h, --help, -v, --version.

Reading input

From a file on the disk

For all things file system, the node:fs module.

The node:path module works with file system paths at an abstract level, working .. and ..

Expanding globs

The user’s shell can probably already expand globs:

# glob is expanded by shell…
mytool pages/*.html

# …into something like below
mytool pages/some-file.html pages/some-other-file.html …

Support of various glob features varies among shells. For example, macOS still packages bash 3.2.57, a version released circa 2006, that does not support the ** globstar operator. And discrepancies between shells can sometimes cause bugs that go unnoticed.

Rather then depending on shell expansion, some tools may benefit from expanding globs themselves. The user can then provide a quoted glob argument that passes through to process.argv unchanged.

# quoted glob doesn’t get expanded by shell
mytool 'pages/**/*.html'

Node.js 22 adds the glob() function to the node:fs module. For earlier Node.js versions, you can use the popular fast-glob package.

Reading ancillary files

If the tool needs to refer to another file packaged along with it, the file’s path must be resolved against the tool’s path, and not relative to process.cwd(). Assuming you’re writing your tool in ESM format, you can use import.meta.url as the reference:

/* 
Relative to `process.cwd()`:
what works when running the tool locally will fail
when the end user installs the tool globally.
*/

const content = await fs.readFile('templates/default.html', 'utf8');

/*
Relative to the current path:
works locally and for the end user.
*/

const content = await fs.readFile(
new URL('../templates/default.html', import.meta.url),
'utf8'
);

From an URL

Node.js 18 adds to its global scope a browser-compatible fetch() function which makes it easy to retrieve the content of a URL. For earlier Node.js versions, node-fetch is a helpful replacement.

It’s fine if your tool covers the basic scenario where you HTTP GET an URL. But a POST here, some credentials there, and things can get complicated fast. Unless fetching web pages is your tool’s main focus, don’t go overboard adding fetching options to the command-line interface. Instead, let the user defer to a dedicated tool such as curl or wget, which devote ample API space to this sort of configuration.

When working with web page content, JavaScript libraries to parse HTML may come in handy.

Two of my projects, percollate and hred, operate on the content of web pages. Even when the content is fetched with a separate tool, the page’s original URL remains important for resolving relative links. For both tools you can pass the URL as an option with -u <url> or --url=<url>:

curl https://danburzo.ro/ | hred "a@.href" -u https://danburzo.ro/

From stdin

Slurp the stdin stream:

/* Slurp a Readable stream */
async function slurp(stream) {
// Switch to text mode (default is binary)
stream.setEncoding('utf8');
let result = '';
for await (let chunk of stream) result += chunk;
return result;
}

// Usage:
const content = await slurp(process.stdin);

From the clipboard

Node.js doesn’t have access to reading and writing to the system clipboard. Operating systems have their own: macOS has pbcopy and pbpaste, while Linux distributions often come with xsel.

# pipe the content of the macOS clipboard 
# to `mytool` via `stdin`
pbpaste | mytool

Writing output

stdout and stderr

Configuration

In order to make a JavaScript file into a CLI tool, you need to:

  1. Include #!/usr/bin/env node as the first line in the file
  2. Make the file executable with chmod +x ./index.js
  3. Add it to the bin object in package.json

Using the app

Instal it from npm.

Use it directly with npx.

Further reading

Node.js command-line tools are the topic of an entire book by Dr. Axel Rauschmayer, Shell scripting with Node.js.

References to work into the text