Home

Guide: How to create a NodeJS CLI (Command Line Interface)

Introduction

In this guide, we are going to:

  1. Create a simple interactive CLI that prompts the user for a URL and opens that in a browser.
  2. Implement the option to skip the interactive part by providing arguments directly to the CLI (like —help, —force, etc.)
  3. Introduce the basic tools that you should know to create powerful NodeJS CLI’s with ease.

All the tools we are going to use are optional. You can choose and combine only the ones you need. They are also very customizable on their own - check out their full documentation to discover more awesomeness.

TL;DR The full project is available on GitHub.

Getting Started

To get started, we first need to create a standard Node project:

$ mkdir nodejs-cli
$ cd nodejs-cli
$ npm init -y

Rename name and add bin in your package.json file:

package.json
{
"name": "devimal-cli","version": "1.0.0",
"description": "",
"main": "index.js",
"bin": "./index.js","scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

Create an index.js file and add a shebang line at the very top:

index.js
#!/usr/bin/env node

Creating the CLI Script

For the dynamic portion of our CLI, we are going to use a fun package called Inquirer.

To install it, open a terminal window and run:

$ npm install --save inquirer

Now let’s begin creating our CLI script in index.js.

We want to create an async function called run since prompting the user for input is an asynchronous action.

One of the cool things about Inquirer is that is works great with async/await.

Let’s add a confirmation prompt:

index.js
#!/usr/bin/env node

async function run() {  const { openDevimal } = await inquirer.prompt({    type: 'confirm',    name: 'openDevimal',    message: 'Would you like to visit devimalplanet.com?',    default: true  });  console.log(openDevimal);}run();

Now when we run our script, it prints:

$ node index.js

? Would you like to visit devimalplanet.com? $ Yes
true

Nice! Now let’s add some more functionality. When the user answers “no” we want to prompt him with a new question.

We can accomplish that like so:

index.js
#!/usr/bin/env node

const inquirer = require('inquirer');

async function run() {
  console.log('Hi! 👋  Welcome devimal-cli!');  let urlToVisit = 'devimalplanet.com';
  const { openDevimal } = await inquirer.prompt({
    type: 'confirm',
    name: 'openDevimal',
    message: 'Would you like to visit devimalplanet.com?',
    default: true
  });

  if (!openDevimal) {    // not opening devimalplanet.com    const { someFunUrl } = await inquirer.prompt({      type: 'input',      name: 'someFunUrl',      message: '😢  No? Which URL would you like to visit?',      validate: function(input) {        return (          /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(            input          ) || 'Please enter a valid URL.'        );      }    });    urlToVisit = someFunUrl;  }  console.log(urlToVisit);}

run();

Cool stuff! This example is simple, but you can already see how async/await will help readability by avoiding chaining .then()’s when your CLI begins to grow.

Also, notice that Inquirer makes it easy to validate the user’s input via the validate function. We return true if it passes, or a string with the error message if it fails. I got the URL regex from here.

You can see how the project looks like at this stage by clicking here.

Opening the URL

Now that we have seen how to do the interactive part of the CLI, let’s add some code to open the URLs in the browser. In your own CLI, you could replace that with any other code of your choice.

For opening the chosen URL’s, we are going to use a handy package called open.

$ npm install --save open

Adding open to our index.js:

index.js
#!/usr/bin/env node

const inquirer = require('inquirer');
const open = require('open');
async function run() {
  console.log('Hi! 👋  Welcome devimal-cli!');
  let urlToVisit = 'devimalplanet.com';

  const { openDevimal } = await inquirer.prompt({
    type: 'confirm',
    name: 'openDevimal',
    message: 'Would you like to visit devimalplanet.com?',
    default: true
  });

  if (!openDevimal) {
    // not opening devimalplanet.com
    const { someFunUrl } = await inquirer.prompt({
      type: 'input',
      name: 'someFunUrl',
      message: '😢  No? Which URL would you like to visit?',
      validate: function(input) {
        return (
          /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(
            input
          ) || 'Please enter a valid URL.'
        );
      }
    });

    urlToVisit = someFunUrl;
  }

  urlToVisit = urlToVisit.startsWith('http')    ? urlToVisit    : 'https://' + urlToVisit;  await open(urlToVisit);}

run();

Now you can open devimalplanet.com:

$ node index.js

? Would you like to visit devimalplanet.com? $ Yes

You can see how the project looks like at this stage by clicking here.

Making the Script Available in the Command Line

Alright! Now our script does as advertised, but we always have to call node <path to script>. We don’t want that.

CLIs like Angular CLI allow us to just type ng <cmd> in any terminal window. That’s what we want, and npm makes it very easy:

$ npm link

Now we can use our script from the terminal by invoking the name property of our pakcage.json (we renamed it earlier, remember?):

$ devimal-cli
Hi! 👋  Welcome devimal-cli!
? Would you like to visit devimalplanet.com? (Y/n)

Yeeeeah! That’s what we are talking about!

With the above knowledge you can already create some CLIs, but if you wish to learn a bit more, read on.

You can see how the project looks like at this stage by clicking here.

Bonus Tip #1: Skipping the Interactive Part

More developed CLIs often allow you to skip the interactive questions by passing command line arguments.

Let’s add -y and -u flags to skip all CLI prompts. For that we are going to use a package called Commander:

$ npm install --save commander
index.js
#!/usr/bin/env node

const inquirer = require('inquirer');
const open = require('open');
const package = require('./package.json');
const commander = require('commander');

const devilmalUrl = 'devimalplanet.com';
const program = setupCommander();

async function run() {
  const ranWithArgs = program.skipPrompts || program.url;
  if (!ranWithArgs) return interactiveRun();

  const url = typeof ranWithArgs === 'string' ? ranWithArgs : devilmalUrl;
  return staticRun(url);
}

async function interactiveRun() {
  console.log('Hi! 👋  Welcome devimal-cli!');

  const { openDevimal } = await inquirer.prompt({
    type: 'confirm',
    name: 'openDevimal',
    message: 'Would you like to visit devimalplanet.com?',
    default: true
  });

  const urlToVisit = openDevimal
    ? devilmalUrl
    : (
        await inquirer.prompt({
          type: 'input',
          name: 'someFunUrl',
          message: '😢  No? Which URL would you like to visit?',
          validate: function(input) {
            return (
              /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(
                input
              ) || 'Please enter a valid URL.'
            );
          }
        })
      ).someFunUrl;

  await openUlr(urlToVisit);
}

async function staticRun(url) {
  // for now just opens url
  await openUlr(url);
}

async function openUlr(url) {
  url = url.startsWith('http') ? url : 'https://' + url;
  await open(url);
}

function setupCommander() {
  const program = new commander.Command();

  program
    .version(package.version)
    .option(
      '-y, --skip-prompts',
      'skips questions, directly opens devimalplanet.com'
    )
    .option('-u, --url <URL>', 'skips questions, directly opens provided URL')
    .parse(process.argv);

  return program;
}

run();

A quick look at the changes:

  1. In setupCommander() we initialized Commander in a variable called program. We then gave our program its version (from package.json), the -y and -u <URL> options, and told it to parse the process arguments.
  2. We split our code into interactiveRun() and staticRun().
  3. run() now only controls whether staticRun() or interactiveRun() should be called.

For this guide’s simplicity, we are going to keep all code in a single file. But keep in mind that as your CLI begins to grow, you should start splitting your code in different modules/files. Just like in any other type of development!

Also, Commander is a very powerful tool. Check out their docs to see more awesome stuff it can do.

Click here to browse the full project state up to this point.

Bonus Tip #2: Adding ASCII Art

This is completely cosmetic, but if you want to add some ASCII art into your CLI, you may like Figlet:

$ npm install --save figlet
index.js
#!/usr/bin/env node

const inquirer = require('inquirer');
const open = require('open');
const package = require('./package.json');
const commander = require('commander');
const figlet = require('figlet');
const devilmalUrl = 'devimalplanet.com';
const program = setupCommander();

async function run() {
  const ranWithArgs = program.skipPrompts || program.url;
  if (!ranWithArgs) return interactiveRun();

  const url = typeof ranWithArgs === 'string' ? ranWithArgs : devilmalUrl;
  return staticRun(url);
}

async function interactiveRun() {
  console.log('Hi! 👋  Welcome devimal-cli!');

  const { openDevimal } = await inquirer.prompt({
    type: 'confirm',
    name: 'openDevimal',
    message: 'Would you like to visit devimalplanet.com?',
    default: true
  });

  const urlToVisit = openDevimal
    ? devilmalUrl
    : (
        await inquirer.prompt({
          type: 'input',
          name: 'someFunUrl',
          message: '😢  No? Which URL would you like to visit?',
          validate: function(input) {
            return (
              /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(
                input
              ) || 'Please enter a valid URL.'
            );
          }
        })
      ).someFunUrl;

  await openUlr(urlToVisit);
}

async function staticRun(url) {
  console.log(await generateAsciiArt());  await openUlr(url);
}

async function openUlr(url) {
  url = url.startsWith('http') ? url : 'https://' + url;
  await open(url);
}

function setupCommander() {
  const program = new commander.Command();

  program
    .version(package.version)
    .option(
      '-y, --skip-prompts',
      'skips questions, directly opens devimalplanet.com'
    )
    .option('-u, --url <URL>', 'skips questions, directly opens provided URL')
    .parse(process.argv);

  return program;
}

async function generateAsciiArt() {  return new Promise((resolve, reject) => {    // figlet docs: https://www.npmjs.com/package/figlet    figlet.text(      'Devimal',      {        font: 'ANSI Shadow',        horizontalLayout: 'default',        verticalLayout: 'default'      },      function(err, data) {        if (err) {          console.log('Something went wrong...');          console.dir(err);          reject(err);        }        resolve(data);      }    );  });}
run();

The result:

$ devimal-cli -y
██████╗ ███████╗██╗   ██╗██╗███╗   ███╗ █████╗ ██╗
██╔══██╗██╔════╝██║   ██║██║████╗ ████║██╔══██╗██║
██║  ██║█████╗  ██║   ██║██║██╔████╔██║███████║██║
██║  ██║██╔══╝  ╚██╗ ██╔╝██║██║╚██╔╝██║██╔══██║██║
██████╔╝███████╗ ╚████╔╝ ██║██║ ╚═╝ ██║██║  ██║███████╗
╚═════╝ ╚══════╝  ╚═══╝  ╚═╝╚═╝     ╚═╝╚═╝  ╚═╝╚══════╝

Click here to browse the full project state up to this point.

Bonus Tip #3: Add Spinners for Long Running Tasks

Bonus Tip #2 was completely cosmetic. This is one is not.

CLI feed back is important. Without it, users cannot tell the difference between a frozen script or a long running task.

By the way, don’t forget: JavaScript runs in a single thread. Use async operations for long tasks not to block the main thread.

The package we are going to use for spinners is called Ora:

$ npm install --save ora
index.js
#!/usr/bin/env node

const inquirer = require('inquirer');
const open = require('open');
const package = require('./package.json');
const commander = require('commander');
const figlet = require('figlet');
const ora = require('ora');
const devilmalUrl = 'devimalplanet.com';
const program = setupCommander();

async function run() {
  const spinner = ora({    text: 'Simulating some slow async task. What a Devimal Planet...',    spinner: 'earth'  }).start();  await simulateSlowAsyncTask(5000);  spinner.succeed('Heavy task finished!\n');
  const ranWithArgs = program.skipPrompts || program.url;
  if (!ranWithArgs) return interactiveRun();

  const url = typeof ranWithArgs === 'string' ? ranWithArgs : devilmalUrl;
  return staticRun(url);
}

async function interactiveRun() {
  console.log('Hi! 👋  Welcome devimal-cli!');

  const { openDevimal } = await inquirer.prompt({
    type: 'confirm',
    name: 'openDevimal',
    message: 'Would you like to visit devimalplanet.com?',
    default: true
  });

  const urlToVisit = openDevimal
    ? devilmalUrl
    : (
        await inquirer.prompt({
          type: 'input',
          name: 'someFunUrl',
          message: '😢  No? Which URL would you like to visit?',
          validate: function(input) {
            return (
              /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w.-]+)+[\w\-._~:/?#[\]@!$&'()*+,;=.]+$/.test(
                input
              ) || 'Please enter a valid URL.'
            );
          }
        })
      ).someFunUrl;

  await openUlr(urlToVisit);
}

async function staticRun(url) {
  console.log(await generateAsciiArt());
  await openUlr(url);
}

async function openUlr(url) {
  url = url.startsWith('http') ? url : 'https://' + url;
  await open(url);
}

function setupCommander() {
  const program = new commander.Command();

  program
    .version(package.version)
    .option(
      '-y, --skip-prompts',
      'skips questions, directly opens devimalplanet.com'
    )
    .option('-u, --url <URL>', 'skips questions, directly opens provided URL')
    .parse(process.argv);

  return program;
}

async function generateAsciiArt() {
  return new Promise((resolve, reject) => {
    // figlet docs: https://www.npmjs.com/package/figlet
    figlet.text(
      'Devimal',
      {
        font: 'ANSI Shadow',
        horizontalLayout: 'default',
        verticalLayout: 'default'
      },
      function(err, data) {
        if (err) {
          console.log('Something went wrong...');
          console.dir(err);
          reject(err);
        }
        resolve(data);
      }
    );
  });
}
async function simulateSlowAsyncTask(ms) {  return new Promise(resolve => {    setTimeout(() => resolve(), ms);  });}
run();

With the above changes, your terminal should look like this:

Terminal with spinner
Terminal with spinner

Click here to browse the full project state up to this point.

Bonus Tip #4: Add Multiple CLI Scripts

Most use cases will need only a single CLI command.

However there are times you want to split your CLI into multiple commands. You may also want to create multiple aliases for the same command.

Npm allows you to do all that with ease. Let’s add the new script file, joy.js, that generates some more ASCII art:

joy.js
#!/usr/bin/env node

const figlet = require('figlet');

function generateAsciiArt() {
  figlet.text(
    'Enjoy!',
    {
      font: 'ANSI Shadow',
      horizontalLayout: 'default',
      verticalLayout: 'default'
    },
    function(err, data) {
      if (err) {
        console.log('Something went wrong...');
        console.dir(err);
      }
      console.log(data);
    }
  );
}

generateAsciiArt();

Cool. Now we just need to modify the bin property in the package.json file:

package.json
{
    [...]
    "bin": {    "devimal-cli": "./index.js",    "devimal-joy": "./joy.js"    }    [...]
}

Tell npm to link once again:

$ npm link

And voila! The new script works:

$ devimal-joy
███████╗███╗   ██╗     ██╗ ██████╗ ██╗   ██╗██╗
██╔════╝████╗  ██║     ██║██╔═══██╗╚██╗ ██╔╝██║
█████╗  ██╔██╗ ██║     ██║██║   ██║ ╚████╔╝ ██║
██╔══╝  ██║╚██╗██║██   ██║██║   ██║  ╚██╔╝  ╚═╝
███████╗██║ ╚████║╚█████╔╝╚██████╔╝   ██║   ██╗
╚══════╝╚═╝  ╚═══╝ ╚════╝  ╚═════╝    ╚═╝   ╚═╝

Click here to browse the full project state up to this point.

Bonus Tip #5: More NPM Usage

You can remove the linked scripts:

$ npm unlink

If you publish your package, you can install the CLI globally on any machine with:

$ npm install -g <package-name>

Conclusion

CLIs often grow and become very powerful tools in your development workflow.

In this guide we have created a basic interactive CLI in NodeJS. We introduced tools that help you build the functionality of your CLI, while also providing good end user experience.

Now go hack together some CLIs!