It's tough to imagine what life as a developer would be without CLIs. In this book we've relied heavily on commands like curl, jq, and git. We've also seen how important it is for platforms to have a CLI to interact with their products. We've used npm constantly to install module packages, and in the last chapter, we used the heroku CLI to deploy our API.

Just like web apps or other GUIs, CLIs are a great way for users to interact with our services. They are often a product themselves.

Building A CLI#

In this chapter we're going to build out a CLI for our API to be used by our users and admins. To begin, we'll make a simple script to pull a list of our products and display them in a table. A defining characteristic of a CLI is allowing the user to provide options to change the output, so we'll accept a tag as an argument and return products filtered by that tag.

After we create this script we can run it like this:

And this will be the result:

Our CLI Using Our API

Let's dive into the code to see how this is done:

Did you notice the first line, #!/usr/bin/env node? This is a Unix convention called a Shebang that tells the OS how to run this file if we open it directly. Above, we run the file with Node.js by using node cli/index-01.js dogs. However, by adding this shebang to the top of the file we would be able to run this file directly like : cli/index-01.js dogs and the OS would figure out that Node.js is needed and handle that for us. For this to work we need to make sure that the index-01.js file has executable permissions. We can do that with the command chmod +x cli/index-01.js. This is important later if we want to rename our file from cli-index-01.js to printshop-cli and be able to call it directly.

Our code does three things:

  1. Grab the user's tag with process.argv (it's ok if the user doesn't provide one).

  2. Fetch the product list using the API Client.

  3. Print the table of products using cli-table.

process is a globally available object in Node.js. We don't need to use require() to access it. Its argv property is an array that contains all command-line arguments used to call the script. The first item in the array will always be the full path to the node binary that was used to execute the script. The second item will be the path to the script itself. Each item after the second, will be any additional arguments to the script if they were provided.

When we execute cli/index.js dogs or node cli/index.js dogs, the process.argv array will be equal to something like this:

If we did not execute the script with dogs as an argument, process.argv would only have the first two items in the array.

process.argv will always contain an array of strings. If we wanted to pass a multiword string as a single argument, we would surround it with quotes on the command line, e.g. node cli/index.js "cute dogs" instead of node cli/index.js cute dogs. By doing this we'd see "cute dogs" as a single argument instead of "cute" and "dogs" as two separate arguments.

After our script has figured out if the user wants to filter results by a tag, it uses the API client to fetch the product list. If the tag is undefined because the user did not specify one, the API client will fetch all products up to the default limit (25 in our case).

We're not going to cover how the API client was built, but the code is fairly straightforward. It's a simple wrapper around the axios module that accepts a few options to make HTTP requests to predefined endpoints of our API.

Lastly, once we get the product list from the API client, we use the cli-table module (by Guillermo Rauch, who is also the author of mongoose) to print a nicely formatted table.

Most of the code in this example is used to make the output look as nicely as possible. cli-table does not automatically size to the width of a user's terminal, so we need to do some math to figure out how much space we have for each column. This is purely for aesthetics, but spending time on this will make the CLI feel polished.

We know how many characters a user's terminal has per line by checking process.stdout.columns. After we figure out our margins and how much space our ID column needs, we divide up the remaining space between the other columns.

Now that we have a basic CLI that easily return product listings filtered by tag, we've got a good foundation to build from.

Sophisticated CLIs#

Our first example is a nice interface for a very narrow use case. It works great as long as the user only wants to get product listings filtered by tag. However, we want to be able to support a wide range of functionality, and we want to be able to easily add new features.

If we want to support new types of commands, we're going to have to rethink how we're parsing arguments. For example, if we want support both a list view and detailed single product view, we're going to need change how our CLI is invoked.

Currently we use the form cli/index.js [tag]. To handle both cases we could change this to cli/index.js [command] [option]. Then we could accept things like:

and

This would work fine for these two examples. We'd check process.argv[2] to determine what command the user wants, and this would tell us how to interpret process.argv[3]. If the command is list, we know it's a tag, and if it's view, we know it needs to be an product ID.

For example, in addition to tag filtering, our API also supports limit and offset controls. To accept those on the command line we could allow:

However, what happens if the user doesn't want to filter by tag? A user wouldn't be able to omit tag if they still wanted to set a limit and offset. We could come up with rules where we assume tag is undefined if there are only two arguments, but what happens if a user wants to only provide tag and limit and let offset be 0 by default? We could check to see if the first option looks like a number (assuming we have no numeric tags), to guess user intention, but that's a lot of added complexity.

As our app develops more functionality, it will become more difficult for our users to remember what the options are and how to use it. If we support options for tag, offset, and limit for our product listing, it will be difficult for the user to remember if the order is [tag] [offset] [limit] or [tag] [limit] [offset].

 

This page is a preview of Fullstack Node.js

Start a new discussion. All notification go to the author.