Commanding the CLI with Node

08/31/2015

Prior to Node v0.12, writing a command line tool in Node was a rather difficult proposition. There was no way to execute a command synchronously, so tools like ShellJS worked around this by blocking the event loop until a 'done' file was created on the filesystem signifying that the command was complete. Thankfully, Node v0.12 introduces the long-awaited child_process.execSync()method. Behind the scenes it uses its own event loop for each launched process and watches for the SIGCHLD signal to indicate that the process is done, but all of this is smoothly handled for you so all you need to care about is that Node can finally execute commands synchronously.

Help Wanted

When we started working on our new front end architecture at Craftsy, we had some pretty ambitious goals. We wanted every component to be independently versioned so that we could update them in one place while keeping previous versions in place on other sections of the site. We wanted to have the ability to A/B test different versions of components. We wanted to publish everything to our private internal npm registry. With all of this came several maintenance headaches.

To ease the pain I developed Mr. Fluffs, a command line tool written in Node themed after our beloved Craftsy mascot who shows up on 404 and 500 pages. It was time to finally put that cat to work! Mr. Fluffs would help us manage our many npm packages, increment their version numbers, publish them to our registry, create new packages from templates, and much more.

Engage Launch Sequence

To aid me in this task I used the excellent liftoff library, written by Grunt co-maintainer Tyler Kellen. Liftoff helps you manage the details of launching your code from the command line, including argument handling and config file discovery. It's currently used by Gulp, and tools like Grunt and jscs are transitioning to it.

For Mr. Fluffs I wanted a tool that would work anywhere on the filesystem, reading config from the user's home directory. First off, I created the liftoff instance itself:

let cli = new Liftoff({
    name: 'flf',
    processTitle: 'flf',
});

cli.launch({}, handleArguments);

I called the tool 'flf' to make it easy for our developers to type in a hurry. There are a number of options that you can pass into cli.launch(), but we're not really using any at the moment.

The meat of the cli launcher is in the handleArguments() function:

/**
 * The entry point for the 'flf' command

 * @param  {Object} env - the liftoff environment object
 */
function handleArguments(env) {
    if (options.version) {
        log.info('Mr. Fluffs version ' + pkg.version);
        process.exit(0);
    }

    if (options.help && options._ === []) {
        commands.usage(env);
        process.exit(0);
    }

    // Suppress NPM warnings
    process.env.NPM_CONFIG_LOGLEVEL = 'error';

    if (Object.keys(commands).indexOf(options._[0]) === -1) {
        commands.usage(options, env);
        process.exit(1);
    } else {
        commands[options._[0]](options, env);
    }

    process.exit(0);
}

I handle a few special options, like 'version' and 'help', and then look for the given command to hand off control to. The tool is meant to be invoked as flf <command> <options>, similar to git's structure. The commands are indexed in acommands.js module:

/*
 * An index of commands, used in the cli tool and in testing.
 */

import {cli as depends} from './depends';
import {cli as edit} from './edit';
import {cli as find} from './find';
import {cli as link} from './link';
import {cli as meow} from './meow';
import {cli as publish} from './publish';
import {cli as test} from './test';
import {cli as unlink} from './unlink';
import {cli as usage} from './usage';

export default {
    depends,
    edit,
    find,
    link,
    meow,
    publish,
    test,
    unlink,
    usage,
};

If the command is found on this 'commands' object, then the command is called with the command line options and the liftoff environment object.

Getting Argumentative

Rather than using the built-in argument parsing from liftoff, I decided to use substack's fantastic minimist library. To do this, I get the 'options' object in the code from a getOptions function:

import minimist from 'minimist';
import rc from 'rc';

/**
 * Get the options using minimist and rc.
 *
 * @param {Object} [argv] - an argv object to use
 * @return {Object} - the current options for the tool
 */
function getOptions(argv) {
    if (!argv) {
        argv = process.argv.slice(2);
    }

    // Pass some options to minimist before handing it off to rc
    let args = minimist(argv, {
        boolean: ['verbose', 'all', 'help'],
        alias: {
            verbose: 'v',
            help: 'h',
        },
    });

    // Return the options (pulling in settings from the ~/.flfrc file as well)
    return rc('flf', {}, args);
}

export default getOptions;

This way, I'm able to set some fine-grained options on minimist, and use the rc library to pull in settings from the config file saved in the user's home directory.

Handling a Command

To keep things clean and decoupled, I wrote each command as a collection of functions that have no knowledge of the cli. This makes it easy to use programmatically inside other Node modules, and it allows for much easier testing. The module for each command exports a 'cli' function to translate the command line options into a sequence of function calls:

/**
 * The entry point for the command.
 *
 * @param {Object} options - the current options object from the cli tool
 */
function cli(options) {
    if (options.help) {
        usage();
        process.exit(0);
    }

    // Check for the package name argument
    if (options._.length < 2) {
        log.error(
            'Please specify a package name to publish.'
        );
        process.exit(1);
    }

    let index = getPackageIndex(untildify(options.components));
    let pkgName = options._[1];

    // Uninstall it so that the symlinks are removed
    npmUninstall(pkgName, index, untildify(options.app));

    // Remove the -dev suffix from the version number
    updatePackageJson(`${index[pkgName]}/package.json`, [removeVersionSuffix]);

    // Workaround for npm3 - remove node_modules first
    removeNodeModules(pkgName, index);

    // Publish the finished package
    npmPublish(pkgName, index);

    // Reinstall it so that dependents use the finished package
    npmInstall(pkgName, index, untildify(options.app));
}

Most of the code is simply driving the execution by calling functions with the correct arguments pulled from the command line options. There's also some code to handle contextual help, and to make sure that a required argument to the command was provided. The usage function simply looks like this:

/**
 * Print usage information.
 */
function usage() {
    log.info(
`Usage: flf publish <package name>

Finish editing the specified package and publish it to the package index. This
command will unlink the package, remove the "-dev" suffix from the package.json
version, and publish the package.

Arguments:
    <package name>    the name of the package to publish`
    );
}

Speed Bump

One problem I initially experienced was that the execution was really slow at first. It would take more than 3 seconds to print out the usage details. I realized that my mistake was using Babel's on-the-fly transpilation, so I compiled my ES6 code ahead of time into a 'dist' directory. I pointed the 'bin' attribute of my package.json file to the transpiled version of the tool:

"bin": {
    "flf": "./dist/bin/flf.js"
},

With that change, the command began responding in the 300ms-500ms range, which was much more usable!

Mission Accomplished

Much of my background is in the world of Python, which has an excellent set of tools for writing command line applications. For a long time I avoided writing any cli tools in Node because it lacked a lot of that support. With the coming of execSync and libraries like liftoff, I'm actually beginning to enjoy command line scripting in Node quite a bit. The performance is really fantastic!

Comments (0)

The comments to this entry are closed.