---
title: 'Command-Line Applications'
source: 'https://academia.sh/en/courses/nodejs/command-line-applications'
course: 'The Node.js Runtime'
language: en
updated: '2026-08-17T18:09:51+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
#!/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.

```sh
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.

```sh
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:

```sh
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:

```js
// tty.mjs
console.log('stdout connected to terminal:', Boolean(process.stdout.isTTY));
console.log('stdin  connected to terminal:', Boolean(process.stdin.isTTY));
```

```sh
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:

```sh
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:

```sh
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:

```sh
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:

```sh
./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.
