$ black-pearl hoist the colors --black-flag
Black Flag
Black Flag is a fairly thin library that wraps yargs, extending its
capabilities with several powerful declarative features.
Tested on Ubuntu and Windows.
Features
Not yet familiar with yargs? Check out their intro documentation before
continuing.
Declaratively Build Deep Command Hierarchies β¨
Black Flag provides first-class support for authoring sprawling deeply nested
tree-like structures of commands and child commands.
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 me@mine.myself
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.
Built-In Support for Dynamic Options β¨
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.
It's Yargs All the Way down β¨
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:
export function builder(yargs, helpOrVersionSet, argv) {
yargs.parserConfiguration({ 'parse-numbers': false });
if (argv) {
if (argv.lang === 'node') {
return {
lang: { choices: ['node'] },
version: { choices: ['19.8', '20.9', '21.1'] }
};
} else {
return yargs.options({
lang: { choices: ['python'] },
version: {
choices: ['3.10', '3.11', '3.12']
}
});
}
}
else {
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 node@21.1 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:
import type { Configuration, $executionContext } from '@black-flag/core';
const configuration: Configuration = {
aliases: [],
builder(yargs, helpOrVersionSet, argv) {
return yargs.boolean('verbose');
return {
verbose: {
boolean: true,
description: '...'
}
};
},
command: '$0 [positional-arg-1] [positional-arg-2]',
deprecated: false,
description: 'initializes stuff',
handler(argv) {
console.log(`> initializing new ${argv.lang} project...`);
},
name: 'init',
usage: 'This is neat.'
};
export default configuration;
Run Your Tool Safely and Consistently β¨
Black Flag not only helps you declaratively build your CLI tool, but run it
too.
#!/usr/bin/env node
import { runProgram } from '@black-flag/core';
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 calls process.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';
export default runProgram(join(__dirname, 'commands'));
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;
Convention over Configuration β¨
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';
export const configureExecutionContext: ConfigureExecutionContext = async (
context
) => {
context.somethingDifferent = 'cool';
return context;
};
export const configureExecutionPrologue: ConfigureExecutionPrologue = async (
{ effector, helper, router },
context
) => {
};
export const configureArguments: ConfigureArguments = async (
rawArgv,
context
) => {
return rawArgv;
};
export const configureExecutionEpilogue: ConfigureExecutionEpilogue = async (
argv,
context
) => {
return argv;
};
export const configureErrorHandlingEpilogue: ConfigureErrorHandlingEpilogue =
async ({ error, message, exitCode }, argv, context) => {
console.error(message);
};
Then our CLI's entry point might look something like this:
#!/usr/bin/env node
import { runProgram } from '@black-flag/core';
export default runProgram(
import.meta.resolve('./commands'),
import('./configure.js')
);
Simple Comprehensive Error Handling and Reporting β¨
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) {
throw new 'something bad happened';
throw new CliError('something bad happened', { suggestedExitCode: 5 });
throw new CliError('user failed to do something', { showHelp: true });
throw new CliError(error, { suggestedExitCode: 9 });
}
-
Handling graceful exit events (like when --help
or --version
is used) as
non-errors automatically.
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.
await runProgram(import.meta.resolve('./commands'), {
configureErrorHandlingEpilogue({ error }, argv, context) {
sendJsErrorToLog4J(argv.aMoreDetailedErrorOrSomething ?? error);
}
});
export function builder(blackFlag) {
blackFlag.showHelpOnFail(false);
}
Note that framework errors and errors thrown in
configureExecutionContext
or configureExecutionPrologue
, which are always
the result of developer error rather than end user error, cannot be handled by
configureErrorHandlingEpilogue
. If you're using
makeRunner
/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.
A Pleasant Testing Experience β¨
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.
import remoteRemove from './commands/remote/remove';
test('remote remove command works as expected', async () => {
expect.hasAssertions();
const fakeArgv = { removalTarget: 'upstream' };
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
:
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:
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 paste runProgram(...)
and all its
arguments between tests.
import { makeRunner } from '@black-flag/core/util';
let latestError: string | undefined = undefined;
const run = makeRunner(`${__dirname}/commands`, {
configureExecutionEpilogue(argv, context) { },
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([
[expect.stringMatching(/.../)],
...,
...,
...,
...,
...,
]);
});
it('throws on bad init --lang argument', async () => {
expect.hasAssertions();
await run(['init', '--lang', 'bad']);
expect(latestError).toBe('...');
});
Built-In debug
Integration for Runtime Insights β¨
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:
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!
Extensive Intellisense Support β¨
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!
const { dirname, basename } = require('node:path');
const name = basename(dirname(__filename));
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 three
XConfiguration
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.
Install
npm install @black-flag/core
Usage
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
.
Building and Running Your CLI
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())
yargs::exitProcess(false)
- Black Flag only sets
process.exitCode
and never calls process.exit(...)
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 yargs@17.7.2, calling
yargs::usage(...)
multiple times
(such as in configureExecutionPrologue
) 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
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:
export const builder = function (blackFlag) {
return blackFlag.strict(false);
};
export const handler = function (argv) {
console.log('ran root command handler');
};
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.
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 Your CLI
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');
const run = makeRunner({ commandModulePath: `${__dirname}/commands` });
afterEach(() => {
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! πΈ
Appendix π΄
Further documentation can be found under docs/
.
Terminology
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. |
Differences between Black Flag and Yargs
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.
Minor Differences
-
The yargs::argv
magic property is soft-disabled (always returns undefined
)
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 in yargs::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's builder
function or just call the middleware function right then and there. If you
want the middleware to apply globally, invoke the function directly in
configureArguments
. If neither solution is desirable, you can also
muck around with the relevant yargs instances manually in
configureExecutionPrologue
.
-
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 by yargs::option
, calling
yargs::help
/yargs::version
will throw. If you require the functionality of
yargs::help
/yargs::version
to disable or modify the --help
/--version
option, update
context.state.globalHelpOption
/context.state.globalVersionOption
directly in configureExecutionContext
.
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 setting
context.state.globalVersionOption = undefined
will prevent yargs
from clobbering any custom version arguments on the root command too.
Irrelevant Differences
-
A bug in yargs@17.7.2 prevents yargs::showHelp
/--help
from printing
anything when using an async builder
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 async
builder
function, hoist the async logic to work around this bug
for now.
-
A bug? in yargs@17.7.2 causes yargs::showHelp
to erroneously print
the second element in the aliases
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
and
description
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 yargs@17.7.2, 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.
-
Unfortunately, yargs@17.7.2 doesn't really support calling
yargs::parse
or yargs::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 call
runProgram
multiple times. Easy peasy. Second,
PreExecutionContext::execute
(the wrapper around yargs::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, the
yargs::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 use yargs::epilogue
/yargs::example
.
Also, any invocation of yargs::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
, and yargs::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 leverage configureExecutionPrologue
to call
yargs::global
or yargs::check
by hand.
Similarly, yargs::onFinishCommand
should only be called when the argv
parameter in builder
is not undefined
(i.e. only on effector
programs). This would prevent the callback from being executed twice.
Further, the "Black Flag way" would be to ditch yargs::onFinishCommand
entirely and use plain old JavaScript and/or the
configureExecutionPrologue
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 the
yargs::parseX
functions directly anyway.
-
Black Flag sets several defaults compared to vanilla yargs. These defaults are
detailed in the Usage section.
Advanced Usage
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');
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 actual handler
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 the argv
third parameter passed to
the builder
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.
Motivation
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
and myctl parent child2
work but we want
myctl 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
and
myctl 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.
Generating Help Text
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: in this example, runProgram
is a function returned by
makeRunner
.
Execution Flow Diagram
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 root myctl
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-child
myctl remote
command. It has no children itself, making it a "pure child"
command.
The ascii art diagram was built using https://asciiflow.com
Inspiration
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! π΄ββ πΎ
Published Package Details
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.
License
See LICENSE.
Contributing and Support
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.
Contributors
Thanks goes to these wonderful people (emoji
key):
This project follows the all-contributors
specification. Contributions of any kind welcome!