@2fd/command 
Modular, object-oriented framework for building command line tools in Node.js, with TypeScript definitions included.
Zero dependencies.
@2fd/commandhas no runtime dependencies: installing it adds exactly one package to yournode_modules. The only dev dependency is TypeScript, used to compile the sources.
- Install
- Goals
- Quick start
- Commands
- Params
- Flags
- Executor (multi-command tools)
- Input and output
- Error handling
- Extending
- API reference
- Development
Install
npm install --save @2fd/command
Supported Node.js versions are the ones tested in CI: every major release from 0.12 to 26.
Supported TypeScript versions are the ones tested in CI: 2.0, 2.4, 2.9, 3.9,
4.9, 5.x, 6.x and the latest release. The types job installs the packed
package into compat/ and type-checks it with each version. TS 2.0
to 5.x use moduleResolution: node, and TS 5.x and later use nodenext with
strict.
Goals
-
No dependencies. Nothing else gets pulled into your tree, so there are no transitive packages to audit, update or patch.
-
Lazy loading. A command can be registered as a string that points to its module (
'./commands#migrate'). The module is onlyrequired when that command runs (or when help is printed), so large tools boot fast. -
Command names belong to the tool, not to the package. The tool that registers a command decides how it is invoked (
migration:init,mgt:i, …). Third-party packages export commands; you pick the ones you need, rename them, or replace them with your own. -
Fully modular. Commands, params, flags, input and output are small objects behind interfaces, so any piece can be shared, replaced or extended.
-
Typed. Type definitions ship with the package (
index.d.ts); notypings/@typesinstall required. They are tested with TypeScript 2.0 up to the latest release (see Install).
Quick start
// cli.js
const {
Command,
ExecutorCommand,
Param,
BooleanFlag,
ValueFlag,
ArgvInput,
ConsoleOutput,
} = require('@2fd/command');
class Greet extends Command {
constructor() {
super();
this.description = 'Greet someone';
this.params = new Param('name [...others]');
this.flags = [
new BooleanFlag('loud', ['--loud', '-l'], 'Uppercase the greeting'),
new ValueFlag('times', ['--times', '-t'], 'Repeat N times', Number, 1),
];
}
action(input, output) {
const names = [input.params.name, ...(input.params.others || [])];
let msg = 'Hello ' + names.join(', ');
if (input.flags.loud)
msg = msg.toUpperCase();
for (let i = 0; i < input.flags.times; i++)
output.log(msg);
}
}
const tool = new ExecutorCommand();
tool.version = '1.0.0';
tool.description = 'My tool';
tool.addCommand('greet', new Greet());
tool.handle(new ArgvInput(process.argv), new ConsoleOutput());
$ node cli.js greet Ana Bob -l -t 2
HELLO ANA, BOB
HELLO ANA, BOB
Commands
A command is any object that implements CommandInterface:
interface CommandInterface<F, P> {
description: string;
handle(input: InputInterface<F, P>, output: OutputInterface): void;
}
You will usually extend one of the built-in classes instead of implementing it directly.
Command (class)
Extend Command, define description, params and flags, and implement
action. handle() parses the input, validates params and flags, and then
calls action().
import {
Command,
InputInterface,
OutputInterface,
Param,
BooleanFlag,
} from '@2fd/command';
type Flags = { force: boolean };
type Params = { requireParam: string; optionalParam?: string; optionalParamList?: string[] };
export class MyCommand extends Command<Flags, Params> {
description = 'My Command description';
params = new Param('requireParam [optionalParam] [...optionalParamList]');
flags = [
new BooleanFlag('force', ['--force', '-f'], 'Force flag'),
];
action(input: InputInterface<Flags, Params>, output: OutputInterface): void {
// input.params.requireParam -> string
// input.params.optionalParam -> string | undefined
// input.params.optionalParamList -> string[] | undefined
// input.flags.force -> boolean
}
}
export const myCommand = new MyCommand();
Rules enforced by Command:
paramsis required (useNoParamsorIgnoreParamsfor commands without positional arguments).flagsmust be an array (it can be empty).- Two flags cannot share the same
nameor the same option (e.g. two-f). - Unknown options throw
Unexpected option: <flag>. - A help flag (
-h, --help) is added automatically. When present, help is printed andaction()is not called.
A standalone command can run without an executor:
// command.js
const { ArgvInput, ConsoleOutput } = require('@2fd/command');
const { myCommand } = require('./commands');
myCommand.handle(new ArgvInput(process.argv), new ConsoleOutput());
$ node command.js --help
My Command description
Usage: node command.js [OPTIONS] requireParam [optionalParam] [...optionalParamList]
[OPTIONS]:
--force, -f Force flag
-h, --help Print this help
Quick commands (functions)
A plain function (input, output) => void can be registered as a command.
Set a description property on the function to show it in the help (defaults
to -). Quick commands receive the raw input: no params/flags are parsed,
input.argv contains the remaining arguments.
function ping(input, output) {
output.log('pong', input.argv);
}
ping.description = 'Reply with pong';
exports.ping = ping;
Params
Param parses positional arguments from a definition string. Each
whitespace-separated token defines one param:
| Token | Meaning | Value in input.params |
|---|---|---|
name | required | string |
[name] | optional | string | undefined |
...name | required list (at least one value) | string[] |
[...name] | optional list | string[] | undefined |
new Param('source');
new Param('source [target]');
new Param('...files');
new Param('src1 src2 [optional] [...rest]');
Values are assigned in order. A list param consumes every remaining positional
argument. Extra arguments throw Unexpected param: <value>; a missing required
param throws Param <name> is required.
Other param handlers:
| Class | Behavior |
|---|---|
NoParams | Any positional argument throws Unexpected param. |
IgnoreParams | Positional arguments are silently ignored. |
Flags
Every flag takes (name, list, description):
name: key used ininput.flags.list: options that activate the flag, e.g.['--force', '-f'].description: text shown in the help. Required, must not be empty.
| Class | Value in input.flags[name] |
|---|---|
BooleanFlag(name, list, desc) | true if present, otherwise false. |
ValueFlag(name, list, desc, parser?, default?) | Next argument, passed through parser if given. default if absent, otherwise undefined. |
ListValueFlag(name, list, desc, parser?, default?) | Array of values, one per occurrence (--tag a --tag b). default (or []) if absent. |
RequireFlag(flag) | Wraps another flag and throws if its value is undefined, null, NaN or empty. |
HelpFlag() | Boolean help flag on -h, --help. Added automatically. |
NullFlag() | No-op flag. |
const {
BooleanFlag, ValueFlag, ListValueFlag, RequireFlag,
} = require('@2fd/command');
this.flags = [
new BooleanFlag('dryRun', ['--dry-run', '-n'], 'Do not write anything'),
new ValueFlag('port', ['--port', '-p'], 'Port to listen on', Number, 8080),
new ListValueFlag('env', ['--env', '-e'], 'Environment variable (repeatable)'),
new RequireFlag(
new ValueFlag('config', ['--config', '-c'], 'Config file path')
),
];
Parsing notes:
- Any argument starting with
-is treated as a flag. - Values are passed as a separate argument (
--port 80).--port=80and grouped short flags (-abc) are not supported. - A value cannot start with
-;--port -1throwsFlag port (--port, -p) expect a value.
Executor (multi-command tools)
ExecutorCommand routes the first positional argument to a registered command
and passes the remaining arguments to it.
const { ExecutorCommand, ArgvInput, ColorConsoleOutput } = require('@2fd/command');
const { myCommand } = require('./commands');
const tool = new ExecutorCommand();
tool.version = '1.0.0';
tool.description = 'My tool';
// Command instance
tool.addCommand('run', myCommand);
// Lazy loaded: "<module path>#<export name>"
tool.addCommand('build', './lib/commands#build');
// Lazy loaded: the module itself is the command (module.exports = ...)
tool.addCommand('clean', './lib/commands/clean');
// Several at once
tool.addCommands({
'start': './lib/commands#start',
'stop': './lib/commands#stop',
});
// Namespaced: registered as "db:migrate" and "db:seed"
tool.addCommadsNS('db', {
'migrate': './lib/commands/db#migrate',
'seed': './lib/commands/db#seed',
});
tool.handle(new ArgvInput(process.argv), new ColorConsoleOutput());
Running without a command (or with an unknown one) prints the help. Commands without a namespace are listed first, then everything is sorted alphabetically:
$ node cli.js
My tool [v1.0.0]
Usage: node cli.js [OPTIONS] [COMMAND]
[COMMAND]:
build Build the project
clean Remove build output
run My Command description
start Start the server
stop Stop the server
db:migrate Run migrations
db:seed Seed the database
[OPTIONS]:
-h, --help Print this help
Lazy loading paths
A string command has the format <module>[#<export>]:
-
<module>starting with.is resolved againstprocess.cwd(), not against the file that registers it. For tools that can run from any directory, use an absolute path:const { join } = require('path'); tool.addCommand('build', join(__dirname, 'lib/commands') + '#build'); -
Any other
<module>is resolved withrequire.resolve(package names, absolute paths):'some-package/commands#migrate'. -
Without
#<export>, the module export itself is used. -
The export can be a command object or a quick command function.
Lazy commands are loaded when they run or when the executor help is printed
(to read their description).
Executors can be nested
ExecutorCommand is itself a command, so it can be registered inside another
executor to build deeper command trees.
Input and output
ArgvInput(argv, exec?)
Wraps process.argv. It removes the Node.js executable and script path from
argv and stores them in exec, which is used to print the Usage: line.
If the script lives in a PATH directory (a globally installed bin), only the
script name is shown.
interface InputInterface<F, P> {
argv: string[]; // arguments not consumed yet
exec: string[]; // how the tool was invoked, e.g. ['node', 'cli.js']
flags: F; // parsed flags
params: P; // parsed params
}
Outputs
| Class | Behavior |
|---|---|
ConsoleOutput | Writes to console.log / console.error. %c styles are stripped. |
ColorConsoleOutput | Same, but translates %c CSS-like styles to ANSI colors. error() is red. |
Both support the util.format placeholders (%s, %d, %j) plus the browser
console placeholders %i, %f, %o, %O and %c:
output.log('%cdone%c in %dms', 'color:green;font-weight:bold', '', 120);
output.error('Cannot open %s', file);
Supported %c styles: color:<name>, background:<name> /
background-color:<name>, font-weight:bold|normal, font-style:italic,
text-decoration:underline|line-through, visibility:hidden.
Color names: black, red, green, yellow, blue, magenta, cyan,
white, gray/grey (no gray background).
Any object with log() and error() methods can be used as output (for
example, to capture output in tests).
Error handling
Parse and validation errors are thrown as regular Errors and are not
caught by the library. Wrap handle() to print a clean message and set the
exit code:
try {
tool.handle(new ArgvInput(process.argv), output);
} catch (err) {
output.error(err.message);
process.exitCode = 1;
}
Errors thrown by the library:
| Message | Cause |
|---|---|
Unexpected option: <flag> | Unknown flag. |
Unexpected param: <value> | Too many positional arguments. |
Param <name> is required | Missing required param. |
Flag <name> (<list>) expect a value | ValueFlag/ListValueFlag without value. |
Flag <name> (<list>) is required | RequireFlag not provided. |
Cannot overwrite flag ... | Duplicate flag name or option. |
<export> not found in <path> | Lazy command export does not exist. |
Extending
Custom flags and params implement small interfaces with three hooks, called in
this order by Command.handle():
before(input, output): once, before parsing. Set defaults here.parse(arg, input, output): for each matching argument. A flag can consume its value withinput.argv.shift().after(input, output): once, after parsing (skipped when--helpis present). Validate or normalize here.
interface FlagInterface<F> {
name: string;
description: string;
list: string[];
before(input, output): void;
parse(flag: string, input, output): void;
after(input, output): void;
}
interface ParamInterface<P> {
definition: string; // shown in the "Usage:" line
before(input, output): void;
parse(param: string, input, output): void;
after(input, output): void;
}
Example: a counter flag (-v -v -v → 3).
const { FlagConstructor } = require('@2fd/command');
class CountFlag extends FlagConstructor {
before(input) { input.flags[this.name] = 0; }
parse(flag, input) { input.flags[this.name]++; }
after() { }
}
new CountFlag('verbose', ['--verbose', '-v'], 'Increase verbosity');
To customize the help, override helpDescription(), helpUsage(executable) or
helpOptions() in your command, or replace the help flag by setting
helpFlag before the first handle() call.
API reference
Everything below is exported from @2fd/command.
| Export | Kind | Description |
|---|---|---|
Command | class | Base class for commands with params and flags. |
ExecutorCommand | class | Routes sub-commands; supports lazy loading. |
Param | class | Positional params from a definition string. |
NoParams | class | Rejects positional params. |
IgnoreParams | class | Ignores positional params. |
FlagConstructor | class | Base class that validates name, list, description. |
BooleanFlag | class | true/false flag. |
ValueFlag | class | Flag with a single value. |
ListValueFlag | class | Repeatable flag with a list of values. |
RequireFlag | class | Makes a wrapped flag required. |
HelpFlag | class | -h, --help. |
NullFlag | class | No-op flag. |
ArgvInput | class | Input from process.argv. |
ConsoleOutput | class | Plain console output. |
ColorConsoleOutput | class | Console output with ANSI colors. |
CommandInterface | interface | Contract for any command. |
InputInterface | interface | Contract for input objects. |
OutputInterface | interface | Contract for output objects. |
Development
npm install
npm run compile # tsc -p .
npm run declaration # generate .d.ts files
npm test # mocha (run compile first)