How to Build CLI in Node.js

| Comments

CLI (Command-line interface) is a very common way to run a program in a terminal. As a software engineer you use different CLIs every day – git, Docker, npm, etc.

Today I want to share my experience building CLIs using Node.js and a few helpful packages.

Goal

I’m going to only focus on npm in this article, simply because I don’t have enough experience with other package managers for Node.js and apparently npm is still the most popular choice.

So, our goal is to have something like this in the end:

1
2
npm install -g awesometool
awesometool run --fast

npm setup

First of all, let’s make sure we have a proper npm configuration and folder structure!

bin/awesometool.js:

1
2
3
#!/usr/bin/env node

require("../lib/awesometool")

Snippet above should look really straightforward – we simply require the main file (or entry point) of the app, in our case ./lib/awesometool.js.

package.json:

1
2
3
4
5
6
7
8
9
10
{
  "name": "awesometool",
  "version": "0.1.2",
  ...
  "main": "./lib/awesometool.js",
  "bin": {
    "awesometool": "bin/awesometool.js"
  },
  ...
}

The most important things in the package.json are:

  • name: this is what we use for naming (in npm ecosystem)
  • version: every time you change your program you’ll have to update the version and publish it
  • main: simply an entry point
  • bin.awesometool: should point to our bin/awesometool.js file. Also, this name is going to be used after the installation as a terminal command for our program

Designing the CLI

Now we have the basic setup and it’s time to think about the CLI itself. Usually every Command-line interface description consists from a few sections:

  • usage information
  • a list of available commands
  • a list of available options

So, you need to decide how to structure the CLI – what commands do you need, what options are available, what are the safe defaults, etc.

A few resources that can help with that:

Also I encourage to get some inspiration from well-known tools like Docker or Heroku CLI.

After you realize what kind of commands and options you would need, instead of implementing command-line arguments parsing from scratch (yay) and solving a bunch of terminal rendering issues we’re going to use commander.

commander is an awesome tool to help us define CLI commands, options and related actions. Here’s an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import commander from 'commander'

commander
    .option('--fast', 'running things even faster')

commander
    .command('run')
    .description('Run something')
    .action(() => {
        console.log('Working!')
        console.log(commander.dryRun)
    })

commander.parse(process.argv)

if (commander.rawArgs.length <= 2) {
    commander.help()
}

I like how concise and expressive it is. We define one command with an action plus an option that’s available for all commands.

This is how it looks in the terminal:

1
2
3
4
5
6
7
8
9
10
  Usage: awesometool [options] [command]

  Commands:

    run   Run something

  Options:

    -h, --help  output usage information
    --fast  running things even faster

Bonus: inquiry session flow

If you build a complex CLI you might need to ask user a few questions before proceeding. To make things easier for the end user it’s usually a good idea to introduce validation, default values and some other helpers. Also, sometimes user needs to choose from a set of predefined values instead of entering a custom one. All these things are handled by inquirer!

Let me show you the wonderful pizza example they have:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
/**
 * Pizza delivery prompt example
 * run example by writing `node pizza.js` in your console
 */

'use strict';
var inquirer = require('..');

console.log('Hi, welcome to Node Pizza');

var questions = [
  {
    type: 'confirm',
    name: 'toBeDelivered',
    message: 'Is this for delivery?',
    default: false
  },
  {
    type: 'input',
    name: 'phone',
    message: 'What\'s your phone number?',
    validate: function (value) {
      var pass = value.match(/^([01]{1})?[-.\s]?\(?(\d{3})\)?[-.\s]?(\d{3})[-.\s]?(\d{4})\s?((?:#|ext\.?\s?|x\.?\s?){1}(?:\d+)?)?$/i);
      if (pass) {
        return true;
      }

      return 'Please enter a valid phone number';
    }
  },
  {
    type: 'list',
    name: 'size',
    message: 'What size do you need?',
    choices: ['Large', 'Medium', 'Small'],
    filter: function (val) {
      return val.toLowerCase();
    }
  },
  {
    type: 'input',
    name: 'quantity',
    message: 'How many do you need?',
    validate: function (value) {
      var valid = !isNaN(parseFloat(value));
      return valid || 'Please enter a number';
    },
    filter: Number
  },
  {
    type: 'expand',
    name: 'toppings',
    message: 'What about the toppings?',
    choices: [
      {
        key: 'p',
        name: 'Pepperoni and cheese',
        value: 'PepperoniCheese'
      },
      {
        key: 'a',
        name: 'All dressed',
        value: 'alldressed'
      },
      {
        key: 'w',
        name: 'Hawaiian',
        value: 'hawaiian'
      }
    ]
  },
  {
    type: 'rawlist',
    name: 'beverage',
    message: 'You also get a free 2L beverage',
    choices: ['Pepsi', '7up', 'Coke']
  },
  {
    type: 'input',
    name: 'comments',
    message: 'Any comments on your purchase experience?',
    default: 'Nope, all good!'
  },
  {
    type: 'list',
    name: 'prize',
    message: 'For leaving a comment, you get a freebie',
    choices: ['cake', 'fries'],
    when: function (answers) {
      return answers.comments !== 'Nope, all good!';
    }
  }
];

inquirer.prompt(questions).then(function (answers) {
  console.log('\nOrder receipt:');
  console.log(JSON.stringify(answers, null, '  '));
});

I like the balance between using declarative rules and writing custom logic for validation and default values.

Bonus: colors

Yes, colors in your terminal! Or colours

So easy to use:

1
2
3
4
5
6
import colors from 'colors'

console.log('hello'.green); // outputs green text
console.log('i like cake and pies'.underline.red) // outputs red underlined text
console.log('inverse the color'.inverse); // inverses the color
console.log('OMG Rainbows!'.rainbow); // rainbow

Comments