Conquering Commander.js

Writing large command line tools in Node

Tim Santeford

Sr. Software Engineer at AppNexus

Source Code:

Overview

  • why write cli tools in Node.js?
  • what is Commander.js
  • how and why to use it
  • project organization
  • adding missing pieces
  • quick demo

Why Building CLI Tools Rocks

  • testing and learning APIs
  • provide missing functionally while UI is in development
  • automation and ETL
  • tools for less technical co-workers
  • curl is not practical for all users

Plus it's fun!

  • great libraries available via npm
  • asynchronous io
  • easy installation and updates for users
  • Beats shell scripts

Why write cli tools in Node.js?

It's Javascript! I know this...

brew install node
npm install <your-cli> -g

DONE!

What is Commander.js

  • Command-line argument parser
  • commands and options with values
  • used by 1000 other npm modules
  • Actively maintained

5th most depended on npm module after underscore, async, request, and lodash

Why use Commander.js?

  • inspired by the Ruby gem Commander
  • command routing
  • clean syntax
  • auto generated help

Alternatives

The Program Object

 var program = require('commander');

 // define program and options

 ...

 // define commands and their options

 ...

 // kick off the parser 
 program.parse(process.argv);

Defining Commands

  • name with [optional] or <required> argument
  • description for help
  • action callback
program
  .command('hello <name>')
  .description('Say hello to <name>')
  .action(function(name, command) {
    console.log('Hello ' + name);
  });

[ ] < >

Defining Options

  • options can be command specific
  • optional or required values
  • can take a coercion function
  • accessed on the program object

example up next

--option

--option [value]

--option <value>

program
  .command('countdown <count>')
  .description('Countdown timer')
  .option(
    '-i, --interval <interval>',
    'The delay between ticks',
    parseInterval, // coercion function
    1000 // default value
  ).action(function(count, command) {
    // option values are placed on 
    // the command object
    command.interval 
    
    ...

  });

Command & Option Example

cli countdown 10 --interval 1000

  1. defined in package.json under "bin"
  2. command defined on the program object
  3. command argument <required>
  4. command option
  5. option value <required>

Quick Demo

--help

  Usage: cli <command> [options]

  Commands:

    countdown [options] <count>
       Count down timer

  Options:

    -h, --help     output usage information
    -V, --version  output the version number

What Commander.js does not come with:

  • a good way to organize large projects
  • user prompting
  • progress indication
  • debugging
  • logging
  • testing

Folder Structure

├── bin
│   └── cli
├── commands
│   ├── command-a.js
│   ├── command-b.js
│   └── index.js
├── lib
│   └── ...
├── node_modules
│   └── ...
├── test
│   └── ...
├── index.js
└── package.json
  1. application entry point
  2. shell script used by npm
  3. commands folder
  4. command loader
  5. shared library folder

5

2

3

1

4

bin\cli

the cli will be executed with the name specified in the package.json file.

"bin": {
    "<cli-name>": "./bin/<cli-name>"
}

package.json

bin/<cli-name>

#!/usr/bin/env node

require('../index');

use npm link while developing

Command Loader

module.exports = function commandLoader(program) {
  var commands = {};
  var loadPath = path.dirname(__filename);

  // Loop though command files
  fs.readdirSync(loadPath).filter(function (filename) {
    return (/\.js$/.test(filename) && filename !== 'index.js');
  }).forEach(function (filename) {
    var name = filename.substr(0, filename.lastIndexOf('.'));

    // Require command
    var command = require(path.join(loadPath, filename));

    // Initialize command
    commands[name] = command(program);
  });

  return commands;
};

User Input with Prompt

  • use prompt
  • initialize in index.js
  • place on the program object

example up next

supports password masking

program.prompt.get({
  properties: {
    port: {
      description: 'Enter port number:',
      conform: validatePort, // function
      pattern: /^\d+$/
    }
 }
}, function (err, result) {
   
  // Input values placed on the result object
  result.port

  ...

});

Prompt example

Success & Error Messaging

  • create common methods
  • constant looking output
  • logging errors is easier
  • debugging is easier too (--debug)
  • use color
  • log to a file
 program.log = function () {
 program.successMessage = function () {
 program.errorMessage = function () {
 program.handleError = function () {

Text

Use a common Request object

Wrap request for debugging and logging.

  program.request = function (opts, next) {
    // Start loading indicator
    if (program.debug) { // log request options
    program.log(opts.uri);
    return request(opts, function (err, res, body) {
      // Stop loading indicator
      if (err) {
        if (program.debug) {
          program.errorMessage(err.message);
        }
        return next(err, res, body);
      } else {
        if (program.debug) { // log response & body
        return next(err, res, body);
      }
    });
  }

Final Thoughts

  • use progress for long running tasks
  • use configuration files
  • detect terminal type: process.stdout.isTTY
  • use cli-table to format tabular data
  • unit test every command
  • mocking the program object makes unit testing easy

Demo

Summary

  • Use Commander.js
  • Organize / Unit Test
  • Use common logging, message / error handling, and request objects for each task

Questions?

Source Code:

Conquering Commander.js

By Tim Santeford

Conquering Commander.js

Writing large command line tools in Node

  • 14,257