How to Build a Command Line (CLI) Tool in Node.js

by Tapasweni Pathak

8 min read·

Command Line Tool enables the user to complete required tasks directly from the terminal. User-friendly commands are interpreted as system calls internally and commands are executed. Node.js provides packages that provide another layer of abstraction and encapsulation for creating these command-line tools (Node-cmd tools). Let’s create our first command-line tool in Node.js (Nodejs CLI) for manipulating and triggering API calls of AWS ECS Cluster.

Introduction to Task

AWS Elastic Container Service (AWS ECS) is a collection of AWS EC2s. In simple terms, Linux machines run similar configurations, and in turn, create a network to develop a networking system. AWS ECS provides an API layer to directly manipulate and work with the AWS EC2s i.e., Linux machines. For example, using AWS ECS APIs one can get a particular EC2 configuration, available memory space, idle time of the network e.t.c.

Setting up Project

To learn how to make a CLI, Node.js developers should start with creating a simple Node.js project. After npm init provides answers to questions asked. This is general information about the new npm build command package that you are creating.

mkdir nodejs-cli-tool
cd nodejs-cli-tool
mkdir ecs-cluster-control
cd ecs-cluster-control

npm init

> ecs-cluster-control$ npm init
> This utility will walk you through creating a package.json file.
It only covers the most common items, and tries to guess sensible defaults.

> See `npm help json` for definitive documentation on these fields
and exactly what they do.

> Use `npm install <pkg>` afterwards to install a package and
save it as a dependency in the package.json file.

> Press ^C at any time to quit.
> package name: (ecs-cluster-control) 
> version: (1.0.0) 
> description: how to build a command line tool in nodejs
> entry point: (index.js) 
> test command: 
> git repository: 
> keywords: 
> author: 
> license: (ISC) 
> About to write to /home/username/nodejs-cli-tool/ecs-cluster-control/package.json:

{
  "name": "ecs-cluster-control",
  "version": "1.0.0",
  "description": "how to build a command line tool in nodejs",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}


Is this OK? (yes) 


You can also set all the answers to defaults using npm init -y.

Node.js Packages as Dependencies

Let’s install the following packages:

easy-tableNice utility for rendering text tables with javascript.
eslintESLint is a tool for identifying and reporting on patterns found in ECMAScript/JavaScript code. In many ways, it is similar to JSLint and JSHint with a few exceptions:
eslint-plugin-nodeAdditional ESLint's rules for Node.js
meowCLI app helper
simple-statisticsA JavaScript implementation of descriptive, regression, and inference statistics.
sinonStandalone and test framework agnostic JavaScript test spies, stubs, and mocks (pronounced "sigh-non", named after Sinon, the warrior).
tap-specFormatted TAP output like Mocha's spec reporter
tapetap-producing test harness for node and browsers
@mapbox/mock-aws-sdk-jsA library that provides sinon-style stubs for aws-sdk-js service methods for use in testing.

npm i easy-table eslint eslint-plugin-node meow simple-statistics sinon tap-spec tape @mapbox/mock-aws-sdk-js

Initializing Node Command Line Tool Creation

Let’s create four main directories to provide a modular structure for our CLI tool. 


bin      → cli.js
commands → ecs-cluster-control.js
lib      → terminate-instance.js
         → pending-tasks.js
         → persist-disk-metrics.js
         → task-churn.js
test     → ecs-cluster-control.test.js
         → pending-tasks.test.js
         → task-churn.test.js

bin/cli.js

In this file, we will instantiate meow.js and provide the following:

  • usage command for the cli tool
  • commands 
  • options
  • description
  • aliases i.e., short forms for our long commands
  • include the file describing our commands
  • error messages for the command line tool

The cli.js file will look something like this:

#!/usr/bin/env node

/* eslint-disable no-console */

var meow = require('meow');

var cli = meow({
  help: `
    USAGE: ecs-cluster-control <command> [OPTIONS]
    Commands:
      get-capacity          print spot fleet capacity information
      set-capacity          adjust the spot fleet's target capacity
      terminate-instance    gracefully shut down a single EC2
      teardown              gracefully delete an ecs-cluster stack
      scaling-activity      print recent spot fleet scaling activity
      fleet-events          print spot fleet event history
      instance-balance      print a table of instance type / region distribution
      docker-disk-metrics   open CloudWatch metrics for docker disk utilization
      persist-disk-metrics  open CloudWatch metrics for persistent disk utilization
      pending-metrics       open CloudWatch metrics for pending tasks per instance
      metric-offenders      find cluster instance max from the last 10 min for any System/Linux metric
      task-churn            print min/avg/max durations for tasks stopped in the last hour
      disable-scaledown     disables the scale-down schedule rule and error alarm actions
      enable-scaledown      enables the scale-down schedule rule and error alarm actions
      worker-capacity       calculates how many service tasks can be placed on cluster at current capacity
    Options:
      -h,  --help            show this help message
      -r,  --region          the region the cluster is in
      -cl, --cluster-name    the cluster name, e.g. production
      -c,  --capacity        the desired spot fleet target capacity
      -i,  --instance-id     an EC2 instance id
      -a,  --app-name        the application's service name on the cluster
      -m,  --me              your slack handle, e.g. '@rclark'
      --metric-name          the MetricName for a System/Linux metric
  `,
  description: 'Helper utilities for interacting with AWS ECS clusters'
}, {
  alias: {
    r: 'region',
    cl: 'cluster-name',
    c: 'capacity',
    i: 'instance-id',
    a: 'app-name',
    m: 'me'
  },
  string: ['region', 'cluster-name', 'instance-id', 'me'],
  number: ['capacity']
});

var command = cli.input[0];
var fn;
try { fn = require(`../lib/${command}`); }
catch(err) {
  console.error(err.message);
  cli.showHelp(1);
}

const preamble = cli.flags.region && cli.flags.clusterName ?
  require('../lib/cluster-info.js') : () => Promise.resolve();

preamble(cli.flags)
  .then((info) => fn(cli.flags, info))
  .catch((err) => {
    console.error(`ERROR: ${err.message}`);
    process.exit(1);
  });

commands/ecs-cluster-control.js

This will be our core file to provide an abstraction layer over every command code file inclusion. 
The ECS-cluster-control.js will look something like this:

'use strict';
const fs = require('fs');
const path = require('path');

/**
 * Description of ecs-cluster-control commands to be used in registration with mbxcli.
 */
module.exports.configuration = {
  description: 'Helper utilities for interacting with AWS ECS clusters',
  help: `
    USAGE: source ecs-cluster-control <command> [OPTIONS]
    command:
      task-churn                  print min/avg/max durations for tasks stopped in the last hour
      terminate-instance          gracefully terminates the specified instance
      pending-tasks               giveb a cluster name and region print currently pending tasks.
    Options:
      -a,  --account              [default] a comma-delimited list of accounts
                                  to create the stack in
      -r,  --region               [us-east-1] a comma-delimited list of regions
                                  to create the stack in
      -h,  --help                 shows this help message
  `
};


/**
 * Function called when command is run through source cli tool.
 * @param  {object} cli     parsed command-line arguments
 * @param  {object} context information about the context in which the command was requested
 */
module.exports.run = (cli, context) => {
  const command = cli.input[0];
  const files = fs.readdirSync(path.resolve(__dirname, '..', 'lib'));
  const commands = new Set(files.map((filename) => filename.replace('.js', '')));

  //check if command corresponds with a file in ./lib
  const valid = commands.has(command);

  if(valid){
    const run = require(`../lib/${command}.js`).run;
    return run(cli, context);
  } else {
    return Promise.resolve()
      .then(() => { console.log(`Command ${cli.input[0]} not valid`); });
  }
};

lib/command-name.js

This will contain every command the file system calls triggers. Let’s take as an example terminating an AWS ECS cluster instance, i.e., calling a Terminate Your Instance AWS ECS API with an ID.

Our command file terminate-instance.js will look something like this:

'use strict';

/**
 * Given a cluster name, region and instance id, terminates that instance.
 *
 * @param {String} A region, a cluster name and instance id.
 *
 * @returns {Object} Terminating the instance.
 */

module.exports.run = (cli, context) => {
  const instanceID = cli.input[1];
  const region = cli.flags.regions[0] || 'us-east-1';
  const accounts = cli.flags.accounts[0] || 'default';
  if(!instanceID)
    return Promise.reject(new Error('Please provide an instance ID'));
  console.log(`Terminating AutoScaling Instance ${cli.input[1]}`);

  return Promise.resolve()
    .then(() => {
      const params = {
        InstanceId: instanceID,
        ShouldDecrementDesiredCapacity: false
      };
      context.access.getClient('AutoScaling', accounts, { region: region })
        .then((autoscaling) => new Promise((resolve, reject) => {
          autoscaling.terminateInstanceInAutoScalingGroup(params, function(err, data) {
            if(err) return reject(err);
            else resolve(data);
          });
        }));
    });
};

This completes our first command line tool using Node.js.

Usage

ecs-cluster-control terminate-instance <id>

Sample output:

Terminating AutoScaling Instance instance-id

Tests

Now let’s write some tests. 


test/file-name.js: will contain tests per file. 

  1. Instantiate, mock-aws-api, tape and sinon.
  2. Replicate ideal instance object values and assign dummy values.
  3. Call mocked AWS API, this will not trigger the AWS API but replication present in your node_modules/.
  4. Assert true and false based on the values that you should get and what you are getting. 

Full code files can be found here. Let me know what you create!

Final Word

Did we just simplify your most difficult and time-consuming problem of interacting with AWS APIs? Let us know!

If you want to broaden your knowledge of Node.js, check out these articles on Sails js tutorial, Node js architecture, Node js discord bot, containerizing Node.js apps with docker, and Node js queue.


We're hiring!

If you are passionate about Node.js, come and join us at Adeva! We are always looking for new talent to join our network.