Mert Özen Further With Every Line
Backend Node.js Fundamentals — #19

Writing Scripts and CLI Tools in Node.js: process, util.parseArgs, Exit Codes, readline, child_process and Signals

Mert Özen Nov 15, 2026 14 min 8 views
Writing Scripts and CLI Tools in Node.js: process, util.parseArgs, Exit Codes, readline, child_process and Signals

A Node.js CLI parses process.argv with util.parseArgs, writes results to stdout and errors to stderr, exits through process.exitCode instead of process.exit, and calls external commands without a shell through execFile or spawn. With real output: an export file never created because of process.exit, strict parseArgs catching a typo, a confirmation that does not ask without a terminal, a command injection through exec, and a script that deletes its temp file on SIGINT.

Version note: The examples in this post were run with Node.js 26.13.1 (LTS), npm 11.20.0, TypeScript 7.0.2 and Git.

Guess First

The back office team of Bookstore, the online bookstore used as the shared example across this site's series, wrote a nightly export script. It writes 100,000 order lines to a CSV file and exits:

import { createWriteStream } from "node:fs";
import { mkdirSync } from "node:fs";

// An export script: 100,000 order lines into a CSV file, then exit
mkdirSync("labs/19/out", { recursive: true });
const out = createWriteStream("labs/19/out/orders.csv");
for (let i = 1; i <= 100_000; i++) {
    out.write(`${1000 + i};9780441172719;1;349.90\n`);
}
console.log("export written");
process.exit(0);

The screen says export written. How many lines are in the file?

node labs/19/guess.ts
node labs/19/count-lines.ts labs/19/out/orders.csv
export written
labs/19/out/orders.csv: does not exist

The file was never created. createWriteStream opens the file asynchronously, and the write() calls first go to an in-memory buffer; process.exit(0) drops everything still waiting in the event loop and ends the process on the spot. Even with the file already open, things would not be much better: in a version that waited for the file to open first, only the first line was written. The fix is to end the stream, wait for it to finish, and let the process exit on its own:

const out = createWriteStream("labs/19/out/orders.csv");
for (let i = 1; i <= 100_000; i++) {
    out.write(`${1000 + i};9780441172719;1;349.90\n`);
}
out.end();
await finished(out);
console.log("export written");
process.exitCode = 0;
export written
labs/19/out/orders.csv: 100000 lines

In this post we look at the pieces of a command-line tool in Node.js: the process object, exit codes, util.parseArgs, readline, child_process and signals. By the end Bookstore will have a CLI called bookstore-tools; the .NET counterpart is a console app with System.CommandLine, or a dotnet tool.

process: argv, env, exitCode, stdout and stderr

Everything a CLI uses to talk to the outside world is on process. process.argv is an array of strings: the first two elements are the paths of node and the script, your arguments are process.argv.slice(2). process.env holds the environment variables; the --env-file we saw in post 02 fills this object, and unlike configuration in ASP.NET Core, the values are always strings.

There are two ways out. process.exit(code) ends the process right now; process.exitCode = code only sets the exit code, and the process exits with it once its work is done. The loss in Guess First was the price of the former. The Node documentation also says that process.stdout and process.stderr behave differently depending on the target: synchronous for files, asynchronous for pipes on POSIX systems, synchronous for pipes on Windows. So a script that calls process.exit() right after console.log may lose lines on Linux when its output goes to | grep, and not on Windows. The rule: use process.exit() only for a truly unexpected situation, when nothing is left to write; in the normal flow use process.exitCode.

Keeping stdout and stderr apart matters too. stdout is for the tool's result and may become another program's input; warnings, progress and errors go to stderr. The exit code is the only signal that tells the caller whether the script succeeded: a CI job, commands chained with &&, or $LASTEXITCODE in PowerShell look at it. The usual convention: 0 for success, 1 when the work failed, 2 for wrong usage. Shells report a process stopped with Ctrl+C as 130 (128 + 2, the number of SIGINT).

Arguments with util.parseArgs

Instead of parsing arguments by hand, there is util.parseArgs, stable since Node 20. You declare the options with their types, and it returns values and positionals. Bookstore's first command turns the CSV import we wrote in post 08 into a CLI command:

async function importBooks(args: string[], io: CliIo): Promise<number> {
    const { values, positionals } = parseArgs({
        args,
        allowPositionals: true,
        options: {
            out: { type: "string", short: "o", default: "data/catalog.json" },
            "dry-run": { type: "boolean", default: false },
            json: { type: "boolean", default: false },
            yes: { type: "boolean", short: "y", default: false },
        },
    });
    if (positionals.length !== 1) {
        throw new UsageError("import-books needs exactly one CSV file");
    }
    const [csvPath] = positionals;
    const { books, errors } = await readCatalogCsv(csvPath);

    // Problems go to stderr, the result to stdout: "--json > result.json" stays valid JSON
    for (const error of errors) {
        io.stderr.write(`${styleText("yellow", "skipped", { stream: io.stderr })} ${error}\n`);
    }
    const dryRun = values["dry-run"];
    if (!dryRun) {
        if ((await exists(values.out)) && !values.yes && !(await confirm(`${values.out} exists. Overwrite? (y/N) `, io))) {
            io.stderr.write("nothing written\n");
            return EXIT.failed;
        }
        await mkdir(path.dirname(values.out), { recursive: true });
        await writeFileAtomic(values.out, JSON.stringify(books, null, 2) + "\n");
    }

    if (values.json) {
        io.stdout.write(JSON.stringify({ file: csvPath, imported: books.length, skipped: errors.length, written: dryRun ? null : values.out }) + "\n");
    } else {
        const target = dryRun ? "would be imported (dry run)" : `imported into ${values.out}`;
        io.stdout.write(`${styleText("green", "done", { stream: io.stdout })} ${books.length} books ${target}, ${errors.length} rows skipped\n`);
    }
    return errors.length > 0 ? EXIT.failed : EXIT.ok;
}

strict is on by default: an undeclared option makes parseArgs throw with the code ERR_PARSE_ARGS_UNKNOWN_OPTION. main catches that code and its own UsageError, prints the usage and returns 2:

npx bookstore-tools import-books --dryrun data/catalog-sample.csv
echo "exit $?"
error: Unknown option '--dryrun'. To specify a positional argument starting with a '-', place it at the end of the command after '--', as in '-- "--dryrun"
usage: bookstore-tools <command> [options]
  import-books <file.csv> [--out <file.json>] [--dry-run] [--json] [--yes]
  snapshot [--out <file.zip>] [--allow-dirty]
exit 2

The typo was not silently ignored, and no real import ran under the impression of a dry run. default values and short aliases (-o, -y) live in the same declaration. parseArgs knows nothing about subcommands (import-books, snapshot); a switch on the first argument is enough for that. For larger tools that need generated help, subcommand trees and shell completion, there are packages such as commander and yargs; they are the counterparts of System.CommandLine.

The --json option shows why separating stdout and stderr matters:

npx bookstore-tools import-books data/catalog-sample.csv --dry-run --json > labs/19/out/result.json
echo "exit $?"
cat labs/19/out/result.json
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
exit 1
{"file":"data/catalog-sample.csv","imported":6,"skipped":2,"written":null}

The skipped rows appeared in the terminal, and the file got only one valid JSON line. The exit code is 1 because two rows were skipped; in CI this command turns red. Colours need no package: util.styleText has been stable since 23.5 and checks whether the stream you pass is a terminal, as well as the NO_COLOR and FORCE_COLOR variables. That is why output redirected to a file has no colour codes.

Asking for Confirmation with readline

If the target file exists, we want to ask before overwriting it. Asking a question with node:readline/promises takes one line; the real decision is when to ask:

// Asks only on a terminal; in CI or behind a pipe nobody can answer, so the answer is "no"
async function confirm(question: string, io: CliIo): Promise<boolean> {
    if (!io.stdin.isTTY) {
        io.stderr.write(`${question.trim()} no terminal to ask, pass --yes\n`);
        return false;
    }
    const rl = createInterface({ input: io.stdin, output: io.stderr });
    try {
        const answer = await rl.question(question, { signal: io.signal });
        return /^y(es)?$/i.test(answer.trim());
    } finally {
        rl.close();
    }
}

In CI or behind a pipe there is nobody to answer; waiting there means the job hangs until its timeout. process.stdin.isTTY is true only on a real terminal. First outside a terminal, with stdin connected to an empty input:

npx bookstore-tools import-books data/catalog-sample.csv
echo "exit $?"
npx bookstore-tools import-books data/catalog-sample.csv < /dev/null
echo "exit $?"
npx bookstore-tools import-books data/catalog-sample.csv --yes
echo "exit $?"
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
done 6 books imported into data/catalog.json, 2 rows skipped
exit 1
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
data/catalog.json exists. Overwrite? (y/N) no terminal to ask, pass --yes
nothing written
exit 1
skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
done 6 books imported into data/catalog.json, 2 rows skipped
exit 1

On the first run the file did not exist and was written. On the second, stdin was not a terminal; the tool did not ask, wrote nothing and returned 1. With --yes it overwrote the file. All three runs exit with 1, because the sample file's two bad rows are skipped every time. On a terminal the same command asks; the answer is n:

skipped line 8: invalid ISBN-13 9780141439519
skipped line 9: invalid price abc
data/catalog.json exists. Overwrite? (y/N) n
nothing written
exit 1

We pass a signal to question so that the question can be cancelled while it waits; pressing Ctrl+D in the terminal makes question reject with Aborted with Ctrl+D, which I also saw while trying it.

All of this can be tested, because main takes its streams as parameters: the test passes PassThrough streams and collects the output into strings. No child process, no terminal:

// Runs the CLI in-process with captured streams: no child process, no terminal
async function run(argv: string[]) {
    const stdout = new PassThrough();
    const stderr = new PassThrough();
    let out = "";
    let err = "";
    stdout.on("data", (chunk) => (out += chunk));
    stderr.on("data", (chunk) => (err += chunk));
    const code = await main(argv, { stdout, stderr, stdin: new PassThrough(), signal: new AbortController().signal });
    return { code, out, err };
}
▶ bookstore-tools
  ✔ rejects an unknown option with exit code 2 (0.822833ms)
  ✔ prints only JSON on stdout with --json (0.972125ms)
  ✔ does not overwrite without a terminal or --yes (1.40625ms)
✔ bookstore-tools (5.6755ms)
ℹ tests 3
ℹ pass 3
ℹ fail 0

package.json "bin" and npx

Instead of running node scripts/bookstore-tools.ts, let's give it a command name. The "bin" field in package.json maps the command name to the file, and the file's first line is a shebang:

#!/usr/bin/env node
// Back office CLI, registered in package.json "bin": npx bookstore-tools <command>
import { main } from "../src/tools/cli.ts";

// Ctrl+C and SIGTERM cancel the running work through a signal instead of killing the process mid-write;
// a second Ctrl+C has no listener any more and stops the process at once
const interrupted = new AbortController();
for (const name of ["SIGINT", "SIGTERM"] as const) {
    process.once(name, () => interrupted.abort(new Error(`received ${name}`)));
}

// exitCode instead of process.exit(): pending writes to stdout and files still finish
process.exitCode = await main(process.argv.slice(2), {
    stdout: process.stdout,
    stderr: process.stderr,
    stdin: process.stdin,
    signal: interrupted.signal,
});
"bin": {
  "bookstore-tools": "scripts/bookstore-tools.ts"
}

npx bookstore-tools runs this file inside the project; on Windows, npm reads the shebang and generates a .cmd and a .ps1 wrapper (I did not try this step on Windows). There is a limit: Node does not strip types from .ts files under node_modules. Running a .ts file copied into node_modules gave me ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. This tool works inside the project, but published to npm and installed with npm install -g it would not; compile a CLI you publish to JavaScript, as described in post 05.

child_process: execFile, spawn and Running Without a Shell

The second command puts the committed state of the code into a zip file. Git itself produces the zip with git archive --format=zip, so we do not depend on a zip command, which Windows does not have:

// Arguments go as an array and no shell is started: nothing in them is interpreted
async function git(args: string[], signal?: AbortSignal): Promise<string> {
    const { stdout } = await execFileAsync("git", args, { signal, maxBuffer: 1024 * 1024 });
    return stdout.trim();
}

export interface GitSnapshot {
    file: string;
    commit: string;
    bytes: number;
}

// Zips the committed source at HEAD; git writes the zip, so it works the same on Windows, Linux and macOS
export async function createGitSnapshot(options: { out?: string; allowDirty: boolean; signal?: AbortSignal }): Promise<GitSnapshot> {
    const commit = await git(["rev-parse", "--short", "HEAD"], options.signal);
    const changes = await git(["status", "--porcelain"], options.signal);
    if (changes && !options.allowDirty) {
        throw new Error(`the working tree has ${changes.split("\n").length} uncommitted change(s); commit them or pass --allow-dirty`);
    }
    const file = options.out ?? `bookstore-api-${commit}.zip`;

    // git archive writes the zip to stdout; it is streamed to the file instead of being buffered in memory
    const child = spawn("git", ["archive", "--format=zip", "HEAD"], { signal: options.signal, stdio: ["ignore", "pipe", "pipe"] });
    let stderr = "";
    child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk));
    try {
        const [, [code, signal]] = await Promise.all([pipeline(child.stdout, createWriteStream(file)), once(child, "close")]);
        if (code !== 0) {
            throw new Error(`git archive failed (${signal ?? `exit code ${code}`}): ${stderr.trim()}`);
        }
    } catch (err) {
        // Never leave half a zip behind
        await rm(file, { force: true });
        throw err;
    }
    return { file, commit, bytes: (await stat(file)).size };
}

child_process has four main functions. execFile runs a program with an array of arguments and hands you the whole output when it finishes; good for short output, and it kills the process if the output exceeds maxBuffer. spawn does the same with streams; the megabytes from git archive flow straight to the file through the pipeline of post 09. exec hands the command to a shell as a single string; fork starts a new Node process with a message channel between the two. execFile and spawn also take a signal: when it is aborted the child process is stopped, so on Ctrl+C Git stops with us. We read the exit code from the close event, because it fires after stdout and stderr have closed.

npx bookstore-tools snapshot
npx bookstore-tools snapshot --allow-dirty --out labs/19/out/bookstore-api.zip
error: the working tree has 5 uncommitted change(s); commit them or pass --allow-dirty
exit 1
done labs/19/out/bookstore-api.zip (commit b78afc1, 167 KB)
exit 0

Why not exec? Because a shell interprets the command line, and a value that came from a user can turn into a command:

// A branch name that came from user input (a CLI argument, a web form...)
const branch = "main; echo INJECTED: this line was run by the shell";

// exec: one string, handed to a shell (/bin/sh, cmd.exe on Windows); ';' starts a second command
const viaShell = await execAsync(`git log --oneline -1 ${branch}`);
console.log("exec:\n" + viaShell.stdout.trim());

// execFile: no shell; the whole value is a single argument for git
try {
    await execFileAsync("git", ["log", "--oneline", "-1", branch]);
} catch (err) {
    console.log("execFile:\n" + (err as { stderr: string }).stderr.trim().split("\n")[0]);
}
exec:
b78afc1 18 HTTP client
INJECTED: this line was run by the shell
execFile:
fatal: invalid object name 'main; echo INJECTED'.

With exec, everything after the ; ran as a second command: a command injection. Here it is a harmless echo; it could just as well have been rm or a download. On Windows cmd.exe does not treat ; as a separator, but & does the same job. execFile and spawn without a shell passed the value as a single argument, and Git found no such branch. Since Node 24, passing both an argument array and shell: true to spawn or execFile also triggers a runtime deprecation warning (DEP0190), because the arguments are joined without escaping. A Windows-specific note: .bat and .cmd files cannot run without a shell; if you must call a command such as npm on Windows, the documentation suggests running it through cmd.exe /c and validating the arguments yourself.

Signals: SIGINT and SIGTERM

Ctrl+C sends SIGINT to the process; docker stop and Kubernetes send SIGTERM. With no listener, Node exits immediately. We saw this with a script that writes an export piece by piece; after a second I sent the process a SIGINT (what Ctrl+C does in a terminal):

exit 130
labs/19/out/export.csv.tmp: 12000 lines

A half-finished temporary file of 12,000 lines was left behind. Adding a listener removes the default behaviour, and the shutdown is yours to manage. The script ties the signal to an AbortController; when the wait is cancelled it closes the stream, deletes the temporary file and exits with 130:

if (handle) {
    for (const name of ["SIGINT", "SIGTERM"] as const) {
        process.once(name, () => stop.abort(new Error(`received ${name}`)));
    }
}
export cancelled after 12000 lines (received SIGINT), temp file removed
exit 130
labs/19/out/export.csv.tmp: does not exist

bookstore-tools uses the same pattern and registers it with process.once: the first Ctrl+C cancels the work cleanly, and since no listener is left, a second Ctrl+C stops the process at once. A stuck tool that cannot be stopped forces the user to close the terminal. The graceful shutdown of the API in post 14 is the same idea; there the goal was to finish open requests. On Windows Ctrl+C arrives as SIGINT; you can add a listener for SIGTERM, but the documentation says it is not supported on Windows.

Production Notes

Scripts are code too: bookstore-tools lives in the same repo, with the same type check and tests. Keep one-off scripts at least under labs/ or scripts/; a script that runs in production and that nobody can find is a nasty surprise.

No colours and no prompts in CI. styleText uses no colour without a terminal; give every command that asks for confirmation a --yes option, and never wait when stdin is not a terminal.

Build paths with path.join, and split lines coming from Git with /\r?\n/: on Windows, line endings may be \r\n. When you call a child process, make sure the command exists on Windows too; git and node do, zip, grep and sed do not.

Common Pitfalls

Passing user input to exec as a string. It opens the door to command injection; use execFile or spawn with an argument array.

Calling process.exit() before the work is done. Data in streams and stdout is lost; exit through process.exitCode.

Writing errors to stdout. The program reading the output takes them for data.

Exiting with 0 on every error. CI shows a failed import as green.

Catching SIGINT and never exiting. The user is left with no way to stop the process.

A Small Experiment

★☆☆ Add a --quiet option to import-books: it should not print the skipped rows, but the exit code must not change. Write a test.

★★☆ Write a bookstore-tools changes command: read the stdout of spawn("git", ["log", "--format=%an"]) line by line with readline's createInterface and print the number of commits per author.

★★★ Switch import-books to reading with streams as in post 09, and verify with a child process test that on SIGINT it deletes the half-written temporary file and exits with 130 (start it with spawn, send child.kill("SIGINT")).

In the next post we move on to node:crypto: we will verify the HMAC signature of a webhook from the payment provider, store passwords with scrypt and encrypt a sensitive field with AES.