Production Ready Node: Command Line Tooling
ommand Line tools are an important component to development, administration and maintenance of most all software projects - large or small. A good command line tool greatly speeds up repetitive operations through simple and flexible abstractions.
The Node.js community has a number of tools out there for quickly building out a command line interfaces, commander being one of the more popular. However, most all of them suffer from the same set of problems, making them difficult to integrate into applications in a clean an re-usable manner. Primarily speaking, there is no way to separate general functionality, from a command, from the argument parsing, or a command from the command line tool itself. This makes building for code re-use exceptionally difficult.
Separation of Concerns
When building a command line interface for your applications, you want to keep commands separate from the primary tool, and general functionality separate from each of the commands. As a rule of thumb, your commands should not do anything that can not be done from within the application itself. Ideally, you want the command line tool to be a small, simple program that knows how to find, register and run your defined commands. To accomplish this, we are going to being using the Seeli framework.
npm install --save seeli
Primary Tool
We can use npm to define the name of our command line tool, or binary script through the bin
property in the package.json
of our project:
{
"version":"0.0.0",
"name":"project",
"bin":{
"fancyname":"./bin/cli.js"
}
}
When our package is installed globally ( npm install -g
), or dynamically linked ( npm link
), this will add our program cli.js
to our systems execution path as fancyname
. You can call it whatever you like however.
Next, we just need to define command line tool. All our tool will need to do is load and register commands. Following the project structure we have previously established, we can assume all commands will be located in a commands
folder of packages under our primary internal package directory.
// project/bin/cli.js
var cli = require( 'seeli' )
, child_process = require( 'child_process' )
, fs = require('fs')
, path = require('path')
, clone = require('mout/lang/clone')
, packagepath = path.normalize( path.resolve(__dirname,'..','packages') )
, jsregex = /\.js$/
, files
;
debug('current dir', __dirname);
debug('package path: %s', packagepath);
fs
.readdirSync( packagepath )
.forEach( function( file ){
var searchpath = path.join( packagepath, file, 'commands' )
if( fs.existsSync( searchpath ) && fs.statSync( searchpath ).isDirectory() ){
fs.readdirSync(searchpath).forEach( function(module){
if( jsregex.test( module ) ){
var requirepath = path.join( searchpath, module )
var cmd = require(requirepath)
// name will be the name defined on the command
// OR the name of the file if not specified
var name = ((cmd.options.name ? cmd.options.name : module)).replace(jsregex,'').toLowerCase().replace('-', '_');
try{
// Register the command by name
cli.use( name, cmd)
} catch( e ){
console.error('unable to register module %s', module )
}
}
} );
}
});
cli.run()
Believe it or not, this is the entirety of our command like tool. Here is what it is doing
- Find all
directories
in the internal packages directory - Locate javascript files in the
commands
directory of each package - calls
require
on the normalized path - Register the module ( assumed to be a command ) with Seeli
- Runs Seeli
Simple.
Commands
Commands are simply javascript file that export a command instance as the module. To illustrate this, we can start with the obligatory hello world
command. This command will be called hello
and it will simply say hello to a name, which will be passed via a name
flag.
.
|-- project
| |-- packages
| | |-- project-foo
| | | |-- package.json
| | | |--commands
| | | `-- hello.js
| | `-- README.md
| `-- README.md
`-- README.md
// project/packages/project-foo/commands/hello.js
var cli = require( 'seeli' );
var Hello;
Hello = new cli.Command({
description:"Says hello"
, usage:[
"hello --name=Bob",
"hello -n Bob"
]
, flags:{
name:{
type:String
, shorthand:'n'
, required:true
}
}
,run: function( directive, data, done ){
done(null/* error */, 'hello, ' + data.name /* data*/ )
}
});
module.exports = Hello;
It is pretty easy to understand what this command does. Everything we've defined is effectively optional, with the exception of the run
function. The run
function what the command actually does and needs to call the done
function if and when it is done. The done
is a node style callback that accepts an error, if there is one, and an optional string which will be written to stdout.
$ fancyname hello --name=Bob
$ hello, bob
$fancyname hello -n Sam
$ hello, Sam
Functionality
We can take this one step further and create a command that is a little more involved. We'll make a command that displays all of the version information of our internal packages. Keeping our rule of thumb about commands, we'll encapsulate the primary functionality of the command in a separate module.
// lib/loading/base.js
// locates and loads files based on a pattern.
function Loader( options ){
options = options || {};
// cut
}
// returns an object of file paths that we are looking for
Loader.prototype.find = function find(){
var searchpath = path.join( PROJECT_PATH, 'packages', searchpath' );
// cut
return {pkgnameA:[filepath, filepath], pkgnameB:[filepath, filepath]};
};
// returns the js modules denoted by files paths from find()
Loader.prototype.load = function load(){
var packages = this.find.apply(this, arguments)
// cut
return {pkgnameA:[pkg,pkg], pkgnameB:[pkg,pkg]};
};
The implementation of the loader is not important here, but our little loader class has two functions, find
and load
. Find
, as the name would imply locates a kind of file in our packages returning absolute paths to the files. In this situation it is looking for packages.json
files. The load
method, calls the find
method and runs require
over the results so we end up with an array containing each of the package manifest modules that we have found. We can use the information in this to find version information for use in our command.
Our module does most of the work, so our command really just needs to format the data, and print it out.
// packages/project-foo/commands/version
var cli = require( 'seeli' );
var loader = new require('project-foo/lib/loading/base');
var util = require( 'util' );
module.exports = new cli.Command({
description:"Display package version information"
,usage:[
"fancyname version"
]
,run: function( directive, data, done ){
var pkgs = loader.load();
var out = [];
var current;
for( var pkg in pkgs ){
current = pkgs[pkg];
current.forEach(function( p ){
out.push(
util.format(
"%s@%s"
, current.name
, current.version
)
);
})
}
done(null, out.join('\n') );
}
})
That is now we can do fancyname version
and get version information for all of the private packages. Our tool is independant of the actual commands, and our commands are loosely coupled to the functionality they expose.
While these commands are simple, you could image commands that generate documentation or perform some kind of database initialization, etc. Moreover, because of our commands as modules set up, if we have to, we could directly require and invoke the commands programmatic-ally, making them multi-purpose and highly testible.
var version = require('project-foo/commands/version')
version.run(null, console.log) // print results
version.run(null, someParseFn) // does some magic with results
// set flags progrommatically
version.setOptions({
args:['--someflag=1']
});
version.run(null, console.log);
Lastly, because seeli commands don't alter argv
, if you have your configuration system set up like we have talked about, you can pass additional flags through the command line tool to the configuration handlers to alter application set up and behavior.
Simple. Flexible. Powerful.