tutorial // Aug 20, 2021

How to Build a Command Line Interface (CLI) Using Node.js

How to use the Commander.js library to build a command-line interface (CLI) that talks to the JSON Placeholder API.

How to Build a Command Line Interface (CLI) Using Node.js

Getting Started

For this tutorial, we're going to create a fresh Node.js project from scratch. We're going to assume that we're using the latest version of Node.js (v16) as of writing.

On your computer, start by creating a folder where our CLI code will live:

Terminal

mkdir jsonp

Next, cd into the project folder and run npm init -f to force the creation of a package.json file for the project:

Terminal

npm init -f

With a package.json file, next, we want to add two dependencies: commander (the package we'll use to structure our CLI) and node-fetch which we'll use to run HTTP requests to the JSON Placeholder API:

Terminal

npm i commander node-fetch

With our dependencies ready, finally, we want to modify our package.json file to enable JavaScript modules support by adding the "type": "module" property:

/package.json

{
  "name": "jsonp",
  "type": "module",
  "version": "1.0.0",
  ...
}

With that, we're ready to get started.

Adding a bin flag to your package.json

Before we close up our package.json file, real quick we're going to jump ahead and add the bin property which, when our package is installed, will add the specified value to our user's command line PATH variable:

/package.json

{
  "name": "jsonp",
  "type": "module",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "bin": {
    "jsonp": "index.js"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "commander": "^8.1.0",
    "node-fetch": "^2.6.1"
  }
}

Here, we set bin to an object with a property jsonp set to a value of index.js. Here, jsonp is the name that our CLI will be made accessible as jsonp via the command line (e.g., $ jsonp posts). The index.js part is pointing to the location of the script that we want to associate with that command.

Let's create that index.js file now and start building our CLI. We'll revisit the significance of this bin setting later in the tutorial.

Setting up the main CLI command

Fortunately, thanks to the commander dependency we installed earlier, setting up our CLI is fairly straightforward.

/index.js

#!/usr/bin/env node

import cli from "commander";

cli.description("Access the JSON Placeholder API");
cli.name("jsonp");

cli.parse(process.argv);

Getting us set up, a few different things here. First, because our script will be executed via the command line (e.g., via a bash shell or zsh shell), we need to add what's known as a shebang line (don't be creepy). This tells the command line through what interpreter the passed script should be run. In this case, we want our code to be interpreted by Node.js.

So, when we run this file via the command line, its code will be handed off to Node.js for interpretation. If we excluded this line, we would expect the command line to throw an error as it wouldn't understand the code.

Below this line, we dig into our actual code. First, from the commander package we import cli. Here, because we expect a default export (meaning no specific name is used by Commander internally for the value it exports), we import it as cli instead of commander to better contextualize the code in our file.

Next, we add a description and name with .description() and .name() respectively. Pay attention to the syntax here. While working with Commander, everything we do is built off of the main Commander instance, here, represented as cli.

Finally, at the bottom of our file, we add a call to cli.parse() passing in process.argv. process.argv is pulling in the arguments passed to the Node.js process (the in-memory name for our script once loaded up) which are stored in the argv property on the process object. It's important to note that this is a Node.js concept and has nothing to do with Commander.

The Commander part is cli.parse(). This method, like the name implies, parses the arguments passed into our script. From here, Commander takes in any arguments passed to the script and tries to interpret and match them up with commands and options in our CLI.

Though we don't expect anything to happen just yet, to test this out, in your command line, cd into the root of the jsonp folder we created and run node index.js. If everything is setup correctly so far, the command should execute and return without printing anything out in the terminal.

Adding details and individual commands

Now for the interesting part. As of right now, our CLI is, well, useless. What we want to do is add individual commands that are part of the CLI that we can run or "execute" to perform some task. Again, our goal is to build a simple CLI for accessing the JSON Placeholder API. We're going to focus on three commands:

  1. posts will retrieve a list of posts from the API, or, a single post (we'll learn how to pass an argument to our commands to make this possible).
  2. comments will retrieve a list of comments from the API. We'll intentionally keep this simple to show variance between our commands.
  3. users will retrieve a list of users from the API, or, a single user. This will behave identical to the posts command, just accessing a different resource on the API.

Before we add our commands, real quick, we want to add some more cli-level settings to clean up the user experience:

/index.js

#!/usr/bin/env node

import cli from "commander";

cli.description("Access the JSON Placeholder API");
cli.name("jsonp");
cli.usage("<command>");
cli.addHelpCommand(false);
cli.helpOption(false);

cli.parse(process.argv);

Here, beneath our call to cli.name() we've added three more settings: cli.usage(), cli.addHelpCommand(), and cli.helpOption().

The first, cli.usage(), helps us to add the usage instructions at the top of our CLI when it's invoked via the command line. For example, if we were to run jsonp in our terminal (hypothetically speaking), we'd see a message that read something like...

Usage: jsonp <command>

Here, we're suggesting that you use the CLI by calling the jsonp function and passing the name of a sub-command that you'd like to run from that CLI.

The .addHelpCommand() method here is being passed false to say we do not want Commander to add the default help command to our CLI. This is helpful for more complex CLIs but for us, it just adds confusion.

Similarly, we also set .helpOption() to false to achieve the same thing, but instead of removing a help command, we remove the built-in -h or --help option flag.

Now, let's wire up the posts command we hinted at above and then see how to fetch data via the JSON Placeholder API.

/index.js

#!/usr/bin/env node

import cli from "commander";
import posts from "./commands/posts.js";

cli.description("Access the JSON Placeholder API");
cli.name("jsonp");
...

cli
  .command("posts")
  .argument("[postId]", "ID of post you'd like to retrieve.")
  .option("-p, --pretty", "Pretty-print output from the API.")
  .description(
    "Retrieve a list of all posts or one post by passing the post ID (e.g., posts 1)."
  )
  .action(posts);

cli.parse(process.argv);

Again, all modifications to our CLI are done off the main cli object we imported from the commander package. Here, we defined an individual command by running cli.command(), passing the name of the command we want to define posts. Next, using the method-chaining feature of Commander (this means we can run subsequent methods one after the next and Commander will understand it), we define an .argument() postId. Here, we pass two options: the name of the argument (using the [] square bracket syntax to denote that the argument is optional—required arguments use <> angle brackets) and a description of that argument's intent.

Next, to showcase option flags, we add .option(), first passing the short-form and long-form versions of the flag comma-separated (here, -p and --pretty) and then a description for the flag. In this case, --pretty will be used internally in the function related to our command to decide whether or not we will "pretty print" (meaning, format with two-spaces) the data we get back from the JSON Placeholder API.

To round out our command's settings, we call to .description() adding the description we want to display when our CLI is run without a specific command (effectively a manual or "help" page).

Finally, the important part, we finish by adding .action() and passing in the function we want to call when this command is run. Up top, we've imported a function posts from a file in the commands folder which we'll add now.

/commands/posts.js

import fetch from "node-fetch";

export default (postId, options) => {
  let url = "https://jsonplaceholder.typicode.com/posts";

  if (postId) {
    url += `/${postId}`;
  }

  fetch(url).then(async (response) => {
    const data = await response.json();

    if (options.pretty) {
      return console.log(data);
    }

    return console.log(JSON.stringify(data));
  });
};

To keep us moving, here, we've added the full code for our posts command. The idea here is fairly simple. The function we're exporting will be passed two arguments: postId if an ID was specified and options which will be any flags like --pretty that were passed in.

Inside of that function, we set the base URL for the /posts endpoint on the JSON Placeholder API in the variable url, making sure to use the let definition so we can conditionally overwrite the value. We need to do that in the event that a postId is passed in. If there is one, we modify the url appending /${postId}, giving us an updated URL like https://jsonplaceholder.typicode.com/posts/1 (assuming we typed in jsonp posts 1 on the command line).

Next, with our url, we use the fetch() method we imported from node-fetch up top passing in our url. Because we expect this call to return a JavaScript Promise, we add a .then() method to handle the response to our request.

Handling that response, we use a JavaScript async/await pattern to await the call to response.json() (this converts the raw response into a JSON object) and then stores the response in our data variable.

Next, we check to see if options.pretty is defined (meaning when our command was run, the -p or --pretty flag was passed as well) and if it is, we just log the raw JSON object we just stored in data. If options.pretty is not passed, we call to JSON.stringify() passing in our data. This will get us back a compressed string version of our data.

To test this out, open up your terminal and run the following:

node index.js posts --pretty

If everything is working, you should see some data coming back from the JSON Placeholder API, pretty-printed onto screen.

[
  {
    userId: 10,
    id: 99,
    title: 'temporibus sit alias delectus eligendi possimus magni',
    body: 'quo deleniti praesentium dicta non quod\n' +
      'aut est molestias\n' +
      'molestias et officia quis nihil\n' +
      'itaque dolorem quia'
  },
  {
    userId: 10,
    id: 100,
    title: 'at nam consequatur ea labore ea harum',
    body: 'cupiditate quo est a modi nesciunt soluta\n' +
      'ipsa voluptas error itaque dicta in\n' +
      'autem qui minus magnam et distinctio eum\n' +
      'accusamus ratione error aut'
  }
]

If you remove the --pretty flag from that command and add the number 1 (like node index.js posts 1), you should see the condensed stringified version of a single post:

{"userId":1,"id":1,"title":"sunt aut facere repellat provident occaecati excepturi optio reprehenderit","body":"quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"}

This sets up with a template for the rest of our commands. To wrap things up, let's go ahead and add those two commands (and their functions in the /commands directory) and quickly discuss how they work.

/index.js

#!/usr/bin/env node

import cli from "commander";
import posts from "./commands/posts.js";
import comments from "./commands/comments.js";
import users from "./commands/users.js";

cli.description("Access the JSON Placeholder API");
...

cli
  .command("posts")
  .argument("[postId]", "ID of post you'd like to retrieve.")
  .option("-p, --pretty", "Pretty-print output from the API.")
  .description(
    "Retrieve a list of all posts or one post by passing the post ID (e.g., posts 1)."
  )
  .action(posts);

cli
  .command("comments")
  .option("-p, --pretty", "Pretty-print output from the API.")
  .description("Retrieve a list of all comments.")
  .action(comments);

cli
  .command("users")
  .argument("[userId]", "ID of the user you'd like to retrieve.")
  .option("-p, --pretty", "Pretty-print output from the API.")
  .description(
    "Retrieve a list of all users or one user by passing the user ID (e.g., users 1)."
  )
  .action(users);

cli.parse(process.argv);

To showcase multiple commands, here, we've added in two additional commands: comments and users. Both are set up to talk to the JSON Placeholder API in the exact same way as our posts command.

You will notice that users is identical to our posts command—save for the name and description—while the comments command is missing an .argument(). This is intentional. We want to show off the flexibility of Commander here and show what is and isn't required.

What we learned above still applies. Methods are chained one after the next, finally culminating in a call to .action() where we pass in the function to be called when our command is run via the command line.

Let's take a look at the comments and users functions now and see if we can spot any major differences:

/commands/comments.js

import fetch from "node-fetch";

export default (options) => {
  fetch("https://jsonplaceholder.typicode.com/comments").then(
    async (response) => {
      const data = await response.json();

      if (options.pretty) {
        return console.log(data);
      }

      return console.log(JSON.stringify(data));
    }
  );
};

For comments, our code is nearly identical to what we saw earlier with posts with one minor twist: we've omitted storing the url in a variable so we can conditionally modify it based on the arguments passed to our command (remember, we've set up comments to not expect any arguments). Instead, we've just passed the URL for the JSON Placeholder API endpoint we want—/comments—and then perform the exact same data handling as we did for posts.

/commands/users.js

import fetch from "node-fetch";

export default (userId, options) => {
  let url = "https://jsonplaceholder.typicode.com/users";

  if (userId) {
    url += `/${userId}`;
  }

  fetch(url).then(async (response) => {
    const data = await response.json();

    if (options.pretty) {
      return console.log(data);
    }

    return console.log(JSON.stringify(data));
  });
};

This should look very familiar. Here, our function for users is identical to posts, the only difference being the /users on the end of our url as opposed to /posts.

That's it! Before we wrap up, we're going to learn how to install our CLI globally on our machine so we can actually use our jsonp command instead of having to run things with node index.js ... like we saw above.

Globally installing your CLI for testing

Fortunately, installing our package globally on our machine is very simple. Recall that earlier, we added a field bin to our /package.json file. When we install our package (or a user installs it once we've published it to NPM or another package repository), NPM will take the property we set on this object and add it to the PATH variable on our (or our users) computer. Once installed, we can use this name—in this tutorial, we chose jsonp for the name of our command—in our console.

To install our package, make sure you're cd'd into the root of the project folder (where our index.js file is located) and then run:

Terminal

npm i -g .

Here, we're saying "NPM, install the package located in the current directory . globally on our computer." Once you run this, NPM will install the package. After that, you should have access to a new command in your console, jsonp:

Terminal

jsonp posts -p

You should see the output we set up earlier in the console:

Wrapping Up

In this tutorial, we learned how to build a command line interface (CLI) using Node.js and Commander.js. We learned how to set up a barebones Node.js project, modifying the package.json file to include a "type": "module" field to enable JavaScript modules as well as a bin field to specify a command to add to the PATH variable on our computer when our package is installed.

We also learned how to use a shebang line to tell our console how to interpret our code and how to use Commander.js to define commands and point to functions that accept arguments and options. Finally, we learned how to globally install our command line tool so that we could access it via the name we provided to our bin setting in our package.json file.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode