A declarative wrapper around yargs for building beautiful, fluent command line interfaces
$ black-pearl hoist the colors --black-flag
Black Flag is a fairly thin library that wraps yargs, extending its capabilities with several powerful declarative features. It can be used to create simple single-level CLIs or deeply nested sprawling interfaces alike.
Black Flag tries to be a drop-in replacement for vanilla yargs, specifically for
users of yargs::commandDir()
.
Tested on Ubuntu and Windows.
If you find yourself a fan of Black Flag's more declarative DX, check out Black Flag Extensions (BFE). BFE also protects you from a couple yargs footguns that Black Flag by itself cannot.
- Install
- Features
- Declaratively Build Deep Command Hierarchies ✨
- Built-In Support for Dynamic Options ✨
- It's Yargs All the Way down ✨
- Run Your Tool Safely and Consistently ✨
- Convention over Configuration ✨
- Simple Comprehensive Error Handling and Reporting ✨
- A Pleasant Testing Experience ✨
- Built-In
debug
Integration for Runtime Insights ✨ - Extensive Intellisense Support ✨
- Usage
- Appendix 🏴
- Contributing and Support
npm install @black-flag/core
And if you're ready to go all in on Black Flag's declarative API, check out Black Flag Extensions:
npm install @black-flag/extensions
Not yet familiar with yargs? Check out their intro documentation before continuing.
Black Flag provides first-class support for authoring simple one-off executables and sprawling deeply nested tree-like structures of commands and child commands alike.
No more pleading with yargs::commandDir()
to behave. Less wrestling with
positional parameters. Less tap-dancing around footguns. And no more dealing
with help text that unexpectedly changes depending on the OS or the presence of
aliases.
myctl --version
myctl init --lang 'node' --version=21.1
myctl remote add origin [email protected]
myctl remote add --help
myctl remote remove upstream
myctl remote show
myctl remote --help
Your hierarchy of commands is declared via the filesystem. Each command's configuration file is discovered and loaded automatically (so-called auto-discovery).
By default, commands assume the name of their file or, for index files, their
parent directory; the root command assumes the name of the project taken from
the nearest package.json
file.
my-cli-project
├── cli.ts
├── commands
│ ├── index.ts
│ ├── init.ts
│ └── remote
│ ├── add.ts
│ ├── index.ts
│ ├── remove.ts
│ └── show.ts
├── test.ts
└── package.json
That's it. Easy peasy.
Dynamic options are options whose builder
configuration relies on the
resolved value of other options. Vanilla yargs does not support these, but Black
Flag does:
# These two lines are identical
myctl init --lang 'node'
myctl init --lang 'node' --version=21.1
# And these three lines are identical
myctl init
myctl init --lang 'python'
myctl init --lang 'python' --version=3.8
Note how the default value of --version
changes depending on the value of
--lang
. Further note that myctl init
is configured to select the pythonic
defaults when called without any arguments.
At the end of the day, you're still working with yargs instances, so there's no unfamiliar interface to wrestle with and no brand new things to learn. All of yargs's killer features still work, the yargs documentation still applies, and Black Flag, as a wrapper around yargs, is widely compatible with the existing yargs ecosystem.
For example, Black Flag helps you validate those dynamic options using the same yargs API you already know and love:
// File: my-cli-project/commands/init.ts
// "argv" is a new third argument for builders vvv
export function builder(yargs, helpOrVersionSet, argv) {
// ^^^
// Tell yargs to leave strings that look like numbers as strings
yargs.parserConfiguration({ 'parse-numbers': false });
// This first conditional branch will be used to validate any dynamic
// arguments and trigger the command's handler if validation succeeds
//
// vvv
if (argv) {
// ^^^
if (argv.lang === 'node') {
return {
lang: { choices: ['node'] },
version: { choices: ['19.8', '20.9', '21.1'] }
};
} else {
// Note how we can return a literal options object instead of calling
// yargs.options(...), but we still can if we want to:
return yargs.options({
lang: { choices: ['python'] },
version: {
choices: ['3.10', '3.11', '3.12']
}
});
}
}
// This else branch will be used for generic help text and first-pass parsing
else {
// This next line is the best you'd be able to do when using vanilla yargs.
// But with Black Flag, it's only the fallback :)
return {
lang: { choices: ['node', 'python'] },
version: { string: true }
};
}
}
export function handler(argv) {
console.log(`> initializing new ${argv.lang}@${argv.version} project...`);
// ...
}
See the demo repo for the complete implementation of this command.
myctl init --lang 'node' --version=21.1
> initializing new [email protected] project...
myctl init --lang 'python' --version=21.1
Usage: myctl init
Options:
--help Show help text [boolean]
--lang [choices: "python"]
--version [choices: "3.10", "3.11", "3.12"]
Invalid values:
Argument: version, Given: "21.1", Choices: "3.10", "3.11", "3.12"
myctl init --lang fake
Usage: myctl init
Options:
--help Show help text [boolean]
--lang [choices: "node", "python"]
--version [string]
Invalid values:
Argument: lang, Given: "fake", Choices: "node", "python"
myctl init --help
Usage: myctl init
Options:
--help Show help text [boolean]
--lang [choices: "node", "python"]
--version [string]
If builder
and handler
sound familiar, it's because the exports from your
command files are essentially the same as those expected by the yargs::command
function: aliases
, builder
, command
, deprecated
,
description
, handler
, and two new ones: name
and
usage
.
The complete my-cli-project/commands/init.ts
file could look like this:
// File: my-cli-project/commands/init.ts
import type { Configuration, $executionContext } from '@black-flag/core';
// Types are also available vvv
const configuration: Configuration = {
// ^^^
// ALL OF THESE ARE OPTIONAL! Black Flag would still accept this file even if
// if were completely blank
// An array of yargs aliases for this command. DO NOT include positional
// arguments here, those go in `command` just like with vanilla yargs
aliases: [],
// Can be a yargs options object or a builder function like below
builder(yargs, helpOrVersionSet, argv) {
// We are never forced to return anything...
// return yargs;
// ... but we can if we want:
return yargs.boolean('verbose');
// We can also just return an options object too:
return {
verbose: {
boolean: true,
description: '...'
}
};
// Also note you can access ExecutionContext with argv?.[$executionContext]
},
// Always a string. All commands must begin with "$0". Defaults to "$0". The
// given value is also used to replace "$000" during string interpolation for
// the usage option
command: '$0 [positional-arg-1] [positional-arg-2]',
// If true, this command will be considered deprecated. Defaults to false
deprecated: false,
// Used as the command's description in its parent command's help text, and
// when replacing "$1" during string interpolation for the usage option. Set
// to false to disable the description and hide the command. Defaults to ""
description: 'initializes stuff',
// This function is called when the arguments match and pass yargs
// validation. Defaults to a function that throws CommandNotImplementedError
handler(argv) {
console.log(`> initializing new ${argv.lang} project...`);
// Note that you can access ExecutionContext with argv[$executionContext]
},
// Used as the command's name in help text, when parsing arguments, when
// replacing "$0" during string interpolation, and elsewhere. Usually defaults
// to a trimmed version of the file/directory name
name: 'init',
// Used as the command's usage instructions in its own help text. "$000", if
// present, will be replaced by the value of the command option. Afterwards,
// "$1" and then "$0", if present, will be replaced by the description and
// name options. Defaults to "Usage: $000\n\n$1". Will be trimmed before being
// output
usage: 'This is neat.'
};
export default configuration;
Black Flag not only helps you declaratively build your CLI tool, but run it too.
#!/usr/bin/env node
// File: my-cli-project/cli.ts
import { runProgram } from '@black-flag/core';
// Just point Black Flag at the directory containing your command files
export default runProgram(import.meta.resolve('./commands'));
# This would work thanks to that shebang (#!)
./cli.js remote show origin
# This works after transpiling our .ts files to .js with babel...
node ./cli.js -- remote show origin
# ... and then publishing it and running: npm i -g @black-flag/demo
myctl remote show origin
# Or, if we were using a runtime that supported TypeScript natively
deno ./cli.ts -- remote show origin
The runProgram
function bootstraps your CLI whenever you need it, e.g.
when testing, in production, when importing your CLI as a dependency, etc.
runProgram
never throws, and never callsprocess.exit
since that's dangerous and a disaster for unit testing.
Under the hood, runProgram
calls configureProgram
, which
auto-discovers and collects all the configurations exported from your command
files, followed by PreExecutionContext::execute
, which is a wrapper
around yargs::parseAsync
and yargs::hideBin
.
import { join } from 'node:path';
import { runProgram, configureProgram } from '@black-flag/core';
import { hideBin, isCliError } from '@black-flag/core/util';
// Note that this example is using CJS-style path resolution. ESM is different.
export default runProgram(join(__dirname, 'commands'));
// ^^^ These are essentially equivalent vvv
let parsedArgv = undefined;
try {
const commandsDir = join(__dirname, 'commands');
const preExecutionContext = await configureProgram(commandsDir);
parsedArgv = await preExecutionContext.execute(hideBin(process.argv));
process.exitCode = 0;
} catch (error) {
process.exitCode = isCliError(error) ? error.suggestedExitCode : 1;
}
export default parsedArgv;
Black Flag favors convention over configuration, meaning everything works out the box with sensible defaults and no sprawling configuration files.
However, when additional configuration is required, there are five optional configuration hooks that give Black Flag the flexibility to describe even the most bespoke of command line interfaces.
For instance, suppose we added a my-cli-project/configure.ts
file to our
project:
import type {
ConfigureArguments,
ConfigureErrorHandlingEpilogue,
ConfigureExecutionContext,
ConfigureExecutionEpilogue,
ConfigureExecutionPrologue
} from '@black-flag/core';
// These configuration hooks have been listed in the order they're typically
// executed by Black Flag. They are all entirely optional.
/**
* This function is called once towards the beginning of the execution of
* configureProgram and should return what will be used to create the global
* context singleton. Note that the return value of this function is cloned and
* then discarded.
*/
export const configureExecutionContext: ConfigureExecutionContext = async (
context
) => {
// You can add some state shared between all your command handlers and
// configuration hooks here.
context.somethingDifferent = 'cool';
return context; // <== This is: the "context" ExecutionContext used everywhere
};
/**
* This function is called once towards the end of the execution of
* configureProgram, after all commands have been discovered but before any
* have been executed, and should apply any final configurations to the yargs
* instances that constitute the command line interface.
*/
export const configureExecutionPrologue: ConfigureExecutionPrologue = async (
{ effector, helper, router }, // <== This is: root yargs instances (see below)
context
) => {
// Typically unnecessary and suboptimal to use this hook. Configure commands
// (including the root command) declaratively using the simple declarative
// filesystem-based API instead. Otherwise, at this point, you're just using
// yargs but with extra steps.
};
/**
* This function is called once towards the beginning of the execution of
* PreExecutionContext::execute(X) and should return a process.argv-like
* array.
*/
export const configureArguments: ConfigureArguments = async (
rawArgv, // <== This is either the X in ::execute(X), or hideBin(process.argv)
context
) => {
// This is where yargs middleware and other argument pre-processing can be
// implemented, if necessary.
// When PreExecutionContext::execute is invoked without arguments, Black Flag
// calls the yargs::hideBin helper utility on process.argv for you. Therefore,
// calling hideBin here would cause a bug. You shouldn't ever need to call
// hideBin manually, but if you do, it's re-exported from
// '@black-flag/core/util'.
return rawArgv; // <== This is: the argv that yargs will be given to parse
};
/**
* This function is called once after CLI argument parsing completes and either
* (1) handler execution succeeds or (2) a GracefulEarlyExitError is thrown.
*/
export const configureExecutionEpilogue: ConfigureExecutionEpilogue = async (
argv, // <== This is: the yargs::parseAsync() result
context
) => {
// If a GracefulEarlyExitError was thrown, then
// context.state.isGracefullyExiting === true.
return argv; // <== This is: what is returned by PreExecutionContext::execute
};
/**
* This function is called once at the very end of the error handling process
* after an error has occurred. However, this function is NOT called when a
* GracefulEarlyExitError is thrown!
*/
export const configureErrorHandlingEpilogue: ConfigureErrorHandlingEpilogue =
async ({ error, message, exitCode }, argv, context) => {
// message === (error?.message || String(error))
// Bring your own error handling and reporting if you'd like! By default,
// this hook will dump any error messages to stderr like so:
console.error(message);
};
Then our CLI's entry point might look something like this:
#!/usr/bin/env node
// File: my-cli-project/cli.ts
import { runProgram } from '@black-flag/core';
export default runProgram(
// Note that this example is using ESM-style path resolution. CJS is different
import.meta.resolve('./commands'),
// Just pass an object of your configuration hooks. Promises are okay!
import('./configure.js') // <== Might be ".ts" over ".js" for deno projects
);
Black Flag provides unified error handling and reporting across all your commands. Specifically:
-
The ability to suggest an exit code when throwing an error.
try { ... } catch(error) { // Black Flag sets process.exitCode for you regardless of what's thrown throw new 'something bad happened'; // But you can suggest an exit code by throwing a CliError throw new CliError('something bad happened', { suggestedExitCode: 5 }); // You can also tell Black Flag you'd like help text printed for this error throw new CliError('user failed to do something', { showHelp: true }); // You can even wrap other errors with it throw new CliError(error, { suggestedExitCode: 9 }); }
-
Handling graceful exit events (like when
--help
or--version
is used) as non-errors automatically.// Throwing this in your handler or elsewhere will cause Black Flag to exit // immediately with a 0 exit code. throw new GracefulEarlyExitError();
-
Outputting all error messages to stderr (via
console.error
) by default. -
Access to the parsed process arguments at the time the error occurred (if available).
How errors thrown during execution are reported to the user is determined by the
optionally-provided configureErrorHandlingEpilogue
configuration hook,
as well as each command file's optionally-exported builder
function.
// File: my-cli-project/cli.ts
await runProgram(import.meta.resolve('./commands'), {
configureErrorHandlingEpilogue({ error }, argv, context) {
// Instead of outputting to stderr by default, send all errors elsewhere
sendJsErrorToLog4J(argv.aMoreDetailedErrorOrSomething ?? error);
}
});
// File: my-cli-project/commands/index.ts
export function builder(blackFlag) {
// Turn off outputting help text when an error occurs
blackFlag.showHelpOnFail(false);
}
Note that framework errors and errors thrown in
configureExecutionContext
orconfigureExecutionPrologue
, which are always the result of developer error rather than end user error, cannot be handled byconfigureErrorHandlingEpilogue
. If you're usingmakeRunner
/runProgram
(which never throws) and a misconfiguration triggers a framework error, your application will set its exit code accordingly and send an error message to stderr. In such a case, use debug mode to gain insight if necessary.
Black Flag was built with a pleasant unit/integration testing experience in mind.
Auto-discovered commands are just importable JavaScript modules entirely decoupled from yargs and Black Flag, making them dead simple to test in isolation.
// File: my-cli-project/test.ts (with Jest as test runner)
import remoteRemove from './commands/remote/remove';
test('remote remove command works as expected', async () => {
expect.hasAssertions();
// Assuming "myctl remote remove" takes a positional argument "removal-target"
const fakeArgv = { removalTarget: 'upstream' };
// Run the command's handler with a fake "parsed" arguments object
await remoteRemove.handler(fakeArgv);
...
});
Individual configuration hook functions, if used, are similarly mockable and testable without Black Flag.
Suppose we wrote some configuration hooks in my-cli-project/configure.ts
:
// File: my-cli-project/configure.ts
import {
type ConfigureArguments,
type ConfigureErrorHandlingEpilogue
} from '@black-flag/core';
export const configureArguments: ConfigureArguments = (rawArgv) => {
return preprocessInputArgs(rawArgv);
function preprocessInputArgs(args) {
// ...
}
};
export const configureErrorHandlingEpilogue: ConfigureErrorHandlingEpilogue =
async ({ error, message }, _argv, context) => {
// ...
};
Then we could test it with the following:
// File: my-cli-project/test.ts (with Jest as test runner)
import * as conf from './configure';
test('configureArguments returns pre-processed arguments', async () => {
expect.hasAssertions();
await expect(conf.configureArguments([1, 2, 3])).resolves.toStrictEqual([3]);
});
test('configureErrorHandlingEpilogue outputs as expected', async () => {
expect.hasAssertions();
const errorSpy =
jest.spyOn(console, 'error').mockImplementation(() => undefined);
await conf.configureErrorHandlingEpilogue(...);
expect(errorSpy).toHaveBeenCalledWith(...);
});
And for those who prefer a more holistic behavior-driven testing approach, you
can use the same function for testing your CLI that you use as an entry point in
production: runProgram
.
Black Flag additionally provides the
makeRunner
utility function so you don't have to tediously copy and pasterunProgram(...)
and all its arguments between tests.
// File: my-cli-project/test.ts (with Jest as test runner)
import { makeRunner } from '@black-flag/core/util';
let latestError: string | undefined = undefined;
const run = makeRunner(`${__dirname}/commands`, {
// We run our commands decoupled from our CLI's actual configuration hooks,
// since they're too heavy for use in our unit tests. Instead, we substitute
// some bare bones configurations:
configureExecutionEpilogue(argv, context) { /* Some after-action cleanup */ },
configureErrorHandlingEpilogue({ message }) { latestError = message; }
});
beforeEach(() => (latestError = undefined));
afterEach(() => (process.exitCode = undefined));
it('supports help text at every level', async () => {
expect.hasAssertions();
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
await run('--help');
await run('init --help');
await run('remote --help');
await run('remote add --help');
await run('remote remove --help');
await run('remote show --help');
expect(logSpy.mock.calls).toStrictEqual([
// Each "--help" invocation should call console.log once with 1 parameter...
[expect.stringMatching(/.../)],
// ... and there should have been 6 invocations total.
...,
...,
...,
...,
...,
]);
});
it('throws on bad init --lang argument', async () => {
expect.hasAssertions();
await run(['init', '--lang', 'bad']);
expect(latestError).toBe('...');
// Since we didn't disable it, Black Flag will also output help text for this
// error. We could have tested for that with another jest spy if we wanted to.
});
Black Flag integrates debug, allowing for deep insight into your tool's
runtime without significant overhead or code changes. Simply set the DEBUG
environment variable to an appropriate value:
# Shows all possible debug output
DEBUG='*' myctl
# Only shows built-in debug output from Black Flag
DEBUG='black-flag*' myctl
# Only shows custom debug output from your tool's command files
DEBUG='myctl*' myctl
Black Flag's truly rich debug output will prove a mighty asset in debugging any framework-related issues, and especially when writing unit/integration tests. When your CLI is crashing or your test is failing in a strange way, consider re-running the failing test or problematic CLI with debugging enabled.
It is also possible to get meaningful debug output from your commands
themselves. Just include the debug package in your package.json
dependencies and import it in your command files:
// File: my-cli-project/commands/index.ts
// Since it's at the apex of the commands/ directory, this file configures the
// "root command," i.e.:
// myctl
// myctl --help
// myctl --version
import debugFactory from 'debug';
const debug = debugFactory('myctl');
export function handler(argv) {
debug('beginning to do a bunch of cool stuff...');
// ...
const someResult = ...
debug('saw some result: %O', someResult);
// ...
console.log('done!');
}
myctl
done!
DEBUG='myctl*' myctl
myctl beginning to do a bunch of cool stuff... +0ms
myctl saw some result: {
myctl lists: [],
myctl api: [Function: api],
myctl apiHandler: [Function: handler],
myctl anImportantString: 'very',
myctl } +220ms
done!
DEBUG='*' myctl
... A LOT OF DETAILED DEBUG OUTPUT FROM BLACK FLAG AND MYCTL ...
done!
Black Flag itself is fully typed, and each exposed type is heavily commented.
However, your command files are not tightly coupled with Black Flag. An
unfortunate side effect of this flexibility is that your command files do not
automatically pick up Black Flag's types in your IDE/editor. Fortunately, Black
Flag exports all its exposed types, including the generic
RootConfiguration
, ParentConfiguration
, and
ChildConfiguration
types.
Using these types, your command files themselves can be fully typed and you can enjoy the improved DX that comes with comprehensive intellisense. And for those who do not prefer TypeScript, you can even type your pure JavaScript files thanks to JSDoc syntax. No TypeScript required!
// @ts-check
// This is a pure CJS JavaScript file, no TypeScript allowed!
const { dirname, basename } = require('node:path');
const name = basename(dirname(__filename));
/**
* @type {import('@black-flag/core').ParentConfiguration}
*/
module.exports = {
description: `description for program ${name}`,
builder: (blackFlag) => blackFlag.option(name, { count: true }),
handler: (argv) => (argv.handled_by = __filename)
};
Child commands (commands not declared in index files) should use
ChildConfiguration
. Parent commands (commands declared in index files)
should use ParentConfiguration
. The root parent command (at the apex of
the directory storing your command files) should use RootConfiguration
.
There's also
Configuration
, the supertype of the threeXConfiguration
subtypes.
Similarly, if you're using configuration hooks in a separate file, you can enjoy
intellisense with those as well using the ConfigureX
generic types.
All of these generic types accept type parameters for validating custom properties you might set during argument parsing or on the shared execution context object.
See the docs for a complete list of Black Flag's exports and details about generics.
And that's Black Flag in a nutshell! Check out a complete demo repository for
that snazzy myctl
tool here. Or play with the real thing on NPM:
npx -p @black-flag/demo myctl --help
(also supports DEBUG
environment
variable). Or check out the step-by-step getting started guide below!
If you want to see an example of a fairly complex CLI built on Black Flag that implements global options, custom rich logging and error output, and support for configuration files, check out my personal CLI tool.
What follows is a simple step-by-step guide for building, running, and testing
the myctl
tool from the introductory section.
There's also a functional
myctl
demo repository. And you can interact with the published version on NPM:npx -p @black-flag/demo myctl --help
.
Let's make a new CLI project!
Note: what follows are linux shell commands. The equivalent Windows DOS/PS commands will be different.
mkdir my-cli-project
cd my-cli-project
git init
Add a package.json
file with the bare minimum metadata:
echo '{"name":"myctl","version":"1.0.0","type":"module","bin":{"myctl":"./cli.js"}}' > package.json
npm install @black-flag/core
Let's create the folder that will hold all our commands as well as the entry point Node recognizes:
mkdir commands
touch cli.js
chmod +x cli.js
Where cli.js
has the following content:
#!/usr/bin/env node
import { runProgram } from '@black-flag/core';
export default runProgram(import.meta.resolve('./commands'));
These examples use ESM syntax. CJS is also supported. For example:
#!/usr/bin/env node const bf = require('@black-flag/core'); const path = require('node:path'); module.exports = bf.runProgram(path.join(__dirname, 'commands'));
Let's create our first command, the root command. Every Black Flag project has
one, and it's always named index.js
. In vanilla yargs parlance, this would be
the highest-level "default command".
touch commands/index.js
Depending on how you invoke Black Flag (e.g. with Node, Deno, Babel+Node, etc),
command files support a subset of the following extensions in precedence order:
.js
, .mjs
, .cjs
, .ts
, .mts
, .cts
. To keep things simple, we'll be
using ES modules as .js
files (note the type in package.json
).
Also note that empty files, and files that do not export a handler
function or
custom command
string, are picked up by Black Flag as unfinished or
"unimplemented" commands. They will still appear in help text but, when invoked,
will either (1) output an error message explaining that the command is not
implemented if said command has no sub-commands or (2) output help text for the
command if said command has one or more sub-commands.
This means you can stub out a complex CLI in thirty seconds: just name your directories and empty files accordingly!
With that in mind, let's actually run our skeletal CLI now:
./cli.js
This command is currently unimplemented
Let's try with a bad positional parameter:
./cli.js bad
Usage: myctl
Options:
--help Show help text [boolean]
--version Show version number [boolean]
Unknown argument: bad
How about with a bad option:
./cli.js --bad
Usage: myctl
Options:
--help Show help text [boolean]
--version Show version number [boolean]
Unknown argument: bad
We could publish right now if we wanted to. The CLI would be perfectly
functional in that it would run to completion regardless of its current lack of
useful features. Our new package could then be installed via npm i -g myctl
,
and called from the CLI as myctl
! Let's hold off on that though.
You may have noticed that Black Flag calls yargs::strict(true)
on
auto-discovered commands by default, which is where the "unknown argument"
errors are coming from. In fact, commands are configured with several useful
defaults:
yargs::strict(true)
yargs::scriptName(fullName)
yargs::wrap(yargs::terminalWidth())
- If you want to tweak this across your entire command hierarchy, update
context.state.initialTerminalWidth
directly inconfigureExecutionContext
- If you want to tweak this across your entire command hierarchy, update
yargs::exitProcess(false)
- Black Flag only sets
process.exitCode
and never callsprocess.exit(...)
- Black Flag only sets
yargs::help(false)::option('help', { description })
- Black Flag supervises all help text generation, so this is just cosmetic
yargs::fail(...)
- Black Flag uses a custom failure handler
yargs::showHelpOnFail(true)
- Black Flag uses a custom failure handler
yargs::usage(defaultUsageText)
- Defaults to this.
- Note that, as of [email protected], calling
yargs::usage(...)
multiple times (such as inconfigureExecutionPrologue
) will concatenate each invocation's arguments into one long usage string instead of overwriting previous invocations with later ones
yargs::version(false)
- For the root command,
yargs::version(false)::option('version', { description })
is called instead
- For the root command,
Most of these defaults can be tweaked or overridden via each command's
builder
function, which gives you direct access to the yargs API. Let's
add one to commands/index.js
along with a handler
function and usage
string:
/**
* This little comment gives us intellisense support :)
*
* Also note how we're using the `export const X = function(...) { ... }` syntax
* instead of the streamlined `export function X(...) { ... }` syntax. Both of
* these syntaxes are correct, however JSDoc does not support using "@type" on
* the latter form for some reason.
*
* @type {import('@black-flag/core').Configuration['builder']}
*/
export const builder = function (blackFlag) {
return blackFlag.strict(false);
};
/**
* @type {import('@black-flag/core').RootConfiguration['handler']}
*/
export const handler = function (argv) {
console.log('ran root command handler');
};
/**
* Note that `usage` is just a freeform string used in help text. The `command`
* export, on the other hand, supports the yargs DSL for defining positional
* parameters and the like.
*
* @type {import('@black-flag/core').RootConfiguration['usage']}
*/
export const usage = 'Usage: $0 command [options]\n\nCustom description here.';
Now let's run the CLI again:
./cli.js
ran root command handler
And with a "bad" argument (we're no longer in strict mode):
./cli.js --bad --bad2 --bad3
ran root command handler
Neat. Let's add some more commands:
touch commands/init.js
mkdir commands/remote
touch commands/remote/index.js
touch commands/remote/add.js
touch commands/remote/remove.js
touch commands/remote/show.js
Wow, that was easy. Let's run our CLI now:
./cli.js --help
Usage: myctl command [options]
Custom description here.
Commands:
myctl [default]
myctl init
myctl remote
Options:
--help Show help text [boolean]
--version Show version number [boolean]
Let's try a child command:
./cli.js remote --help
Usage: myctl remote
Commands:
myctl remote [default]
myctl remote add
myctl remote remove
myctl remote show
Options:
--help Show help text [boolean]
Since different OSes walk different filesystems in different orders, auto-discovered commands will appear in alpha-sort order in help text rather than in insertion order; command groupings are still respected and each command's options are still enumerated in insertion order.
Black Flag offers a stronger sorting guarantee than
yargs::parserConfiguration({ 'sort-commands': true })
.
Now let's try a grandchild command:
./cli.js remote show --help
Usage: myctl remote show
Options:
--help Show help text [boolean]
Phew. Alright, but what about trying some commands we know don't exist?
./cli.js remote bad horrible
Usage: myctl remote
Commands:
myctl remote [default]
myctl remote add
myctl remote remove
myctl remote show
Options:
--help Show help text [boolean]
Invalid command: you must call this command with a valid sub-command argument
Neat! 📸
Testing if your CLI tool works by running it manually on the command line is nice and all, but if we're serious about building a stable and usable tool, we'll need some automated tests.
Thankfully, with Black Flag, testing your commands is usually easier than writing them.
First, let's install jest. We'll also create a file to hold our tests.
npm install --save-dev jest @babel/plugin-syntax-import-attributes
touch test.cjs
Since we set our root command to non-strict mode, let's test that it doesn't throw in the presence of unknown arguments. Let's also test that it exits with the exit code we expect and sends an expected response to stdout.
Note that we use makeRunner
below, which is a factory function that
returns a curried version of runProgram
that is far less tedious
to invoke successively.
Each invocation of
runProgram()
/makeRunner()()
configures and runs your entire CLI from scratch. Other than stuff like the require cache, there is no shared state between invocations unless you explicitly make it so. This makes testing your commands "in isolation" dead simple and avoids a common yargs footgun.
const { makeRunner } = require('@black-flag/core/util');
// makeRunner is a factory function that returns runProgram functions with
// curried arguments.
const run = makeRunner({ commandModulePath: `${__dirname}/commands` });
afterEach(() => {
// Since runProgram (i.e. what is returned by makeRunner) sets
// process.exitCode before returning, let's unset it after each test
process.exitCode = undefined;
});
describe('myctl (root)', () => {
it('emits expected output when called with no arguments', async () => {
expect.hasAssertions();
const logSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await run();
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls).toStrictEqual([['ran root command handler']]);
});
it('emits expected output when called with unknown arguments', async () => {
expect.hasAssertions();
const logSpy = jest
.spyOn(console, 'log')
.mockImplementation(() => undefined);
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
await run('--unknown');
await run('unknown');
expect(errorSpy).not.toHaveBeenCalled();
expect(logSpy.mock.calls).toStrictEqual([
['ran root command handler'],
['ran root command handler']
]);
});
it('still terminates with 0 exit code when called with unknown arguments', async () => {
expect.hasAssertions();
await run('--unknown-argument');
expect(process.exitCode).toBe(0);
});
});
Finally, let's run our tests:
npx --node-options='--experimental-vm-modules' jest --testMatch '**/test.cjs' --restoreMocks
As of January 2024, we need to use
--node-options='--experimental-vm-modules'
until the Node team unflags virtual machine module support in a future version.
We use
--restoreMocks
to ensure mock state doesn't leak between tests. We use--testMatch '**/test.cjs'
to make Jest see our CJS files.
PASS ./test.cjs
myctl (root)
✓ emits expected output when called with no arguments (168 ms)
✓ emits expected output when called with unknown arguments (21 ms)
✓ still terminates with 0 exit code when called with unknown arguments (20 ms)
Test Suites: 1 passed, 1 total
Tests: 3 passed, 3 total
Snapshots: 0 total
Time: 0.405 s, estimated 1 s
Ran all test suites.
Neat! 📸
Further documentation can be found under docs/
.
Term | Description |
---|---|
command | A "command" is a functional unit associated with a configuration file and represented internally as a trio of programs: effector, helper, and router. Further, each command is classified as one of: "pure parent" (root and parent), "parent-child" (parent and child), or "pure child" (child). |
program | A "program" is a yargs instance wrapped in a Proxy granting the instance an expanded set of features. Programs are represented internally by the Program type. |
root | The tippy top command in your hierarchy of commands and the entry point for any Black Flag application. Also referred to as the "root command". |
default command | A "default command" is yargs parlance for the CLI entry point. Technically there is no concept of a "default command" at the Black Flag level, though there is the root command. |
Note that yargs is a dependency of Black Flag. Black Flag is not a fork of yargs!
Aside from the expanded feature set, there are some minor differences between yargs and Black Flag. They should not be relevant given proper use of Black Flag, but are noted below nonetheless.
-
The
yargs::argv
magic property is soft-disabled (always returnsundefined
) because having such an expensive "hot" getter is not optimal in a language where properties can be accessed unpredictably. For instance, deep cloning a yargs instance results inyargs::parse
(and the handlers of any registered commands!) getting invoked several times, even after an error occurred in an earlier invocation. This can lead to undefined or even dangerous behavior.Who in their right mind is out here cloning yargs instances, you may ask? Jest does so whenever you use certain asymmetric matchers.
Regardless, you should never have to reach below Black Flag's abstraction over yargs to call methods like
yargs::parse
,yargs::parseAsync
,yargs::argv
, etc. Instead, just use Black Flag as intended.Therefore, this is effectively a non-issue with proper declarative use of Black Flag.
-
Yargs middleware isn't supported since the functionality is mostly covered by configuration hooks
and I didn't notice yargs had this feature until after I wrote Black Flag.If you have a yargs middleware function you want run with a specific command, either pass it to
yargs::middleware
via that command'sbuilder
function or just call the middleware function right then and there. If you want the middleware to apply globally, invoke the function directly inconfigureArguments
. If neither solution is desirable, you can also muck around with the relevant yargs instances manually inconfigureExecutionPrologue
. -
By default, Black Flag enables the
--help
and--version
options same as vanilla yargs. However, since vanilla yargs lacks the ability to modify or remove options added byyargs::option
, callingyargs::help
/yargs::version
will throw. If you require the functionality ofyargs::help
/yargs::version
to disable or modify the--help
/--version
option, updatecontext.state.globalHelpOption
/context.state.globalVersionOption
directly inconfigureExecutionContext
.Note: Black Flag enables built-in help and version options, never a help or version command.
Note: only the root command has default support for the built-in
--version
option. Calling--version
on a child command will have no effect unless you make it so. This dodges another yargs footgun, and settingcontext.state.globalVersionOption = undefined
will prevent yargs from clobbering any custom version arguments on the root command too.
-
A bug in [email protected] prevents
yargs::showHelp
/--help
from printing anything when using an asyncbuilder
function (or promise-returning function) for a default command.Black Flag addresses this with its types, in that attempting to pass an async builder will be flagged as problematic by intellisense. Moreover, Black Flag supports an asynchronous function as the value of
module.exports
in CJS code, and top-level await in ESM code, so if you really do need an asyncbuilder
function, hoist the async logic to work around this bug for now. -
A bug? in [email protected] causes
yargs::showHelp
to erroneously print the second element in thealiases
array of the default command when said command also has child commands.Black Flag addresses this by using a "helper" program to generate help text more consistently than vanilla yargs. For instance, the default help text for a Black Flag command includes the full
command
anddescription
strings while the commands under"Commands:"
are listed in alpha-sort order as their full canonical names only; unlike vanilla yargs, no positional arguments or aliases will be confusingly mixed into help text output unless you make it so. -
As of [email protected], attempting to add two sibling commands with the exact same name causes all sorts of runtime insanity, especially if the commands also have aliases.
Black Flag prevents you from shooting yourself in the foot with this. Specifically: Black Flag will throw if you attempt to add a command with a name or alias that conflicts with its sibling commands' name or alias.
-
As of [email protected], and similar to the above point, attempting to add two options with conflicting names/aliases to the same command leads to undefined and potentially dangerous runtime behavior from yargs.
Unfortunately, since yargs allows adding options through a wide variety of means, Black Flag cannot protect you from this footgun. However, Black Flag Extensions (BFE) can.
Specifically: BFE will throw if you attempt to add a command option with a name or alias that conflicts another of that command's options. BFE also takes into account the following yargs-parser settings configuration settings:
camel-case-expansion
,strip-aliased
,strip-dashed
. See BFE's documentation for details. -
Unfortunately, [email protected] doesn't really support calling
yargs::parse
oryargs::parseAsync
multiple times on the same instance if it's using the commands-based API. This might be a regression since, among other things, there are comments within yargs's source that indicate these functions were intended to be called multiple times.Black Flag addresses this in two ways. First, the
runProgram
helper takes care of state isolation for you, making it safe to callrunProgram
multiple times. Easy peasy. Second,PreExecutionContext::execute
(the wrapper aroundyargs::parseAsync
) will throw if invoked more than once. -
One of Black Flag's features is simple comprehensive error reporting via the
configureErrorHandlingEpilogue
configuration hook. Therefore, theyargs::showHelpOnFail
method will ignore the redundant "message" parameter. If you want that functionality, use said hook to output an epilogue after yargs outputs an error message, or useyargs::epilogue
/yargs::example
. Also, any invocation ofyargs::showHelpOnFail
applies globally to all commands in your hierarchy. -
Since every auto-discovered command translates into its own yargs instances, the
command
property, if exported by your command file(s), must start with"$0"
or an error will be thrown. This is also enforced by intellisense. -
The
yargs::check
,yargs::global
, andyargs::onFinishCommand
methods, while they may work as expected on commands and their direct child commands, will not function "globally" across your entire command hierarchy since there are several distinct yargs instances in play when Black Flag executes.If you want a uniform check or so-called "global" argument to apply to every command across your entire hierarchy, the "Black Flag way" would be to just use normal JavaScript instead: export a shared
builder
function from a utility file and call it in each of your command files. If you want something fancier than that, you can leverageconfigureExecutionPrologue
to callyargs::global
oryargs::check
by hand.Similarly,
yargs::onFinishCommand
should only be called when theargv
parameter inbuilder
is notundefined
(i.e. only on effector programs). This would prevent the callback from being executed twice. Further, the "Black Flag way" would be to ditchyargs::onFinishCommand
entirely and use plain old JavaScript and/or theconfigureExecutionPrologue
configuration hook instead. -
Since Black Flag is built from the ground up to be asynchronous, calling
yargs::parseSync
will throw immediately. You shouldn't be calling theyargs::parseX
functions directly anyway. -
Black Flag sets several defaults compared to vanilla yargs. These defaults are detailed in the Usage section.
Note: you shouldn't need to reach below Black Flag's declarative abstraction layer when building your tool. If you feel that you do, consider opening a new issue!
Since Black Flag is just a bunch of yargs instances stacked on top of each other wearing a trench coat, you can muck around with the internal yargs instances directly if you want.
For example, you can retrieve a mapping of all commands known to Black Flag and their corresponding yargs instances in the OS-specific order they were encountered during auto-discovery:
import { runCommand, $executionContext } from '@black-flag/core';
const argv = await runCommand('./commands');
// The next two function calls result in identical console output
console.log('commands:', argv[$executionContext].commands);
await runCommand('./commands', {
configureExecutionEpilogue(_argv, { commands }) {
console.log('commands:', commands);
}
});
commands: Map(6) {
'myctl' => { programs: [Object], metadata: [Object] },
'myctl init' => { programs: [Object], metadata: [Object] },
'myctl remote' => { programs: [Object], metadata: [Object] },
'myctl remote add' => { programs: [Object], metadata: [Object] },
'myctl remote remove' => { programs: [Object], metadata: [Object] },
'myctl remote show' => { programs: [Object], metadata: [Object] }
}
Each of these six commands is actually three programs:
-
The effector (
programs.effector
) programs are responsible for second-pass arguments parsing and comprehensive validation, executing each command's actualhandler
function, generating specific help text during errors, and ensuring the final parse result bubbles up to the router program. -
The helper (
programs.helper
) programs are responsible for generating generic help text as well as first-pass arguments parsing and initial validation. Said parse result is used as theargv
third parameter passed to thebuilder
functions of effectors. -
The router (
programs.router
) programs are responsible for proxying control to other routers and to helpers, and for ensuring exceptions and final parse results bubble up to the root Black Flag execution context (PreExecutionContext::execute
) for handling.
See the flow chart below for a visual overview.
These three programs representing the root command are accessible from the
PreExecutionContext::rootPrograms
property. They are also always the
first item in the PreExecutionContext::commands
map.
const preExecutionContext = configureProgram('./commands', {
configureExecutionEpilogue(_argv, { commands }) {
assert(preExecutionContext.rootPrograms === commands.get('myctl').programs);
assert(
preExecutionContext.rootPrograms ===
commands.get(Array.from(commands.keys())[0])
);
}
});
await preExecutionContext.execute();
Effectors do the heavy lifting in that they actually execute their command's
handler
. They are accessible via the programs.effector
property
of each object in PreExecutionContext::commands
, and can be configured
as one might a typical yargs instance.
Helpers are "clones" of their respective effectors and are accessible via the
programs.helper
property of each object in
PreExecutionContext::commands
. These instances have been reconfigured to
address a couple bugs in yargs help text output by excluding aliases from
certain output lines and excluding positional arguments from certain others. A
side-effect of this is that only effectors recognize top-level positional
arguments, which isn't a problem Black Flag users have to worry about unless
they're dangerously tampering with these programs directly.
Routers are partially-configured just enough to proxy control to other routers
or to helpers and are accessible via the programs.router
property of
each object in PreExecutionContext::commands
. They cannot and must not
have any configured strictness or validation logic.
Therefore: if you want to tamper with the program responsible for running a
command's handler
, operate on the effector program. If you want to
tamper with a command's generic stdout help text, operate on the helper program.
If you want to tamper with validation and parsing, operate on both the helper
and effectors. If you want to tamper with the routing of control between
commands, operate on the router program.
See the docs for more details on Black Flag's internals.
Rather than chain singular yargs instances together, the delegation of
responsibility between helper and effectors facilitates the double-parsing
necessary for dynamic options support. In implementing dynamic options,
Black Flag accurately parses the given arguments with the helper program on the
first pass and feeds the result to the builder
function of the effector
on the second pass (via builder
's new third parameter).
In the same vein, hoisting routing responsibilities to the router program allows Black Flag to make certain guarantees:
-
An end user trying to invoke a parent command with no implementation, or a non-existent child command of such a parent, will cause help text to be printed and an exception to be thrown with default error exit code. E.g.:
myctl parent child1
andmyctl parent child2
work but we wantmyctl parent
to show help text listing the available commands ("child1" and "child2") and exit with an error indicating the given command was not found. -
An end user trying to invoke a non-existent child of a strict pure child command will cause help text to be printed and an exception to be thrown with default error exit code. E.g.: we want
myctl exists noexist
andmyctl noexist
to show help text listing the available commands ("exists") and exit with an error indicating bad arguments. -
The right command gets to generate help and version text when triggered via arguments. To this end, passing
--help
/--version
or equivalent arguments is effectively ignored by routers.
With vanilla yargs's strict mode, attempting to meet these guarantees would require disallowing any arguments unrecognized by the yargs instances earlier in the chain, even if the instances down-chain do recognize said arguments. This would break Black Flag's support for deep "chained" command hierarchies entirely.
However, without vanilla yargs's strict mode, attempting to meet these guarantees would require allowing attempts to invoke non-existent child commands without throwing an error or throwing the wrong/confusing error. Worse, it would require a more rigid set of assumptions for the yargs instances, meaning some API features would be unnecessarily disabled. This would result in a deeply flawed experience for developers and users.
Hence the need for a distinct routing program which allows parent commands to recursively chain/route control to child commands in your hierarchy even when ancestor commands are not aware of the syntax accepted by their distant descendants—while still properly throwing an error when the end user tries to invoke a child command that does not exist or invoke a child command with gibberish arguments.
Effectors are essentially yargs instances with a registered default command. Unfortunately, when vanilla yargs is asked to generate help text for a default command that has aliases and/or top-level positional arguments, you get the following:
This is not ideal output for several reasons. For one, the "cmd"
alias of the
root command is being reported alongside subcmd
as if it were a child command
when in actuality it's just an alias for the default command.
Worse, the complete command string ('$0 root-positional'
) is also dumped into
output, potentially without any explanatory text. And even with explanatory text
for root-positional
, what if the subcmd
command has its own positional
argument also called root-positional
?
...
Commands:
fake-name cmd root-positional Root description [default]
fake-name subcmd root-positional Sub description
[aliases: sub, s] [deprecated]
Positionals:
root-positional Some description [string]
...
It gets even worse. What if the description of subcmd
's root-positional
argument is different than the root command's version, and with entirely
different functionality? At that point the help text is actually lying to the
user, which could have drastic consequences when invoking powerful CLI commands
with permanent effects.
On the other hand, given the same configuration, Black Flag outputs the following:
Note 1: in this example,
runProgram
is a function returned bymakeRunner
.
Note 2: in the above image, the first line under "Commands:" is the root command. In more recent versions of Black Flag, the root command is omitted from the list of sub-commands.
What follows is a flow diagram illustrating Black Flag's execution flow using
the myctl
example from the previous sections.
`myctl --verbose`
┌───────────────────────────────────┐
│ 2 │
│ ┌─────►┌───────────┐ │
┌──────────┐ │ │ │ │ │
│ │ 1 │ ┌───────────┴┐ │ │ │
│ USER ├─────┼─► ROOT │ │ ROUTER │ │
│ TERMINAL │ R1 │ │ COMMAND │ R2 │ (yargs) │ │
│ ◄─────┼─┤(Black Flag)◄─────┤ │ │
└──────────┘ │ └────────────┘ │ │ │
│ └┬──▲───┬──▲┘ │
│ 3A │ │ │ │ │
│ ┌──────────────┘ │ │ │ │
│ │ R3A │ │ │ │
│ │ ┌───────────────┘ │ │ │
│ │ │ 3B │ │ │
│ │ │ ┌─────────────┘ │ │
│ │ │ │ R3B │ │
│ │ │ │ ┌──────────────┘ │
│ │ │ │ │ │
│ │ │ ┌───▼─┴──┐ 4A ┌────────┐ │
│ │ │ │ HELPER ├────►EFFECTOR│ │
│ │ │ │ (yargs)│ R4A│ (yargs)│ │
│ │ │ └────────┘◄───┴────────┘ │
│ │ │ │
└──────┼─┼──────────────────────────┘
│ │
│ │`myctl remote --help`
┌──────┼─┼──────────────────────────┐
│ │ │ 4B │
│ │ │ ┌─────►┌───────────┐ │
│ │ │ │ │ │ │
│ ┌────▼─┴────┴┐ │ │ │
│ │PARENT-CHILD│ │ ROUTER │ │
│ │ COMMAND │ R4B│ (yargs) │ │
│ │(Black Flag)◄─────┤ │ │
│ └────────────┘ │ │ │
│ └┬──▲───┬──▲┘ │
│ 5A │ │ │ │ │
│ ┌──────────────┘ │ │ │ │
│ │ R5A │ │ │ │
│ │ ┌───────────────┘ │ │ │
│ │ │ 5B │ │ │
│ │ │ ┌─────────────┘ │ │
│ │ │ │ R5B │ │
│ │ │ │ ┌──────────────┘ │
│ │ │ │ │ │
│ │ │ ┌───▼─┴──┐ 6A ┌────────┐ │
│ │ │ │ HELPER ├────►EFFECTOR│ │
│ │ │ │ (yargs)│ R6A│ (yargs)│ │
│ │ │ └────────┘◄───┴────────┘ │
│ │ │ │
└──────┼─┼──────────────────────────┘
│ │
│ │`myctl remote remove origin`
┌──────┼─┼──────────────────────────┐
│ │ │ 6B │
│ │ │ ┌─────►┌───────────┐ │
│ │ │ │ │ │ │
│ ┌────▼─┴────┴┐ │ │ │
│ │ CHILD │ │ ROUTER │ │
│ │ COMMAND │ R6B│ (yargs) │ │
│ │(Black Flag)◄─────┤ │ │
│ └────────────┘ │ │ │
│ └────┬──▲───┘ │
│ 7 │ │ │
│ ┌──────────┘ │ │
│ │ R7 │ │
│ │ ┌───────────┘ │
│ │ │ │
│ ┌───▼─┴──┐ 8 ┌────────┐ │
│ │ HELPER ├────►EFFECTOR│ │
│ │ (yargs)│ R8 │ (yargs)│ │
│ └────────┘◄───┴────────┘ │
│ │
└───────────────────────────────────┘
Suppose the user executes myctl --verbose
.🡒1 Black Flag (using
runProgram
) calls your configuration hooks, discovers all available commands,
and creates three programs per discovered command: the "router", "helper", and
"effector". If there was an error during discovery/configuration or hook
execution, an internal error handling routine would execute before the process
exited with the appropriate code.1🡒R1 This is how all errors that
bubble up are handled. Otherwise, Black Flag calls the root
RouterProgram::parseAsync
.1🡒2 The router detects that the given
arguments refer to the current command and so calls
HelperProgram::parseAsync
.2🡒3B If the helper throws (e.g. due to a
validation error), the exception bubbles up to the root
command.R3B🡒R1 Otherwise, the helper will parse the given arguments
before calling EffectorProgram::parseAsync
.3B🡒4A The effector will
re-parse the given arguments, this time with the third argv
parameter
available to builder
, before throwing an error, outputting help/version text,
or in this case, calling the current command's handler
function. The result of
calling EffectorProgram::parseAsync
bubbles up to the root
commandR4A🡒R2 where it is then communicated to the
user.R2🡒R1
The
myctl
command is the root command, and as such is the only command that doesn't have a parent command, making it a "pure parent".
Suppose instead the user executes myctl remote --help
.🡒1 Black Flag
(using runProgram
) sets everything up and calls RouterProgram::parseAsync
the same as the previous example.1🡒2 However, this time the router
detects that the given arguments refer to a child command and so relinquishes
control to the trio of programs representing the myctl remote
command.2->3A Black Flag notes the user asked to generate generic
help text (by having passed the --help
argument) before calling
RouterProgram::parseAsync
.3A->4B myctl remote
's router detects
that the given arguments refer to the current command and that we're only
generating generic help text so calls HelperProgram::showHelp
4B🡒5B
and throws a GracefulEarlyExitError
that bubbles up to the root
commandR5B🡒R2 where it is then communicated to the
user.R2🡒R1
The
myctl remote
child command is a child command of the rootmyctl
command, but it also has its own child commands, making it a parent and a child command (i.e. a "parent-child").
Finally, suppose the user executes myctl remote remove origin
.🡒1
Black Flag (using runProgram
) sets everything up and calls the root
RouterProgram::parseAsync
the same as the previous example.1🡒2 The
router detects that the given arguments refer to a child command and so
relinquishes control to the trio of programs representing the myctl remote
command.2->3A The parent-child router detects that the given
arguments refer to a child command and so relinquishes control to the trio of
programs representing the myctl remote show
command.3A->4B->5A
myctl remote show
's router detects that the given arguments refer to the
current command5A->6B and so calls
HelperProgram::parseAsync
.6B🡒7 If the helper throws (e.g. due to a
validation error), the exception bubbles up to the root command.R7🡒R1
Otherwise, the helper will parse the given arguments before calling
EffectorProgram::parseAsync
.7🡒8 The effector will re-parse the
given arguments, this time with the third argv
parameter available to
builder
, before calling the current command's handler
function. The result
of calling EffectorProgram::parseAsync
bubbles up to the root
commandR8🡒R2 where it is then communicated to the
user.R2🡒R1
The
myctl remote show
child command is a child command of the parent-childmyctl remote
command. It has no children itself, making it a "pure child" command.
The ascii art diagram was built using https://asciiflow.com
I love yargs 💕 Yargs is the greatest! I've made over a dozen CLI tools with yargs, each with drastically different interfaces and requirements. A couple help manage critical systems.
Recently, as I was copying-and-pasting some configs from past projects for yet another tool, I realized the (irritatingly disparate 😖) structures of my CLI projects up until this point were converging on a set of conventions around yargs. And, as I'm always eager to "optimize" my workflows, I wondered how much of the boilerplate behind my "conventional use" of yargs could be abstracted away, making my next CLIs more stable upon release, much faster to build, and more pleasant to test. But perhaps most importantly, I could ensure my previous CLIs once upgraded would remain simple and consistent to maintain by myself and others in perpetuity.
Throw in a re-watch of the PotC series and Black Flag was born! 🏴☠🍾
This is a CJS2 package with statically-analyzable exports
built by Babel for Node.js versions that are not end-of-life. For TypeScript
users, this package supports both "Node10"
and "Node16"
module resolution
strategies.
Expand details
That means both CJS2 (via require(...)
) and ESM (via import { ... } from ...
or await import(...)
) source will load this package from the same entry points
when using Node. This has several benefits, the foremost being: less code
shipped/smaller package size, avoiding dual package
hazard entirely, distributables are not
packed/bundled/uglified, a drastically less complex build process, and CJS
consumers aren't shafted.
Each entry point (i.e. ENTRY
) in package.json
's
exports[ENTRY]
object includes one or more export
conditions. These entries may or may not include: an
exports[ENTRY].types
condition pointing to a type
declarations file for TypeScript and IDEs, an
exports[ENTRY].module
condition pointing to
(usually ESM) source for Webpack/Rollup, an exports[ENTRY].node
condition
pointing to (usually CJS2) source for Node.js require
and import
, an
exports[ENTRY].default
condition pointing to source for browsers and other
environments, and other conditions not enumerated
here. Check the package.json file to see which export
conditions are supported.
Though package.json
includes
{ "type": "commonjs" }
, note that any ESM-only entry points will
be ES module (.mjs
) files. Finally, package.json
also
includes the sideEffects
key, which is false
for
optimal tree shaking where appropriate.
See LICENSE.
New issues and pull requests are always welcome and greatly appreciated! 🤩 Just as well, you can star 🌟 this project to let me know you found it useful! ✊🏿 Or you could buy me a beer 🥺 Thank you!
See CONTRIBUTING.md and SUPPORT.md for more information.
Thanks goes to these wonderful people (emoji key):
Bernard 🚇 💻 📖 🚧 |
||||||
Add your contributions |
This project follows the all-contributors specification. Contributions of any kind welcome!