Premium lesson

Writing CLI Programs in Node.js

Node.js·51 min read·Jan 1, 2025

Node.js is a great versatile tool that packages all the functionalities needed to write a variety of command-line interface programs, such as file management tools, automation scripts, development utilities, API testing tools, and more.

It has access to the underlying APIs of the operation system through the core modules, as well as environment variables, and command-line arguments through the global process object.

Writing an entry point

In software development, an entry point is a point in a program where its execution begins and where it has access to command line arguments.

A common way to implement an entry point in Node.js is to set up a try...catch block in charge of catching and gracefully handling any eventual error that may occur during the script's execution:

// import modules

try {
  // execute main logic
} catch(error) {
  // handle errors
}

Writing an asynchronous entry point

An elegant way to implement an asynchronous entry point in Node.js is to use an immediately invoked function expression (IIFE), whose anonymous function is declared using the async keyword, which will in turn enable the future use of the await operator within the function's body.

// import modules

(async () => {
  try {
    // execute main logic
  } catch(error) {
    // handle errors
  }
})();

Setting an exit status

By convention, programs running on Unix-like operating systems will exit with the value 0 if everything went well, and another value between 1 and 255 if something went wrong.

In Node.js, to set a custom exit status and immediately terminate the execution of the program, you can use the global process.exit() method that takes as argument an optional integer representing the exit code:

// import modules

(async () => {
  try {
    // execute main logic
  } catch(error) {
    // handle errors
    process.exit(1);
  }
  process.exit(0);
})();

Note: By default, the exit code will be set to 0 if it is unspecified.

Accessing command-line arguments

To access the arguments supplied to a Node.js script through the command-line, you can use the global process.argv array:

process.argv[index]

Where:

  • process.argv[0] is the absolute path to the Node.js binary used to execute the script.

  • process.argv[1] is the absolute path to the script being executed.

  • process.argv[2+n] are the actual command-line arguments supplied to the script.

Note: The elements contained in the process.argv array are always strings, even if they represent numbers or other types. Consequently, you will need to explicitly convert them using functions like parseInt() or JSON.parse().

Example

Let's consider this script, that prints the values of the global process.argv array:

console.log('Node.js path:\n - ' + process.argv[0]);
console.log('Script path:\n - ' + process.argv[1]);
console.log('Command-line arguments:');

for (let i = 2 ; i < process.argv.length ; i++) {
  console.log(' - ' + process.argv[i]);
}

Which will produce this output:

$ node index.js Hello World
Node.js path:
 - /Users/razvan/.nvm/versions/node/v20.3.0/bin/node
Script path:
 - /Users/razvan/scripts/index.js
Command-line arguments:
 - Hello
 - World

Parsing command-line arguments

Parsing command-line arguments refers to the process of extracting and interpreting the values passed to a program in order to customize its behavior.

In Node.js, to parse command flags and positional arguments, you can use the parseArgs() method of the util core module:

const { parseArgs } = require('node:util');

const { values, positionals } = parseArgs({
  options,
  allowPositionals?
});

Where:

  • values is an object mapping the parsed flags and their values.

  • positionals is an array containing positional arguments.

  • options is an object used to describe flags.

  • allowPositionals is an optional boolean used to enable positional arguments. Defaults to false.

Defining command flags

To define command flags, you can use the following syntax:

{
  options: {
    flag: {
      type,
      multiple?,
      short?,
      default?
    },
    ...
  }
}

Where:

  • flag is a string describing the name of the flag in the long format (e.g., 'long' -> '--long').

  • type is a string describing the type of the argument's value. Must be either 'string' or 'boolean'.

  • multiple is a boolean indicating whether this option can be provided multiple times. If true, all values are collected in an array, otherwise, values for the option are last-wins. Defaults to false.

  • short is a single character describing the name of the flag in the short format (e.g., 'l' -> '-l').

  • default is either a string, a boolean, or an array of either type. It must be of the same type as the type property. When multiple is true, it must be an array.

Note: It is usually recommended to implement a --help or -h flag to display a concise guide on how to use the CLI, including common examples and usage patterns.

Example

Let's consider this script, that re-implements the Unix head utility:

// File: head.js

const { readFileSync } = require('node:fs');
const { parseArgs } = require('node:util');
const { basename } = require('node:path');

try {
  // Extract the filename of the script
  const scriptName = basename(process.argv[1]);

  // Define the `--bytes`, `--lines`, and `--help` flags
  let { values: flags, positionals: files } = parseArgs({
    options: {
      'bytes': {
        type: 'string',
        short: 'c'
      },
      'lines': {
        type: 'string',
        short: 'n'
      },
      'help': {
        type: 'boolean',
        short: 'h',
        default: false
      }
    },
    allowPositionals: true
  });

  // Output the script manual if the `--help` flag is set to `true` or no files supplied
  if (flags.help || !files.length) {
    const scriptManual = `NAME
     ${scriptName} – display first lines of a file

SYNOPSIS
     $ node ${scriptName} [-n count | -c bytes] file ...

OPTIONS
     -c bytes, --bytes=bytes
             Print bytes of each of the specified files.

     -n count, --lines=count
             Print count lines of each of the specified files. Defaults to 10.

EXIT STATUS
     The ${scriptName} script exits 0 on success, and >0 if an error occurs.`;

    console.log(scriptManual);
    process.exit(0);
  }

  // Convert the flags values into integers
  flags.bytes = parseInt(flags.bytes);
  flags.lines = parseInt(flags.lines);

  // Output an error message if both flags are in use
  if (!isNaN(flags.bytes) && !isNaN(flags.lines)) {
    console.error(`${scriptName}: can\'t combine line and byte counts`);
    process.exit(1);
  }

  // Loop on positional arguments
  for (let file of files) {
    // Read the content of the current file
    const contents = readFileSync(file, { encoding: 'utf8' });

    // Output the name of the current file if there are more than one
    if (files.length > 1) {
      console.log(`==> ${file} <==`);
    }

    // Output the specified number of bytes
    if (flags.bytes) {
      process.stdout.write(contents.slice(0, flags.bytes));
    }
    // Output the specified number of lines or by default 10
    else {
      process.stdout.write(contents.split('\n').slice(0, flags.lines || 10).join('\n'));
    }
  }

  process.exit(0);
} catch(error) {
  console.error(error);
  process.exit(1);
}

Which will produce this output:

$ node head.js --help
NAME
     head.js – display first lines of a file

SYNOPSIS
     $ node head.js [-n count | -c bytes] file ...

OPTIONS
     -c bytes, --bytes=bytes
             Print bytes of each of the specified files.

     -n count, --lines=count
             Print count lines of each of the specified files. Defaults to 10.

EXIT STATUS
     The head.js script exits 0 on success, and >0 if an error occurs.
$ node head.js head.js
// File: head.js

const { readFileSync } = require('node:fs');
const { parseArgs } = require('node:util');
const { basename } = require('node:path');

try {
  let { values: flags, positionals: files } = parseArgs({
    options: {
      'bytes': {
$ node head.js -c 10 head.js
// File: h
$ node head.js -n 5 head.js
// File: head.js

const { readFileSync } = require('node:fs');
const { parseArgs } = require('node:util');
const { basename } = require('node:path');
$ node head.js -n 5 -c 10 head.js
head.js: can't combine line and byte counts

Accessing environment variables

To access the environment variables available in the shell session the script is launched from, you can use the global process.env object:

process.env.ENVIRONMENT_VARIABLE

Notes:

  • While this object can be modified by the script, such modifications won't be reflected outside of the Node.js process.

  • It is usually recommended to provide fallback values in case environment variables are undefined.

Example

Let's consider this script, that re-implements the Unix printenv utility:

// File: printenv.js

// Remove the first 2 elements of the `argv` array
const args = process.argv.slice(2);

// Check if there are positional arguments
if (args.length > 0) {
  // Retrieve the last positional argument
  const varName = args[args.length - 1];

  // Check if the variable exists in the `env` object
  if (varName in process.env) {
    // Output its value
    console.log(process.env[varName]);
  } else {
    // Terminate with an error
    process.exit(1);
  }
} else {
  // Loop on each key of the `env` object
  for (let key of Object.keys(process.env)) {
    // Output the key-value pair
    console.log(`${key}=${process.env[key]}`);
  }
}

// Terminate without errors
process.exit(0);

Which will produce this output:

$ node printenv.js
SHELL=/bin/bash
USER=razvan
PWD=/Users/razvan/learnbackend/scripts/printenv.js
PS1=\u:\w$
SHLVL=1
HOME=/Users/razvan
$ node printenv.js USERNAME
$ node printenv.js SHELL
/bin/bash
$ node printenv.js SHELL USER
razvan

Making a script executable system-wide

To invoke your Node.js script directly without having to prefix it with the node command, you can add this shebang directive at the very top of your script:

#!/usr/bin/env node

Make you script executable using the chmod command:

$ chmod +x script.js

And copy it into one of the directories specified in your PATH environment variable, such as /usr/local/bin:

$ cp script.js /usr/local/bin/.

🗒️ Summary

Here's a summary of what you've learned in this lesson:

  • The global process.exit() method is used to set the exit status of the script.

  • The global process.argv array contains the command-line arguments supplied to the script.

  • The util.parseArgs() static method allows to define and parse command-line flags.

  • The global process.env object contains the environment variables present in the script's environment.

🤖 Projects

Here's a list of projects to apply what you've just learned:

  • lb_bc: Write a command-line script that partially reimplements the bc utility.
icon light bulb key

Unlock the Build CLI Apps in JavaScript & Node.js module

Learn how to build, integrate, and safeguard scalable CLI tools, scripts, and apps with JavaScript, Node.js, npm, and Git.

You get immediate access to:

  • 45 focused lessons across JavaScript, Node.js, npm, and Git
  • 17 real-world projects with commented solutions
  • Ongoing updates to this bundle
  • Lifetime access to this bundle
Unlock this module