CHAPTER-7

Command Line Interfaces

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 g it. 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:

1  node cli/index-01.js dogs

And this will be the result:

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 Shebangl" 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 -81 . 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 -81 . 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 -81 . j s file has executable permissions. We can do that with the command chmod +x cli/index -81 . js. This is important later if we want to rename our file from cli - index -81 . 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”0 (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 – tabl e.

process”l 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:

'/usr/local/bin/node',
                         ,
'dogs'

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 cIi/ index . j s “cute dogs” instead of node cIi/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 1’2 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:

1  cli/index.js list [tag]

and

1  cli/index.js view ‹id›

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 liitit and of fset controls. To accept those on the command line we could allow:

1  cli/index.js list [tag] [limit] [offset]

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 1imit and of fset. 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 limi t and let of fset be 8 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, of fset, and Iimit for our product listing, it will be difficult for the user to remember if the order is [tag] [of fset] [ limit] or[tag] [limit] [offset].

If that’s not enough, we’d also like to support global options: settings that affect all commands. Right now we have hardcoded our CLI to only connect to our API that runs locally. This is useful for testing, but will not be useful for our users. Our CLI needs to accept an option to control which API it communicates with. When developing, we want to use the local API, but when we are actually using the CLI it should hit the deployed production version.

This problem gets even worse once we support more commands. Not only will they need to remember which commands we support, but they’ll need to remember which options go with which commands.

Luckily there’s a great way to deal with all of these issues: yargs“6, a fantastic module that helps “build interactive command line tools, by parsing arguments and generating an elegant user interface.” The best thing about yargs is that it makes it easy to create CLIs with many commands and options and dynamically generate a help menu based on those settings.

We’re going to use yargs to easily support invocations like

1  cli/index-B2 js list products \
2    -t dogs \
3    --limit=5 \
4    --offset=i5 \
5    --endpoint=https://fs-node-book-example.herokuapp.com

where we can use the “list products” command while specifying tag, limit, and offset options. We can also run

1  cli/index-02.js --help

and we’ll get a nicely formatted help screen:

To get this behavior, we first need to install yargs, and then we’ll use the following methods:

  • option( ) : this allows us to set global options that will affect all commands, e.g. which API endpoint to connect to.
  • command( ) : listen for a particular command, e.g. “list products”, define the particular options available for that command, and specify the function that should run and receive the options as an argument.
  • demandCommand( ) : show the user an error if they call the CLI without a command (e.g. they must use either “list products” or “view product”).
  • help( ) : build the help screens for our app — one for the main app and one for each of the commands (e.g. node cIi / index . js  Iist      products  – – heIp)
  • parse( ) : this method needs to be run after all the others to actually have yargs do its thing.

First we’ll take a look at the full code to use yargs to both list products and to view a single product, and then we’ll look at individual parts more closely:

#!/usr/bin/env node
const yargs - require('yargs')
const Table - require('cli-table')
const ApiClient - require(' ./api-client')

yargs
  .option( 'endpoint', {
   alias : ' e ' ,
   default :  ' http : //localhost : 1337 ' ,
   describe: 'The endpoint of the API'

. command(
   ' list products ' ,
     Get a list of products ’ ,

     tag: {
       alias: 't',
       describe: 'Filter results by tag'

     limit: (
       alias : ' I ' , 
       type : ' number ' , 
       default : 25,
       describe : ' Limit  the	number of  results '

    offset: { 
      alias: 'o',
      type: 'number ', 
      default: 0,
      describe: 'Skip number of results'


    listProducts

  .help()
  .demandCommand(1, 'You need at least one command before moving on')
  .parse( )

async function listProducts (opts) {
  const	tag, offset, limit, endpoint } - opts 
  const api - ApiClient( { endpoint })
  const products - await api. listProducts({ tag, offset, limit })

  const cols - process.stdout.columns	10 
  const colsld = 30
  const colsProp - Math.floor((cols	colsld) / 3) 
  const table - new Table({
    head: 1'ID', 'Description', 'Tags', 'User'], 
    colWidths: [colsId, colsProp, colsProp, colsProp]


products.forEach(p =>
 

table . push( [
  p._id,
  p.description.replace(/\n l \r/g, ' '),
  p.userName,
  p.tags.slice(0, 3).join(', ')


console log(table tostring())

The first 40ish lines are setup, and afterwards we define our IistProduct function. Here’s an outline of the code:

Digging into it, Here’s how we tell yargs to accept endpo i nt as an option either as – – endpoint=http: //localhost: 13 or as – e http : // localhost : 1337:

yargs
  .option('endpoint', {
  alias: 'e',
  default: 'http://localhost:{337', 
  describe:'The  endpoint of the API'
})

Using option( ) we can specify that the option can be used as – – endpoint= as well as -e (the alias), provide a default value, and give it a description for use in the help menu.

Next, we define our first command, Iist  products:

command( ) takes four arguments: the command itself, a description, an options object, and the function to be called. Each property of the options object behaves similarly to our previous use of the opt ion( ) method.

A nice thing about yargs is that it can convert data types for us. We learned before that process . argv will always give us strings. This means that if we did not use yargs and wanted a numerical argument, we’d have to convert it from a string to a number ourselves. However, because we specify that limit and of fset are numbers, yargs can do that for us.

The final argument of command( ) is the function that should run when the command is used. This function will received all of the parsed arguments and any default values that we’ve set. If the list products command is invoked without any options, here’s the argument that our listProducbs( ) function will receive:

endpoi nt :  ' http://localhost : 1337 , 
e: 'http://localhost:433T', 
limit: 25,
l: 25,
offset: 0,
O: 9,

We can see that yargs populated the default values for endpoi nt, 1 inn it, and of Iset. Not only that, yargs mirrored the value for each option’s alias. For example, both I i mi t and 1 have the default value of 25. This is a nice convenience for our function; either the long or short option property can be used.

After we set up our command, we tell yargs to build help screens and make them available with

– – heIp. If our users forget what commands are available, they can run cli / index -82 . j s  – – hep, and if they want to know what the options for a command are, they could run something like cli / index -82 . js  Iist  products  – – heIp. Notice that unlike the more general help screen, this lists

options specific to list  products: – – tag, – – limit, and – – offset.

Another convenience for the user is to ensure that at least one command is provided. If we did use demandCommand( ) in our code, and the user called our CLI without a command (list  products), our CLI would exit immediately without any output. A much friendlier way to handle this is to let the user know that at least one command is required and display the help screen showing the available commands.

At the moment we only have a single command available. We could make that the default so that it runs automatically if the user doesn’t specify a command. From a UX perspective, this would be recommended if we did not plan on adding more commands soon.

Then, after we finish configuring yargs we call yargs . parse( ) . This method tells yargs to parse process . argv and to run the appropriate functions with the provided options. Here’s how we can use those options in listProducts ( ) :

async function listProducts (opts) {
  const ( tag, offset, limit, endpoint } — opts 
  const api - ApiClient({ endpoint })
  const products = await api. listProducts( { tag, offset, limit })

Now that we’ve incorporated yargs, we have a great foundation for adding additional commands. Not only that, we’ve added several configuration options and help screens — both global and command-specific.

Additional Commands

With yargs in place, we can add additional commands very quickly. Let’s add view product as an example.

After a user uses Iist  products, it’s likely that they’ll want to examine a particular product in more detail. For example, if a user wants to see all of the properties of the product with ID cjv32m i z j888kc9g I 2r2lgj1r, they should be able to use this command:

1  cli/index-02b.js view product cjv32mizj000kc9gl2r2lgjlr

To make this happen, we need to change our CLI code in two ways:

  1. Use yargs . command ( ) to configure yargs to accept this new command, and
  2. define a new vi ewProduct( ) function to run when that command is used.

This new command will be slightly different, but overall it will be simpler:

.command('view product ‹id› ', 'View a product', {}, viewProduct)

This one is much shorter because we don’t have any named options. However, an important thing to notice is that we define a positional argument. By defining it as view product ‹ id› , we are telling yargs that this command must be invoked with an id argument.

Here’s how we can use this command:

1  cli/index-B2b js view product cjv32mizmBB3pc9gl5v856iBx

The view product command

Of course, we still need to write the viewProduct( ) function that runs when this command is invoked:

async function viewProduct (opts) { 
  const ( id, endpoint } = opts 
  const api - ApiClient({ endpoint })
  const product = await api.getProduct(id)

  const cols = process.stdout.columns	3 
  const table - new Table({
    colWidths: [15, cols	15]

  Object.keys(product).forEach(k =>
    table.push( { [k] JSON.stringify(product[k]) })



  console.log(table.tostring())

We can see that our positional option, id, is available as a property of the opts argument. We pass this to our api . getProduct( ) and apply formatting to the output similar to what we did before in listProducts ( ) .

Using yargs as a foundation for our CLI app we can quickly add new functionality, without worrying about overcomplicating the interface for our user. Our view product command even gets its own help screen — automatically:

The view product help screen

Now that we’ve got the basics of how to add new commands to our app, we’ll cover adding functionality that’s a bit more complicated.

CLIs and Authentication

Our CLI is off to a good start, and we now know how to easily add more commands and options. Our first two commands allow users to fetch public data from our API. Let’s talk about what we need to do to allow our admin users to edit products via our API.

If we think about the user flow, an admin user would use it like this:

  1. Use list     products to find the id of the product they’re interested in.
  2. Use view product ‹ id› to see all of the keys and values of that particular product.
  3. Use our no-yet-created edit  product ‹ id› command to change the value of a particular key.

Just like in the previous example, we use ‹ id› as a positional option. We do this so that it can be used like edit product cj v32m i zj 888oc9g l65ehco j 7, where cj v32m i zj 888oc9g 165ehco j 7 is the i d of the product we want to edit.

However, compared to our view product command, we’ll need four additional required options for edit product:

  • username
  • password
  • key
  • vaIue

username and password are required because this is a protected route, and key and va I ue are required to specify how we’ll actually change the product.

To use this command we’d run something like:

cli/index-03.js edit product cjv32mizj000oc9gl65ehcojT \
  -u admin \
  -p iamthewalrus \
  -k description \
  -v ”New Description”

To support this command we’re going to combine what we learned when creating the I i st products and v i ew product commands. We’ll add ed i I product and have it support both a positional option for id and named options for username, password, key, and va l ue:

Our editProduct( ) function will look similar to our listProducts( ) and viewProduct( ) methods. The only difference is that it uses more options:

async function editProduct (opts) {
  const ( id, key, value, endpoint, username, password } - opts 
  const change — { [key] value }

  const api = ApiClient( { username, password, endpoint })
  await api.editProduct(id, change)

  viewProduct( (  id , endpoint  ) )

In fact, because the options required for editProduct( ) are a superset of viewProduct( ), after the product is edited, we can run viewProduct( ) directly to take advantage of the output we’ve already created.

Editing a product

And just like before, we get a help menu for edit  product for free:

Editing a product

This works just fine, but unfortunately it’s bad practice to force users to specify credentials on the command line. There are few reasons for this, but the two main security issues are that:

  1. While this command is running, the credentials will be visible in the process list (e.g. with ps-aux or similar command) on the user’s machine.
  2. These credentials will be written to the user’s shell history file (e.g. / . bash_history or similar) in plaintext.

To prevent our user from running into either of these issues, we’ll want our user to provide credentials directly to the app via stdin” . We’ve already used process . stdin when we were exploring Async in chapter 2. However, this time around we’ll use an awesome module that is easy to use and will make our app feel polished.

Improved Security And Rich CLI Login Flows

To create a more secure login via CLI, we’ll handle user credentials in a way similar to a form in a web app. Instead of forcing the user to provide their username and password as command line options, we’ll provide an interactive prompt:

Edit a product with the auth prompt 1/3

Edit a product with the auth prompt 2/5

Edit a product with the auth prompt 3/3

After the command is invoked, our CLI asks the user for their username and password. Once the CLI receives the credentials, they’re used to make the authenticated request to edit the product. Because the user is providing the credentials directly to the application, they are not leaked to the process list or into a shell history file.

In the images we can see how the prompt works. First, the user is asked for their username, and after the username is entered, the CLI asks for the password. This looks really nice with the prompts, check marks, colors, and password masking — all features that we get from the excellent prompts”‘ and cha1 k” modules. Prompts handles the question prompts, and cha 1k allows us to easily customize colors.

We only need to change a small amount of our CLI to use question prompts instead of command-line options for credentials. We need to:

  • Require chalk (for colors) and prompts (for question prompts)
  • For the edit product command, remove the command-line options for username and password
  • Use prompts and chalk to display the question prompts to the user to get the credentials

Let’s take a look:

const chalk - require('chalk') 

const prompts = require('prompts') 

yargs

  . command(
  ’ edit  product  ‹ id› ' , 
  ' Edit a product ' ,

     key: (
       alias : ' k ' , 
       required : true,
       describe: 'Product key to edit'

     value: { 
       alias' 'v',
       required: true,
       describe: 'New value for product key'


    editProduct


async function editProduct (opts) {
  const ( id, key, value, endpoint } = opts 
  const change - { [key] value }

  const ( username, password } - await prompts([

      name: ' username',
      message: chalk.gray( 'What is your username?' ), 
      type: 'text'


      name: ' password ’ ,
      message: chalk.gray('What is your password?' ),
      type:  ' password ’


const api - ApiClient( { username, password, endpoint })
  await api.editProduct(id, change) 
  viewProduct({ id, endpoint })

Everything is almost the same. The difference is just how we get username and password. Lucky for us, that’s very simple with prompts:

const ( username, password } - await prompts([

  name:  ’ username ’ ,
  message: chalk.gray( 'What is your username?' ), 
  type: 'text'


  name: ' password ,
  message : chalk . gray( ' What is your password?' ) , 
  type: ' password ’

prompts is an async function that accepts an array of question objects. For our purposes, each object should have name, message, and type properties. prompts will return with an object containing the responses to each question. This return object will have keys that correspond to the name property of each of our question objects. In the array argument that we pass to prompts, we have two objects, one with name : ‘ username ‘ and the other with name: ‘ password ‘. This means that the response object will have keys username and password, and each will have the value that we’re interested in.

Our first question asks for the user’s username, and because this is a basic question (we don’t need to do anything special like mask a password), we use type text. message is the string that we want to display to the user. In our case this message is ‘ What is your username?’ . The only twist is that we want to change the color of this message, so we use chalk . gray( ) to change the color to gray.

chalk has a great API where we can call chalk . ‹style› ( ‹text› ) where ‹style› is a chalk style’60. What’s great is that the styles can be chained to combine effects:

Showing off what chalk can do in the Node.js REPL

Asking for the user’s password is almost the same. The only difference is that we set the type to password which will convert typed characters to *. This conforms to user expectations and makes the UX much nicer.

After we receive the credentials from prompts( ) we don’t need to make any more changes to this function. However, we still have a lot of room for improvement.

Our CLI tool has no memory. Our users will have to enter their credentials each time they want to make a change. This will be especially cumbersome for users with long, secure passwords.

Improving the Login UX

Instead of forcing our users to enter their credentials every time they want to access a protected feature, we can change our app so that they only need to log in once. The only trick is that we’ll need to store the user’s authentication token so that it can be reused.

We’re going to create three new commands:

  • login: ask the user for their credentials, attempt to log in, and if successful, it will store the user’s username and authentication token on the filesystem.
  • whoami: read the username of the logged in user from the filesystem and display it.
  • logout: delete the stored user’s credentials to reset back to the logged out state.

After these commands have been created, our user can first log in and then use the edit product command as many times as they like without being prompted to enter their credentials.

These new commands will write credentials to and read credentials from the filesystem. The question is: where’s the best place to store user credentials? We could choose an arbitrary file on the user’s machine, e.g. /login – into . json. However, it’s poor form to create files on a user’s machine that contain sensitive information, especially when a user doesn’t expect it. A better idea is to use an existing file designed for this purpose.

Unix systems established the convention of using a . netrc file that lives in a user’s home directory. This was originally used to store usernames and passwords for remote FTP servers, but is commonly used by other command-line tools such as curl1″ and heroku c1 i 6z (to name two command-line tools already used in this book).

To read and write to a system’s . netrc file we’ll use the conveniently named netrc 16″ module. netrc has an API that is very simple to work with:

The convention of the . netrc file is to have a section for each host that contains a log i n and password field. Heroku stores tokens instead of passwords in the password field, and we’ll follow that pattern (except our tokens will be JWTs instead of UUIDs).

One of the nice things about netrc is that it allows us to work with the . netrc file synchronously. This would be a big no-no for building an API, because synchronous filesystem access would block requests, but with a CLI we only have one user performing a single operation at a time. In this particular case, there’s no advantage to working with the . netrc file asynchronously because there’s no work to be done concurrently while we wait on reading or writing to that file.

Now that we can persist and restore our user’s authentication token, we can build our new commands. First, we make sure to require netrc, and tell yargs about our new login, logout, and whoam i commands:

const netrc - require('netrc')

yargs

  .command('login', 'Log in to API', (}, login)
  .command('logout', 'Log out of API', {}, logout)
  .command( ' whoami ', 'Check login status' , {}, whoami)

None of these commands accept any extra options. The only option they need is – -endpoint which is global and available to all commands.

Next, we define our login( ) function:

async function login (opts) { 
  const ( endpoint } — opts

  const ( username, password } - await prompts([

      name :  ' username ' ,
      message : chalk.gray ( ' What is your user name? ' ) , 
      type : ' text '


      name: 'password ',
      message: chalk.gray('What is your password?' ), 
      type: 'password'



const api = ApiClient( { username, password, endpoint })
const authToken - await api.login()

saveConfig( { endpoint, username, authToken })

console.log(chalk.green(’Logged in as ${chalk.bold(username)}’))

This should look very familiar. It’s almost the same thing we did in the last section for editProduct( ). However, in this case we’re using the username and password for api . login( ) instead of api . editProduct( ) .

By using api . login ( ) we get the authentication token that we want to store using a new function, saveConfig ( ) .

If everything goes well, we use chalk to display a success message in green.

What our success message will look like

Let’s take a look at our new saveConfig( ) function:

function saveConfig (( endpoint, username, authToken }) { 
  const allConfig - netrc()
  const host - endpointToHost(endpoint)
  allConfig[host] = ( login: username, password : authToken } 
  netrc.save(allConfig)

Here we can see how easy it is to use the netrc module to store the auth token. We use endpoint to get the host of the API, assign the username and token using that host as the key, and finally we call netrc . save( ) .

Our CLI expects the –endpoint option to contain the protocol (e.g. “https” or “http”) instead of just the host. For example, endpoint will be “https://example.com” instead of “example.com”. However, the convention for . netrc is to provide a login and password for each host. This means we should omit the protocol when storing information in . netrc. We won’t show it here, but this is why we use the endpointToHost( ) function to extract the host from the endpoint string.

netrc is designed to parse and save the entire . netrc file. We need to be careful not to overwrite settings for other hosts when we use netrc . save( ) . In our example, we access the configuration data for all hosts, but we take care to only change settings for a single host when we save the entire object again. Our users will not be happy with us if our CLI clears their login information for other apps.

If we want to log out, we can use the saveConfiig ( ) function, but have username and authToken be undefined:

function logout (( endpoint }) {
  saveConfig( ( endpoint ) )
  console.Iog( ' You are now logged out . ' )

The whoam i command will simply read our saved config and print the username:

function whoami ( { endpoint }) {
   const username } = loadConfig( endpoint })

   const message = username
     ? You are logged in as ${chalk.bold(username)}
      ’ You are	not  currently logged in . ’

console . log( message)

IoadCondfig( ) is also very simple:

function loadConfig (( endpoint }) ( 
  const host - endpointToHost(endpoint) 
  const config - netrc()[host] I I {}
  return ( username: config. login, authToken : config.password }

We use endpoint to determine which host to use when we pull our login information out of the netrc config. If no config exists, we return undefined for both the username and authToken.

We also modify editProduct( ) to use loadConfig( ) instead of prompting the user:

async function editProduct (opts) {
  const ( id, key, value, endpoint } - opts 
  const change = { [key] value }

  const ( authToken } = loadConfig(( endpoint })

  const api = ApiClient( { endpoint, authToken })
  await api . editProduct( id, change)

  viewProduct( (  id ,  endpoint  ) )

Now that we’ve made these changes, our users can use cIi / index-85. js login once at the beginning, and then they can use editproduct multiple times without having to worry about credentials.

This is certainly an improvement, but there are more ways we can improve our CLI. In particular, we should concentrate on what happens to the user when they don’t log in correctly or try to use edit product while unauthenticated.

Improving Protected Commands

Our CLI is really coming together. However, there are three rough spots that we should smooth out with improvements: auto-login for protected routes, friendlier error messages, and retry prompts.

If a user attempts to use a protected command like ed it product before they use login, they’ll see an unauthenticated error:

An error when using edit  product before login

If our app knows that the user is not logged in and that they need to log in before using the ed it product command, our app should not blindly run ed i t product and show the user a technical error with a stacktrace. The best thing to do is to run log i n for the user.

We're touching on a general UX principle that will serve us well in many domains. If the user has performed an action that isn't quite right, and our app can be certain what the desired action is, our app should take that desired action on behalf of the user. For example, if our CLI had an interactive mode and the user needed to type quit( ) to exit, and they typed quit instead of quit( ), we should not have a special error message that says “Use quit( ) to exit'’ If we are certain enough of the user's intention to display a special error message, we are certain enough to act on it. So instead of displaying an error message, we should just quit.

The next issue we face is that when logging in, if the user enters an incorrect username/password, our current output is verbose and unfriendly:

Login fail

Most users wou1dn’t want to read through that to figure out what went wrong. They’d just assume the CLI is broken. Instead of allowing our CLI to crash, we should catch the error and display a clear message of what went wrong (e.g. wrong password).

Third, if a user enters an incorrect username or password, we know they would probably like to retry. We can’t be certain, so the app should prompt for confirmation. However, we should not just quit the app and make them start over. We can even use this prompt in lieu of an unfriendly error/stacktrace, e.g. “Incorrect username and/or password. Retry? (y/N)”

Before we dive into code changes, let’s take a look at a flowchart that shows a our desired user flow:

Improved Edit Product User Flow

The flow is really simple when the user has already logged in; we just follow the left side of the flowchart. However, if the user is not logged in, we automatically trigger the log i n command and handle retry logic. If the user declines to retry, we show them the “Please log in to continue” error message.

Now that we have a good sense of what we want to achieve, we can start to change our code. The first thing we need to do is to change editProduct( ) so that it won’t automatically send the API request if the user is not logged in:

async function editProduct (opts) {
  const ( id, key, value, endpoint } = opts

  const authToken = await ensureLoggedIn({ endpoint })
  if (!authToken) return console. log('Please log in to continue. ')

  const change - { [key]  value }

  const api - ApiClient( { authToken, endpoint })
  await api.editProduct(id, change) 

  viewProduct({ id, endpoint })

We’re using a new ensureLoggedIn( ) method. This async function will either come back with an authToken or not. If the user is logged in, it will immediately return with the token. If the user is rtof logged in, it will trigger the login prompt. If the login is successful, it will also return with the authToken. ensureLoggedIn( ) will return without an authToken only if the user fails to log in (and declines to retry). Protected commands can use this method as shown above to ensure that a user is logged in before continuing.

Let’s take a look at ensureLoggedIn( ):

async function ensureLoggedIn ({ endpoint }) { 
  let ( authToken } = loadConfig( { endpoint }) 
  if (authToken) return authToken

  authToken - await login( { endpoint }) 
  return authToken

On the surface it’s pretty simple. We either load the authToken from our saved config or we trigger login( ) . If we can load a saved token, we’ll return with it, and if not, we show the login prompt with login( ). One thing to notice is that we do expect login( ) to return with an authToken, and that was not previously the behavior of login( ). We need to modify login( ) to make this work.

When we created login( ) we did not expect it to be used by other functions, and therefore there was no need to have it return any values. However, now that we want other functions and commands to be able to call it, it’s useful to know whether or not it’s successful, and if it is, it’s convenient to provide the authToken.

First, let’s look at how our login( ) function was before:

async function login (opts) { 
  const { endpoint } - opts

  const ( username, password } - await prompts([

      name: 'username',
      message: chalk.gray('What is your username?' ), 
      type: 'text'


      name: 'password',
      message: chalk.gray( 'What is your password?' ),
      type: 'password'



const api - ApiClient( { username, password, endpoint })
const authToken = await api.login() 

saveConfig({ endpoint, username, authToken })

console.log(chalk.green( Logged in as ${chalk.bold(username)} ))

And here’s the new version:

async function login (opts) { 
  const	endpoint } — opts
  const ( username, password } - await prompts([

      name: ' username',
      message: chalk.gray( 'What is your username?' ), 
      type: 'text'


      name : ' password ' ,
      message : chalk . gray( ' What i s your password?' ) , type : ' password '

try (
  const api - ApiClient( { username, password, endpoint }) 
  const authToken - await api.login()

  saveConfig({ endpoint, username, authToken })

  console log(chalk green(’Logged in as ${chalk bold(username)}’}}
  return  authToken
) catch (err ) (
  const shoul dRetry '	await askRetry (ear ) 
  if(shouldRetry ) returnlogin(opts )


return null

We have introduced a try/catch block. This allows us to handle both success and failure cases. If the login is successful, we return early with the authToken. This is useful if login( ) is called by another function like ensureLoggedIn( ) . In the case of ensureLoggedIn( ) , the token passes up the chain for eventual use in editProduct( ) .

Things get a bit more interesting if there’s a failure, because the logic within the catch block will run. This block uses a new method askRetry( ) that takes an error argument, shows the user a friendly message, asks if they’d like to retry their last action, and returns their response as a boolean:

async function askRetry (error) {
  const ( status } - error.response || (} 
  const message =
    status --- 401 ? 'Incorrect username and/or password. '	error.message

  const ( retry } = await prompts( ( 
    name: ' retry ’ ,
    type:  ’ confirm ’ ,
    message:  chalk . red( ”$(message}  Relay? )
) )

return retry

If the error status code is 401, we know that the user entered an invalid username/password combo, and we can tell them that. For anything else, we’ll output the error message itself. Unlike before, we’re not going to show the entire stacktrace.

Within login( ) , if askRetry( ) returns with true (because the user would like to retry), we recursively call login( ) to try again. By using recursion, we can easily allow the user to attempt as many retries as they’d like. If the user does not want to retry, askRetry( ) will return with false, and Iogin( ) will return with nuII for the authToken. This will eventually make it back to editProduct( ) and that method will show a “Please log in to continue” error message.

Now that we’ve covered all of these changes in sequence, let’s take a look at all of these functions together:

TODO: Not sure why there isn’t a separator between ensureLoggedIn() and login()

async function editProduct (opts) {
  const	id, key, value, endpoint } - opts

  const authToken - await ensureLoggedIn({ endpoint })
  if (!authToken) return console.log('Please log in to continue. ')

  const change — { [key]  value }

  const api - ApiClient( { authToken, endpoint }) 
  await api.editProduct(id, change)

  viewProduct({ id, endpoint })



async function ensureLoggedIn ({ endpoint }) { 
  let ( authToken } - loadConfig({ endpoint }) 
  if (authToken) return authToken

  authToken - await login( { endpoint }) 
  return authToken
}async function login (opts) { 
  const { endpoint } = opts
  const	username, password } - await prompts([

      name: ' username',
      message: chalk.gray( 'What is your username?' ), 
      type: 'text'


      name: 'password',
      message: chalk.gray('What is your password?' ),
      type: 'password'

try
  const api - ApiClient( { username, password, endpoint })
  const authToken - await api.login() 
  
  saveConfig( { endpoint, username, authToken })

  console.log(chalk.green(’Logged in as ${chalk.bold(username)}’)) 
  return authToken
} catch (err) {
  const shouldRetry = await askRetry(err) 
  if (shouldRetry) return login(opts)


return null


async function askRetry (error) {
  const ( status } - error.response || (} 
  const message =
    status --- 40a ? 'Incorrect username and/or password. '	error.message

  const ( retry } - await prompts({ 
    name: 'retry',
    type: 'confirm',
    message: chalk.red(’${message} Retry? )



return retry

With those changes in, we’ve made our CLI much smarter and friendlier:

  • If a user attempts to use ed it product when they’re not logged in, they will automatically see the login prompt.
  • If a user enters in an incorrect password, they will be asked if they would like to retry.
  • If a user encounters a login error, they will no longer see pages and pages of stacktraces.

Wrap Up

In this chapter we’ve learned how to build rich CLIs for our users. This is a powerful tool to add to our toolbox. Any API or platform can be dramatically improved by providing users (or admins) easy control over the features they use frequently. Our CLI can operate our API — even handling complex authentication flows.

Taking what we’ve learned from this chapter, in the future we’ll be able to easily control the format of our output (colors and tables), accept a wide variety of options and commands, provide help screens, and prompt our users for more information. All of these techniques will be very useful for any type of command-line tool we might want to create in the future.

TODO: use console.error instead of console.log to preserve stdout, handle ctrl-c during login prompt, require(‘debug’)