Lesson 13 / 20
Command-Line Applications
Option parsing, taking input from a file or a pipeline, checking terminal attachment, writing the usage text to the right stream, and meaningful exit codes.
Contents
The Built-in Modules topic built every piece of the measurement collector. The service form is not the only way to use it: the same summarizing logic also works as a command-line tool that plugs into a pipeline — quickly inspecting a log file should not require standing up a server.
This lesson writes that tool, to the standard set by the Shell Programming course’s filter model: arguments taken in a defined form, input read from a file or a pipeline, data written to the first stream and descriptions to the second, result reported with an exit code.
Parsing Options
Scanning process.argv by hand gets error-prone past the third option: short and
long forms, the value separator, combined flags, arguments after --. parseArgs in
node:util applies these rules in a defined way. It’s considered stable from a
certain version onward; targeting an older runtime means checking for its presence.
Every option’s type and default are declared to the parser. In strict mode, an
undeclared option throws; this keeps a typo from being silently ignored.
#!/usr/bin/env node // collect.mjs import { parseArgs } from 'node:util'; import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { Summarizer, recordFromLine } from './summarizer.mjs'; const USAGE = `usage: collect.mjs [--file PATH] [--metric NAME] [--format text|json] --file measurement file; standard input is read if omitted --metric summarize only this metric --format output format: text or json (default: text) --help print this text and exit `; function readOptions(args) { const { values } = parseArgs({ args, options: { file: { type: 'string' }, metric: { type: 'string' }, format: { type: 'string', default: 'text' }, help: { type: 'boolean', default: false }, }, strict: true, }); if (!['text', 'json'].includes(values.format)) { throw new TypeError(`--format can only be text or json, got: ${values.format}`); } return values; } let options; try { options = readOptions(process.argv.slice(2)); } catch (error) { process.stderr.write(`${error.message}\n\n${USAGE}`); process.exit(2); } if (options.help) { process.stdout.write(USAGE); process.exit(0); } const input = options.file ? createReadStream(options.file) : process.stdin; if (!options.file && process.stdin.isTTY) { process.stderr.write(`no input\n\n${USAGE}`); process.exit(2); } const summarizer = new Summarizer(); let read = 0; let skipped = 0; try { for await (const line of createInterface({ input, crlfDelay: Infinity })) { if (line.trim() === '') continue; read += 1; let record; try { record = recordFromLine(line); } catch { skipped += 1; continue; } if (options.metric && record.metric !== options.metric) continue; summarizer.add(record); } } catch (error) { process.stderr.write(`could not read input: ${error.code ?? error.message}\n`); process.exitCode = 66; } const summary = summarizer.summary(); if (options.format === 'json') { process.stdout.write(JSON.stringify(summary) + '\n'); } else { for (const row of summary) { process.stdout.write(`${row.key}\t${row.count}\t${row.average}\t${row.max}\n`); } } process.stderr.write(`${read} lines read, ${skipped} lines skipped\n`);
The tool uses the summarizer.mjs module written in the HTTP Server lesson as is.
The same validation and the same accumulation apply in both interfaces; this is the
payoff of keeping the logic in a module separate from the server.
node collect.mjs --file measurements.ndjson
edge-01/temperature 3 21.87 22.3 edge-01/humidity 2 47.6 48 edge-02/temperature 3 20.23 20.7 edge-02/humidity 1 52.5 52.5 edge-03/temperature 2 24.35 24.6 edge-03/humidity 1 41.3 41.3 12 lines read, 0 lines skipped
The last line was written to standard error; redirecting one of the streams is enough to tell them apart.
Two Sources of Input
In the Shell Programming course, filters split into three groups; the third accepts
both a file name and standard input. The tool above belongs to that group:
process.stdin is read if --file is not given.
cat measurements.ndjson | node collect.mjs --metric temperature --format json
[{"key":"edge-01/temperature","count":3,"average":21.87,"max":22.3},{"key":"edge-02/temperature","count":3,"average":20.23,"max":20.7},{"key":"edge-03/temperature","count":2,"average":24.35,"max":24.6}]
12 lines read, 0 lines skipped
Line-by-line reading goes through node:readline, which takes over the
line-splitting work written by hand in the Streams lesson and preserves backpressure:
the source pauses while the loop body runs. The whole file never loads into memory.
The crlfDelay: Infinity option counts the \r\n pair as a single line ending. This
setting is necessary when reading files produced on different platforms.
The tool’s output can also be fed into a pipeline:
node collect.mjs --file measurements.ndjson 2>/dev/null | sort -t$'\t' -k3 -n -r | head -3
edge-02/humidity 1 52.5 52.5 edge-01/humidity 2 47.6 48 edge-03/humidity 1 41.3 41.3
The tab-separated column format was chosen for this composition. The JSON format is for output that will be processed by a program; the column format works directly with the field-based tools from the Shell Programming course.
Checking Terminal Attachment
If standard input is attached to a terminal and the user gave no file name, the program silently starts waiting. To the user, this looks like a freeze.
The process.stdin.isTTY field reports whether the stream is attached to a
terminal. In a run connected to a pipeline, the value is undefined:
// tty.mjs console.log('stdout connected to terminal:', Boolean(process.stdout.isTTY)); console.log('stdin connected to terminal:', Boolean(process.stdin.isTTY));
node tty.mjs | cat
stdout connected to terminal: false stdin connected to terminal: false
When the same script is run directly in a terminal, both lines print true.
This check is used not only to avoid the freeze but also to choose the output format: color and alignment can be added for a terminal, but not for a file or a pipe — color escape sequences mix into the data, and the next command mistakes them for field content.
Separating Errors
The tool has three error conditions, and each is reported with a separate code.
An unrecognized option is a usage error:
node collect.mjs --unknown; echo "exit code: $?"
Unknown option '--unknown' usage: collect.mjs [--file PATH] [--metric NAME] [--format text|json] --file measurement file; standard input is read if omitted --metric summarize only this metric --format output format: text or json (default: text) --help print this text and exit exit code: 2
Failing to read the input is a separate condition and gets a separate code:
node collect.mjs --file missing.ndjson; echo "exit code: $?"
could not read input: ENOENT 0 lines read, 0 lines skipped exit code: 66
Individual broken lines, though, are not an error; they are counted and skipped:
node collect.mjs --file broken.ndjson
edge-01/humidity 1 48 48 edge-02/humidity 1 50 50 3 lines read, 1 lines skipped
This distinction is deliberate: dropping the entire report over one broken line in bulk data makes all of it unusable, but the skipped count must not be silently hidden — it goes to the diagnostic stream.
The usage text is written to standard error, because it is part of an error.
When requested with the --help option, though, it goes to standard output and the
exit code is zero: there, the usage text is the requested result itself.
With the interpreter declaration on the file’s first line and execute permission granted, the tool can be called directly:
./collect.mjs --file measurements.ndjson --metric humidity
edge-01/humidity 2 47.6 48 edge-02/humidity 1 52.5 52.5 edge-03/humidity 1 41.3 41.3 12 lines read, 0 lines skipped
Summary
- The built-in option parser takes option names and types declaratively; strict mode treats an undeclared option as an error and catches typos.
- The same summarizing module is used in both the service and the command-line tool; this is the payoff of separating logic from interface.
- Input is taken from a file or standard input; line-by-line reading preserves backpressure and never loads the file into memory.
- Checking terminal attachment both prevents the wait and helps choose the output format; color escapes mix into data in a pipeline.
- Usage errors, input errors, and individual broken records are handled separately; the first two with separate exit codes, the third with a counter.
Next Step
The tool and the service still have hardcoded values: the file name, the port, the body-size limit. These should change with the environment, but the source code should not. The next lesson takes up configuration management: where values are read from, how they’re validated, and why secrets stay separate from ordinary settings.
To keep your progress and take notes, Log in
My notes
Log in to take notes.