Building CLI Tools
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences
of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were
checked against the official documentation on 2026-10-04, and every example below was run on Node.js 26, 24 and 22 unless the text says
otherwise. A command-line tool is the smallest useful Node.js program that still has to behave like a citizen of the shell: it takes its
instructions from arguments, standard input and the environment, writes results to standard output and complaints to standard error, tells its caller
how it went through an exit status, and stops cleanly when it is interrupted. Node.js ships the pieces for all of that, so a tool such as the
Bookshelf catalogue manager built here needs no runtime dependency: util.parseArgs() parses flags, util.styleText() colours text,
node:readline/promises asks questions and reads lines, and the process object carries the exit code and the signals. This page assembles those
pieces into one complete bookshelf command and then ships it, first through npm and then as a single executable file. The individual APIs are
documented in depth elsewhere in this section: see The util Module,
Console, readline & Terminal I/O and
Process, Signals & Exit Codes; the flags of the node binary itself are the subject of
Running Scripts, the CLI & the REPL. What a CLI should look like to an automated caller, such as an
AI agent, is covered in CLI for Agents, and its Node.js page
Node.js CLI Frameworks compares commander, yargs, oclif and Ink.
The stability and version map
| API or feature | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
Stable since v20.0.0. |
|
2 Stable |
Stable since v23.5.0 and v22.13.0. It checks the stream and |
|
2 Stable |
|
|
2 Stable |
Unchanged across the three lines. |
|
Stable (module level) |
Added in v25.4.0 and v24.14.0. |
|
Stable (module level) |
No longer experimental since v24.10.0 and v22.21.0. Present on all three lines. |
|
Language feature |
Import attributes; ran without a warning on 26, 24 and 22.23.3. |
|
1.1 Active development |
Added in v25.5.0. Node.js 24 and 22 only have the older |
SEA virtual file system ( |
1.0 Early development |
Added in v26.9.0; documented but not run for this page. |
A command is a package with a bin entry
An executable command in the npm world is a file that a package names in its bin field. When someone installs the package, npm links that file
into a bin directory that is on the PATH (globally) or into node_modules/.bin (locally, where npx and npm run find it). The file must
start with a shebang, the line #!/usr/bin/env node: the operating system reads it, looks node up on the PATH (so a version manager that
swaps the active Node.js keeps working) and runs the file with it. Node.js itself skips a leading shebang line, so the same file also runs as
node bin/bookshelf.js. The thin-entry layout below is the one used by most real tools: the file under bin/ only wires the process to the
program (arguments, signals, exit code) and everything testable lives in modules under src/.
{
"name": "bookshelf-cli",
"version": "0.1.0",
"description": "Manage the Bookshelf catalogue from a terminal",
"type": "module",
"bin": {
"bookshelf": "./bin/bookshelf.js"
},
"files": [
"bin",
"src"
],
"engines": {
"node": ">=22"
},
"license": "MIT"
}
"type": "module" makes the .js files ES modules (see ECMAScript Modules); bin maps the command name to the
file (a plain string is shorthand for a command named after the package); files is an allow-list for what the published tarball contains; and
engines documents the supported Node.js range, which npm only enforces as a warning unless engine-strict is set.
#!/usr/bin/env node
import { constants } from 'node:os';
import { stdout } from 'node:process';
import { main } from '../src/main.js';
const controller = new AbortController();
let caught;
for (const name of ['SIGINT', 'SIGTERM']) {
process.once(name, () => {
caught = name;
controller.abort();
});
}
// A closed pipe (`bookshelf list | head -n 1`) is not a failure.
stdout.on('error', (err) => {
if (err.code === 'EPIPE') process.exit(0);
throw err;
});
const code = await main(process.argv.slice(2), { signal: controller.signal });
// Shell convention: a process ended by signal N reports 128 + N.
process.exitCode = caught ? 128 + constants.signals[caught] : code;
A few facts about this layout were observed on macOS with Node.js 26.10.0 and npm 11.19.1, and they are the ones that bite first.
| Situation | What happened |
|---|---|
Source file without the execute bit, packed with |
|
Shebang line saved with Windows line endings ( |
|
Windows |
The npm documentation says a global install either links the file or creates a |
Parsing arguments with util.parseArgs
util.parseArgs() turns an argument array into { values, positionals } and covers what a small tool needs: long and short flags, booleans and
strings, --name=value, grouped short flags, -- handling and negation. In strict mode (the default) an unknown flag or a missing value throws an
error whose code starts with ERR_PARSE_ARGS_; the tool catches it, prints a one-line message and a hint on stderr, and exits with status 2, the
conventional "usage error" code. The API reference of parseArgs() is in The util Module; what matters when
building a tool is the decisions around it.
import { parseArgs, styleText } from 'node:util';
// ...
export async function main(argv, { signal } = {}) {
try {
const { values, positionals } = parseArgs({
args: argv,
allowPositionals: true,
allowNegative: true,
options: {
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
json: { type: 'boolean' },
color: { type: 'boolean' },
file: { type: 'string', short: 'f' },
year: { type: 'string', short: 'y' },
},
});
const [command, ...rest] = positionals;
if (values.help) { console.log(HELP); return EXIT.OK; }
if (values.version) { console.log(pkg.version); return EXIT.OK; }
if (!command) throw new UsageError('missing command');
// ...
} catch (err) {
if (err instanceof UsageError || String(err.code).startsWith('ERR_PARSE_ARGS_')) {
stderr.write(`bookshelf: ${err.message}\nTry 'bookshelf --help'.\n`);
return EXIT.USAGE;
}
// ...
}
}
Source: Node.js API: util.parseArgs()
Four design choices in that excerpt are worth stating, because each one avoids a bug that is easy to ship.
-
main()takes the arguments and returns an exit code. It never callsprocess.exit()and never readsprocess.argvitself, so a test can call it directly (or spawn the real binary, see Testing a CLI) and the entry file stays in charge of the process. -
Options that take part in configuration layering have no
default.parseArgs()applies adefaultwhenever the flag is absent, which would make the flag win over the config file and the environment. Leaving it out keepsvalues.fileundefinedunless the user typed it, which is what the precedence code in Configuration relies on. -
allowNegative: truegives--no-colorfor free. It sets the booleancolortofalse; with no flag,colorstaysundefined. -
Subcommands are your job.
parseArgs()has no command tree: the first positional is the command and the rest are its arguments. For a handful of commands aswitchis enough. When each subcommand needs its own flags, thetokensoption (an ordered list of options and positionals) lets you split the arguments yourself, and at that point a framework starts to pay off (see When a framework pays off).
It also does not generate --help, convert numbers or validate ranges; the tool above keeps its help text in one template string and validates the
year with a regular expression. Running the parsing paths gives:
$ bookshelf frobnicate; echo "exit $?"
bookshelf: unknown command 'frobnicate'
Try 'bookshelf --help'.
exit 2
$ bookshelf list --bogus 2>&1 | head -n 1
bookshelf: Unknown option '--bogus'. To specify a positional argument starting with a '-', place it at the end of the command after '--', as in '-- "--bogus"
$ bookshelf --version
0.1.0
Source: Node.js API: util.parseArgs()
Output: stdout for results, stderr for talk, colour only for people
Two conventions carry most of the weight of a well-behaved tool. Results go to standard output, where they can be redirected or piped; everything
else (prompts, progress, confirmations, errors) goes to standard error, so that bookshelf list > books.txt captures data and nothing else.
And colour is for people: it is emitted only when the stream is a terminal and the user has not opted out. util.styleText() already makes
that decision for you. It checks whether the target stream (process.stdout by default, { stream: process.stderr } for messages on stderr)
supports colours and honours NO_COLOR, NODE_DISABLE_COLORS and FORCE_COLOR; with { validateStream: false } it always emits the escape codes. A
--no-color flag is still worth offering, because not every user can set environment variables, and the tool below combines the three signals.
const config = await loadConfig({ file: values.file, color: values.color });
const paint = (format, text) => (config.color ? styleText(format, text, { stream: stdout }) : text);
// ...
case 'list': {
const lines = values.json || !stdout.isTTY;
for (const b of books) {
console.log(lines ? JSON.stringify(b) : `${paint('bold', b.title)} ${paint('dim', `(${b.year})`)}`);
}
return EXIT.OK;
}
Source: Node.js API: util.styleText()
On a terminal list prints decorated text and when stdout is a pipe or a file it prints one JSON object per line (NDJSON), which jq, grep
and other programs can consume. A --json flag forces that form on a terminal as well. Observed on 26, 24 and 22 with a pseudo-terminal (the escape
sequences are shown as Python-style escapes):
$ bookshelf list # TTY, colours on
'\x1b[1mDune\x1b[22m \x1b[2m(1965)\x1b[22m\r\n\x1b[1mHyperion\x1b[22m ...'
$ bookshelf list --no-color # TTY, flag
'Dune (1965)\r\nHyperion (1989)\r\n...'
$ NO_COLOR=1 bookshelf list # TTY, environment
'Dune (1965)\r\nHyperion (1989)\r\n...'
$ bookshelf list | cat # not a TTY
{"title":"Dune","year":1965}
{"title":"Hyperion","year":1989}
One detail of the observation: without a TERM variable in the pseudo-terminal environment the decorated output had no escape codes either, so
styleText() also depends on the terminal advertising colour support. See Console, readline & Terminal I/O
for isTTY, hasColors() and getColorDepth(), and TTY, Colour & Interactivity for the same rules seen from an
agent’s side.
Prompts with readline/promises
A tool should ask a question only when a person can answer it. The check is stdin.isTTY: when standard input is a file or a pipe, the missing
value is a usage error and the tool exits with 2 instead of waiting for input that will never arrive. When it is a terminal, readline/promises
prints the query on stderr (so stdout stays clean) and question() resolves with the typed line.
import { createInterface } from 'node:readline/promises';
import { stdin, stderr } from 'node:process';
// Interactive only: the caller checks stdin.isTTY first. Prompts go to stderr so stdout stays clean.
export async function ask(questions, { signal } = {}) {
const rl = createInterface({ input: stdin, output: stderr });
// On a terminal Ctrl+C is a keystroke (raw mode), not a signal: readline reports it as 'SIGINT'.
const cancelled = new AbortController();
rl.once('SIGINT', () => cancelled.abort());
const abort = AbortSignal.any([cancelled.signal, ...(signal ? [signal] : [])]);
try {
const answers = {};
for (const [key, query] of Object.entries(questions)) {
answers[key] = (await rl.question(`${query}: `, { signal: abort })).trim();
}
return answers;
} catch (err) {
// Ctrl+D also rejects with an AbortError, but nobody aborted our signal: report it as plain failure.
if (err.name === 'AbortError' && !abort.aborted) throw new Error('input ended before every question was answered');
throw err;
} finally {
rl.close();
}
}
The special cases around a prompt are where hand-written tools go wrong, so each was tried under a pseudo-terminal (identical on Node.js 26, 24 and 22).
| Input | Result |
|---|---|
|
|
Ctrl+C at the first prompt |
With the |
Ctrl+D (end of input) at a prompt |
|
Without the |
A first version of this tool had none: Ctrl+C closed the interface, the pending promise never settled, and the process ended with status 0 and no message, a silent success for a cancelled command. |
stdin at end of file, query pending |
A small script that called |
Password-style (masked) input, history and completion are described in Console, readline & Terminal I/O. Never take a secret as a command-line argument: arguments are visible to other users in the process list and end up in shell history; read it from a prompt with echo disabled, from an environment variable or from standard input.
Piping standard input and output
A Unix-style tool reads from standard input when there is no file argument and writes one record per line, so it composes with other tools. The
import command below reads NDJSON from stdin line by line with readline; memory use stays constant however large the input is, because the
interface is an async iterable over the stream. Passing the signal option to createInterface() closes the interface when the signal aborts, which
ends the loop, so a SIGTERM interrupts a command that is blocked waiting for input.
case 'import': {
let count = 0;
// The signal option closes the interface (ending the loop) when the caller aborts.
for await (const line of createInterface({ input: stdin, crlfDelay: Infinity, signal })) {
if (line.trim() === '') continue;
books.push(JSON.parse(line));
count++;
}
signal?.throwIfAborted(); // interrupted: save nothing
await writeBooks(config.file, books);
console.error(`imported ${count} books`);
return EXIT.OK;
}
$ printf '{"title":"Hyperion","year":1989}\n{"title":"Solaris","year":1961}\n' | bookshelf import
imported 2 books
$ bookshelf list | head -n 1
{"title":"Dune","year":1965}
When the input is a single small document rather than a stream of lines, node:stream/consumers collects a stream in one call:
import { text } from 'node:stream/consumers';
import { stdin } from 'node:process';
// Fine for small inputs; for large ones read line by line instead.
const input = await text(stdin);
const books = JSON.parse(input);
console.log(`${books.length} books, first: ${books[0]?.title}`);
2 books, first: Dune
When the reader goes away: EPIPE
bookshelf list | head -n 1 shows the other half of piping: head exits after one line and closes the pipe, and the next write fails with
EPIPE. Whether that matters depends on how you write. console.log() swallows write errors, so the tool above never notices; but a program that
writes with process.stdout.write() gets an unhandled 'error' event on the stdout stream, and the shell sees a stack trace and a non-zero status.
Both variants were run with 200,000 lines piped into head -n 1:
for (let i = 0; i < 200000; i++) process.stdout.write(`Book ${i}\n`);
import { stdout } from 'node:process';
stdout.on('error', (err) => {
if (err.code === 'EPIPE') process.exit(0);
throw err;
});
for (let i = 0; i < 200000; i++) stdout.write(`Book ${i}\n`);
$ node epipe.mjs | head -n 1; echo "statuses: ${pipestatus[@]}"
Book 0
node:events:505
throw er; // Unhandled 'error' event
Error: write EPIPE
...
statuses: 1 0
$ node epipe2.mjs | head -n 1; echo "statuses: ${pipestatus[@]}"
Book 0
statuses: 0 0
The pipestatus array is zsh syntax (Bash calls it PIPESTATUS). The handler calls process.exit(0) because the reader has already gone and
there is nothing left to flush; a tool that must clean up should call its own shutdown function there instead.
Exit codes and signals
The exit status is the tool’s return value for every caller: a shell if, a Makefile, a CI job, an npm script, an agent. The convention the tool follows
is small and stable. It uses process.exitCode rather than process.exit(), so that pending writes to stdout and stderr are flushed; the full reasoning
and the codes Node.js uses itself are in Process, Signals & Exit Codes.
| Code | Meaning | Where it comes from |
|---|---|---|
|
Success |
The command finished; |
|
Runtime failure |
An error that is not the user’s typing: an unreadable catalogue, bad JSON in the input, a config file that does not parse, end of input at a prompt. |
|
Usage error |
Unknown command or flag, missing value, invalid year. The same code Bash uses for builtin misuse and most tools use for bad invocations. |
|
Interrupted by SIGINT (Ctrl+C) |
|
|
Terminated by SIGTERM |
|
Source: Node.js API: Process exit codes
Signals are delivered to the process, not to a function: the entry file registers a listener with process.once() for SIGINT and SIGTERM that records
which signal arrived and aborts an AbortController. Everything that waits (a prompt, the import loop, a fetch() call) takes the controller’s
signal, so one mechanism cancels all of it, and main() returns normally with the work abandoned. The entry file then sets process.exitCode to 128 +
the signal number, taken from os.constants.signals. Node.js 25.4 and 24.14 added util.convertProcessSignalToExitCode('SIGTERM'), which returns 143;
it does not exist on 22, hence the arithmetic. (The signal listeners on process apply to non-interactive runs, such as a kill from another terminal or
a CI timeout. At a prompt in a terminal, Ctrl+C never becomes a signal, as the table in Prompts shows.)
Behaviour of the two signal paths, observed with a long-lived bookshelf import whose stdin stays open:
SIGTERM -> exit status 143, nothing written to the catalogue
SIGINT -> exit status 130, nothing written to the catalogue
Configuration: flags, environment, config file
Tools usually accept the same setting in several places, and users expect a fixed precedence: built-in defaults, then a configuration file, then
environment variables, then command-line flags, the most specific source winning. loadConfig() implements exactly that with object spread, where
later spreads overwrite earlier ones. A missing config file is normal (ENOENT is swallowed); a file that exists but does not parse is an error the user
must see, with its path in the message.
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
const defaults = {
file: join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'bookshelf', 'books.json'),
color: true,
};
async function readConfigFile() {
const dir = process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config');
const path = join(dir, 'bookshelf', 'config.json');
try {
return JSON.parse(await readFile(path, 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') return {}; // no config file is fine
throw new Error(`cannot use ${path}: ${err.message}`);
}
}
// Precedence, lowest to highest: defaults < config file < environment < command-line flags.
export async function loadConfig(flags) {
const fromEnv = {};
if (process.env.BOOKSHELF_FILE) fromEnv.file = process.env.BOOKSHELF_FILE;
if (process.env.NO_COLOR) fromEnv.color = false;
const fromFlags = Object.fromEntries(Object.entries(flags).filter(([, v]) => v !== undefined));
return { ...defaults, ...(await readConfigFile()), ...fromEnv, ...fromFlags };
}
Running the same command with the setting in each place shows the order (the same on 26, 24 and 22):
$ cat $XDG_CONFIG_HOME/bookshelf/config.json
{ "file": "/work/from-config.json" }
$ bookshelf add A -y 2000 # config file -> /work/from-config.json
$ BOOKSHELF_FILE=/work/from-env.json bookshelf add B -y 2001 # environment -> /work/from-env.json
$ BOOKSHELF_FILE=/work/from-env.json bookshelf add C -y 2002 --file /work/from-flag.json # flag -> /work/from-flag.json
Each of the three commands created exactly the file named in the comment. With the config file containing { nope, bookshelf list printed
bookshelf: cannot use …/bookshelf/config.json: Expected property name or '}' in JSON at position 2 (line 1 column 3) and exited with 1.
Where the files live is a convention, not an API: on Linux and macOS $XDG_CONFIG_HOME (default ~/.config) holds configuration and $XDG_DATA_HOME
(default ~/.local/share) holds data; Windows tools typically use %APPDATA%. Node.js has no helper for this, and the Windows paths were not tried here.
Three related points:
-
.envfiles. A tool that wants a project-local.envcan callprocess.loadEnvFile()itself. A run under all three lines gave the same results: the file’s value is used when the variable is not already set, a value from the shell wins, and a missing file throws an error withcodeENOENTthat the tool must tolerate. The--env-fileflag exists for running scripts, but the flag belongs to thenodebinary and a user of your installed command cannot pass it, so the tool loads the file itself. -
node.config.jsonis not your tool’s config file. It configures thenodeprocess (see CLI Options, Environment & Config File); a tool defines and reads its own file as above. -
Secrets stay out of the config file and out of the arguments. Read tokens from the environment or a prompt, and if the tool stores credentials, restrict the file permissions; see Configuration & Auth for the agent-facing side.
import { loadEnvFile } from 'node:process';
// Load ./.env if it exists; variables already in the environment win over the file.
try {
loadEnvFile('.env');
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
console.log(process.env.BOOKSHELF_FILE ?? '(BOOKSHELF_FILE not set)');
$ node env-demo.mjs
/tmp/from-dotenv.json
$ BOOKSHELF_FILE=/tmp/from-shell.json node env-demo.mjs
/tmp/from-shell.json
$ cd .. && node envdemo/env-demo.mjs
(BOOKSHELF_FILE not set)
The last line shows that '.env' is resolved against the current directory, not the script’s directory.
Testing a CLI
The entry file and the real binary are part of the product, so test them as a user would: spawn the program, pass an argument array (never a
shell command string), and assert on the exit status, standard output and standard error. node:test (see Test Runner)
and child_process.spawnSync() are enough; process.execPath is the running node binary, so the test uses the same Node.js version that runs the suite.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const bin = new URL('../bin/bookshelf.js', import.meta.url).pathname;
const env = { ...process.env, BOOKSHELF_FILE: join(mkdtempSync(join(tmpdir(), 'shelf-')), 'books.json'), XDG_CONFIG_HOME: tmpdir() };
const run = (args, input) => spawnSync(process.execPath, [bin, ...args], { env, input, encoding: 'utf8' });
test('a usage error exits with 2 and explains itself on stderr', () => {
const { status, stdout, stderr } = run(['frobnicate']);
assert.equal(status, 2);
assert.equal(stdout, '');
assert.match(stderr, /unknown command 'frobnicate'/);
});
test('imported books come back as NDJSON when stdout is a pipe', () => {
assert.equal(run(['import'], '{"title":"Dune","year":1965}\n').status, 0);
const { status, stdout } = run(['list']);
assert.equal(status, 0);
assert.deepEqual(JSON.parse(stdout.trim()), { title: 'Dune', year: 1965 });
});
test('SIGTERM during import exits with 143 and saves nothing', async () => {
const child = spawn(process.execPath, [bin, 'import'], { env, stdio: ['pipe', 'ignore', 'ignore'] });
await new Promise((resolve) => setTimeout(resolve, 500)); // let it start reading stdin
child.kill('SIGTERM');
const [code, signal] = await new Promise((resolve) => child.on('close', (c, s) => resolve([c, s])));
assert.deepEqual([code, signal], [143, null]);
});
✔ a usage error exits with 2 and explains itself on stderr
✔ imported books come back as NDJSON when stdout is a pipe
✔ SIGTERM during import exits with 143 and saves nothing
ℹ pass 3
ℹ fail 0
The reporter prints ✔ marks on 26 and 24 and TAP lines (ok 1 - …) on 22 when output is not a terminal; the totals are the same. The code and signal
pair [143, null] is the proof that the tool exited by itself with status 143 instead of being killed by the signal. Interactive prompts need a
pseudo-terminal, which the standard library does not provide; test the prompt logic by passing it streams, and keep the check
that the tool refuses to prompt without a TTY (as in add </dev/null, exit 2) as a plain spawn test. More on the child-process options in
Child Processes, and on testing agent-facing CLIs in Testing CLIs.
Packaging and publishing with npm
Before publishing, look at what would be shipped. npm pack --dry-run lists the tarball contents without writing it; the files allow-list in the
package above leaves out tests and local data, and npm always adds package.json (plus README and LICENSE when present).
$ npm pack --dry-run
npm notice package: bookshelf-cli@0.1.0
npm notice Tarball Contents
npm notice 687B bin/bookshelf.js
npm notice 291B package.json
npm notice 1.1kB src/config.js
npm notice 4.0kB src/main.js
npm notice 1.1kB src/prompt.js
npm notice 615B src/store.js
npm notice package size: 3.2 kB
npm notice unpacked size: 7.8 kB
npm notice total files: 6
npm pack then writes bookshelf-cli-0.1.0.tgz. Installing that tarball into a throw-away project is the closest thing to what a user gets, and it
exercises the bin link, the shebang and the files list at once:
$ npm pack --pack-destination ..
$ cd ../consumer && npm install ../bookshelf-cli-0.1.0.tgz
added 1 package, and audited 2 packages in 353ms
$ ls -l node_modules/.bin
bookshelf -> ../bookshelf-cli/bin/bookshelf.js
$ npx bookshelf --version
0.1.0
$ npm exec -- bookshelf --help
usage: bookshelf <command> [options]
For a tool that is developed and used on the same machine, npm link creates a global symlink to the working tree and npm install --global . installs a copy;
neither was run here, to keep the global npm prefix untouched. Users who do not want a global install can run npx bookshelf-cli (npx fetches the package
and runs its bin; a package with several bin entries needs one that matches the unscoped package name, otherwise npm exec fails and you must name the binary with --package), or install it as a project
dependency and call it from an npm script.
Choices that matter once the package is public:
-
Name the supported Node.js versions in
enginesand test on them; a CLI is often run with whatevernodethe user has. The scratch project above declares>=22and its tests passed on 22, 24 and 26. -
Keep dependencies at zero or few. Every dependency is installed on every user’s machine and is part of the supply chain; the built-ins used here replace what used to need a parser, a colour library and a prompt library. See npm & Package Management.
-
Publish with provenance from CI. npm can attach a signed statement of where and how the package was built, and trusted publishing removes the need for a long-lived publish token. The mechanics, workspaces and the release checklist are in Workspaces & Publishing.
-
Version the output. Once scripts depend on the NDJSON lines or on the exit codes, changing them is a breaking change; document them next to the flags. See Packaging & Distribution and Machine-Readable Output.
A single executable application
An npm package needs Node.js on the user’s machine. A single executable application (SEA) copies the node binary, injects your bundled script into it and
produces one file that runs without Node.js installed. The feature is Stability 1.1 (Active development) and its documentation lists tested platforms:
Windows, macOS on arm64 only, and Linux (all distributions except Alpine). The script must be a single file, so a multi-file ESM project is bundled first;
the steps below used esbuild (a third-party bundler, installed in a scratch directory) and were run on
Node.js 26.10.0 on macOS arm64.
npx esbuild bin/bookshelf.js --bundle --platform=node --format=esm --outfile=bookshelf.mjs
cat > sea-config.json <<'JSON'
{
"main": "bookshelf.mjs",
"mainFormat": "module",
"output": "bookshelf-sea",
"disableExperimentalSEAWarning": true
}
JSON
node --build-sea sea-config.json
codesign --sign - bookshelf-sea # macOS only; see below
./bookshelf-sea --version
Two details of the bundling step: the entry file uses top-level await, which esbuild cannot emit as CommonJS (the first attempt with --format=cjs failed with an
error pointing at the await), so the build uses ESM output and "mainFormat": "module"; and --format=esm keeps the node: imports and the JSON
import attribute working, because esbuild inlines the package.json version.
Generated single executable /Users/.../v26.10.0/bin/node + sea-config.json -> bookshelf-sea
$ ls -l bookshelf-sea
-rwxr-xr-x 1 user wheel 144953248 ... bookshelf-sea
$ ./bookshelf-sea --version
0.1.0
$ BOOKSHELF_FILE=./s.json ./bookshelf-sea add Z -y 2020 && BOOKSHELF_FILE=./s.json ./bookshelf-sea list
added Z
{"title":"Z","year":2020}
$ ./bookshelf-sea frob; echo $?
bookshelf: unknown command 'frob'
Try 'bookshelf --help'.
2
$ ./bookshelf-sea --help | head -n 1
usage: bookshelf <command> [options]
Things the run showed, labelled as observed on one platform and not as a promise:
-
The binary is a copy of
node: about 145 MB in this build. Distribution size is the price of not requiring a runtime. -
Arguments pass through unchanged.
process.argv.slice(2)held the user’s arguments, and--helpreached the tool’s own parser instead of being interpreted as a Node.js option. The documentation describesexecArgvExtension("env"by default,"cli"to accept--node-options=…,"none") for controlling which extra Node.js flags the executable accepts, andexecArgvfor fixed ones. -
On macOS the binary must be re-signed. Run straight after the build, without
codesign, the unsigned file was killed (exit status 137); aftercodesign --sign -(an ad-hoc signature) it ran. The documentation lists the same step for macOS and an optionalsigntoolstep for Windows. -
Assets (templates, data files) can be added with the
assetsmap and read throughnode:sea; theuseVfsoption that exposes them as a read-only virtual file system was added in v26.9.0 as Stability 1.0 and was not run here. -
Cross-building needs
useCodeCacheanduseSnapshotset tofalse, because those caches only load on the platform that produced them.
On the older lines the workflow differs, because --build-sea is new in v25.5.0. Node.js 24.21.0 and 22.23.3 reject the
flag (node: bad option: --build-sea). On 24 the older path worked as far as it was tried: node --experimental-sea-config wrote a preparation blob, and the
third-party postject tool injected it into a copy of the node binary (after codesign --remove-signature, with --macho-segment-name NODE_SEA on macOS and
the documented sentinel fuse), but the resulting program failed to start with Cannot use import statement outside a module for the ESM bundle, so on 24 and
22 the main script has to be CommonJS (the documentation of those lines has no mainFormat field). A SEA is therefore a Node.js 26 option for an ESM tool, and a
bundle to a single CommonJS file for older lines, which this page did not complete.
Other ways to ship a self-contained tool are a container image with Node.js and the tool (see Docker) and the standalone binaries of other tool-chains; a SEA is attractive when the target machines have no container runtime and no Node.js.
When a framework pays off
util.parseArgs() stops where tools start to grow: it does not generate help from option descriptions, does not build subcommand trees with their own flags,
does not coerce or validate values, and has no shell completion. When a tool reaches ten commands, nested subcommands, plugin loading or interactive
terminal interfaces, a framework removes code you would otherwise write. Node.js CLI Frameworks works through commander, yargs, oclif
(a plugin-based framework) and Ink (React for terminals) using a different running example, including package.json bin, shebang, ESM and CommonJS notes and exit codes
that match a machine-readable contract. A reasonable rule: start with the built-ins, as here; adopt a framework when you find yourself writing a help generator, a
validator or a command tree. The design rules for output that other programs consume are in CLI Design Fundamentals,
and a finished CLI can be exposed to language-model clients as a tool; see Wrapping as an Agent Tool.
The complete src/main.js
The pieces above come from one file. This is its complete text, as it was run for every claim on this page.
import { parseArgs, styleText } from 'node:util';
import { createInterface } from 'node:readline/promises';
import { stdin, stdout, stderr } from 'node:process';
import { loadConfig } from './config.js';
import { readBooks, writeBooks } from './store.js';
import { ask } from './prompt.js';
import pkg from '../package.json' with { type: 'json' };
export const EXIT = { OK: 0, FAILURE: 1, USAGE: 2, SIGINT: 130 };
const HELP = `usage: bookshelf <command> [options]
commands:
list print the catalogue (NDJSON when piped)
add [title] [-y year] add a book, prompting for what is missing
import read NDJSON books from standard input
options:
-f, --file <path> catalogue file
-h, --help show this help
-v, --version print the version
--json force NDJSON output
--no-color disable colours`;
class UsageError extends Error {}
export async function main(argv, { signal } = {}) {
try {
const { values, positionals } = parseArgs({
args: argv,
allowPositionals: true,
allowNegative: true,
options: {
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
json: { type: 'boolean' },
color: { type: 'boolean' },
file: { type: 'string', short: 'f' },
year: { type: 'string', short: 'y' },
},
});
const [command, ...rest] = positionals;
if (values.help) { console.log(HELP); return EXIT.OK; }
if (values.version) { console.log(pkg.version); return EXIT.OK; }
if (!command) throw new UsageError('missing command');
const config = await loadConfig({ file: values.file, color: values.color });
const paint = (format, text) => (config.color ? styleText(format, text, { stream: stdout }) : text);
const books = await readBooks(config.file);
switch (command) {
case 'list': {
const lines = values.json || !stdout.isTTY;
for (const b of books) {
console.log(lines ? JSON.stringify(b) : `${paint('bold', b.title)} ${paint('dim', `(${b.year})`)}`);
}
return EXIT.OK;
}
case 'add': {
let title = rest.join(' ');
let year = values.year;
if (!title || !year) {
if (!stdin.isTTY) throw new UsageError('title and --year are required when stdin is not a terminal');
const answers = await ask({ ...(title ? {} : { title: 'Title' }), ...(year ? {} : { year: 'Year' }) }, { signal });
title ||= answers.title;
year ||= answers.year;
}
if (!title || !/^\d{4}$/.test(year)) throw new UsageError('a title and a four-digit year are required');
books.push({ title, year: Number(year) });
await writeBooks(config.file, books);
console.error(`added ${title}`);
return EXIT.OK;
}
case 'import': {
let count = 0;
// The signal option closes the interface (ending the loop) when the caller aborts.
for await (const line of createInterface({ input: stdin, crlfDelay: Infinity, signal })) {
if (line.trim() === '') continue;
books.push(JSON.parse(line));
count++;
}
signal?.throwIfAborted(); // interrupted: save nothing
await writeBooks(config.file, books);
console.error(`imported ${count} books`);
return EXIT.OK;
}
default:
throw new UsageError(`unknown command '${command}'`);
}
} catch (err) {
if (err instanceof UsageError || String(err.code).startsWith('ERR_PARSE_ARGS_')) {
stderr.write(`bookshelf: ${err.message}\nTry 'bookshelf --help'.\n`);
return EXIT.USAGE;
}
if (err.name === 'AbortError') {
stderr.write('\n');
return EXIT.SIGINT; // cancelled: 128 + SIGINT
}
stderr.write(`bookshelf: ${err.message}\n`);
return EXIT.FAILURE;
}
}
src/store.js is the other missing piece. It writes the catalogue through a temporary file and a rename, so a reader (or a crash) never sees a half-written
JSON file:
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
export async function readBooks(file) {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
}
// Write to a temporary file and rename it: readers never see a half-written catalogue.
export async function writeBooks(file, books) {
await mkdir(dirname(file), { recursive: true });
const tmp = `${file}.${process.pid}.tmp`;
await writeFile(tmp, JSON.stringify(books, null, 2) + '\n');
await rename(tmp, file);
}
One note on the design: main() catches errors and maps them to codes, but the String(err.code) in the parse-error check is deliberate. A DOMException such as the
AbortError raised by signal.throwIfAborted() has a numeric code (20), and calling .startsWith() on it threw a TypeError in the first version of the tool.
What changed recently
| Version | Change |
|---|---|
v26.9.0 |
Single executable applications can expose bundled assets as a read-only virtual file system with |
v26.1.0 |
|
v25.5.0 |
|
v25.4.0 and v24.14.0 |
|
v24.10.0 and v22.21.0 |
|
v23.5.0 and v22.13.0 |
|
v22.8.0 and v20.18.0 |
|
v22.4.0 and v20.16.0 |
|
v20.0.0 |
|
Where the books are outdated
Both books that this section consults build command-line tools with the packages of their time. Node.js Projects by Jonathan Wexler (O’Reilly, 2025) starts its
projects with readline plus process.stdin and util.promisify, and later builds a menu-driven password manager around synchronous terminal prompts.
Today node:readline/promises returns promises directly, so the promisify step is unnecessary; the incomplete promisify example in that chapter, where the readline
interface is not defined, is repaired by the ask() function above. Synchronous prompt packages block the event loop while they wait, which is acceptable in a
one-shot script but not in a tool that also handles signals or timers. Efficient Node.js by Samer Buna (O’Reilly, 2025) teaches the REPL and the node command line
well and is a good place to read about process.argv; it predates the habit, now possible with built-ins, of replacing argument parsers and colour libraries by
util.parseArgs() and util.styleText(). Neither book covers the parts of a CLI that are about being a good citizen of the shell: the stderr/stdout split, exit
code conventions beyond 0 and 1, handling EPIPE and signals, and shipping a single executable file, so use the sections above for those. Both books also install
npm packages for environment-file loading and terminal prompts that process.loadEnvFile() and readline/promises now cover.
Pitfalls
-
A shebang with CRLF line endings fails with
env: node\r: No such file or directory. Normalise line endings for files underbin/. -
Forgetting
"type": "module"(or the.mjsextension) can makeimportin the entry file fail to load. Recent releases detect ES module syntax in an untyped.jsfile (see Running Scripts, the CLI & the REPL), but a published package should be explicit. -
Calling
process.exit()after writing a lot to stdout can cut the output when stdout is a pipe: the documentation says pipes are asynchronous on POSIX and synchronous on Windows, and thatprocess.exit()does not wait for pending I/O. Setprocess.exitCodeand return, as the entry file does. -
A prompt with a piped stdin that never answers leaves an unsettled promise; Node.js ends such a program with exit status 13 and a warning on all three lines. Check
stdin.isTTYfirst. -
Treating Ctrl+C at a prompt as a signal. In a terminal it arrives as a keystroke that readline reports as
'SIGINT'; with no listener the process can end with status 0. -
Ignoring
EPIPE. Writing withprocess.stdout.write()into a closed pipe throws an unhandled'error'event;console.log()hides it. -
Colour codes in piped output. Use
styleText()with the rightstreamoption, or the tool pollutes files and breaksgrep. -
Defaults in
parseArgs()that outrank the config file. Keep layered settings out ofdefault. -
Putting a secret in an argument or in the config file exposes it in
psoutput, shell history and backups. -
Shipping tests, fixtures or
.envfiles in the tarball. Usefilesand checknpm pack --dry-runbefore every publish. -
Treating an SEA as portable. It is built for one operating system and CPU architecture, a macOS build must be signed, and it is a copy of the exact Node.js version that built it.
References
-
Node.js API: Util (parseArgs, styleText, convertProcessSignalToExitCode), https://nodejs.org/docs/latest/api/util.html
-
Node.js API: Readline, https://nodejs.org/docs/latest/api/readline.html
-
Node.js API: Process, https://nodejs.org/docs/latest/api/process.html
-
Node.js API: TTY, https://nodejs.org/docs/latest/api/tty.html
-
Node.js API: OS, https://nodejs.org/docs/latest/api/os.html
-
Node.js API: Command-line options, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Child process, https://nodejs.org/docs/latest/api/child_process.html
-
Node.js API: Test runner, https://nodejs.org/docs/latest/api/test.html
-
Node.js API: Web Streams (utility consumers), https://nodejs.org/docs/latest/api/webstreams.html#utility-consumers
-
Node.js API: Single executable applications, https://nodejs.org/docs/latest/api/single-executable-applications.html
-
Node.js Learn: Run Node.js scripts from the command line, https://nodejs.org/learn/command-line/run-nodejs-scripts-from-the-command-line
-
Node.js Learn: Accept input from the command line in Node.js, https://nodejs.org/learn/command-line/accept-input-from-the-command-line-in-nodejs
-
Node.js Learn: Output to the command line using Node.js, https://nodejs.org/learn/command-line/output-to-the-command-line-using-nodejs
-
npm docs: package.json (bin, files, engines), https://docs.npmjs.com/cli/v12/configuring-npm/package-json/
-
npm docs: folders, https://docs.npmjs.com/cli/v12/configuring-npm/folders/
-
npm docs: npm pack, https://docs.npmjs.com/cli/v12/commands/npm-pack/
-
npm docs: npm exec, https://docs.npmjs.com/cli/v12/commands/npm-exec/
-
npm docs: npm link, https://docs.npmjs.com/cli/v12/commands/npm-link/
-
npm docs: npm publish, https://docs.npmjs.com/cli/v12/commands/npm-publish/
-
npm docs: Generating provenance statements, https://docs.npmjs.com/generating-provenance-statements/
-
npm docs: Trusted publishers, https://docs.npmjs.com/trusted-publishers/
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/