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.
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:
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).comments
will retrieve a list of comments from the API. We'll intentionally keep this simple to show variance between our commands.users
will retrieve a list of users from the API, or, a single user. This will behave identical to theposts
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.