Tip
Consider familiarizing yourself with Black Flag's terminology.
Caution
You shouldn't need to reach below Black Flag's declarative abstraction layer when building your tool. Doing so is usually an anti-pattern. If you feel that you must do this, 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 { runProgram, $executionContext } from '@black-flag/core';
const argv = await runProgram('./commands');
// The next two function calls result in identical console output
console.log('commands:', argv[$executionContext].commands);
await runProgram('./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 help text with respect to dynamic options, 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.
Tip
See the flow diagram below for a visual overview of how these programs are wired together.
The effector, helper, and router specifically representing the root command are
accessible from the PreExecutionContext::rootPrograms
property. They are
also always the first item in the ExecutionContext::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 ExecutionContext::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
ExecutionContext::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 ExecutionContext::commands
. They cannot and must not have any
configured strictness or validation logic.
Therefore: if you want to tamper with the Yargs instance 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 Yargs
instances, operate on the router program.
See the docs for implementation details on Black Flag's internal APIs.
Rather than naively chain simple singular Yargs instances together, which was how the very first versions of Black Flag operated, splitting parsing responsibilities between the helper and effector programs facilitates the double-parsing necessary for consistent dynamic options support.
Implementing dynamic options in this way allows Black Flag to accurately parse
the given arguments with the helper program on the first pass and feed the
result to the builder
function of the effector on the second pass (via
builder
's new third parameter). It also allows Black Flag to send the
user accurate help text depending on which dynamic options were given.
In the same consistency-accuracy 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 always cause help text to be printed and an exception to be thrown with a default error exit code.
Example: executing
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 always cause help text to be printed and an exception to be thrown with a default error exit code.
Example: 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 correct command always 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 (i.e. exclusively dealt with by helpers/effectors).
Without router programs, attempting to meet these guarantees by enabling vanilla Yargs's strict mode on effectors/helpers would require disallowing any arguments unrecognized by the Yargs instances earlier in the chain (ancestor parent programs), even if the instances down-chain (descendant child programs) do recognize said arguments. This would break Black Flag's support for deep "chained" parent-child command hierarchies entirely, which is the whole point of Black Flag.
However, disabling vanilla Yargs's strict mode to work around this issue would require allowing attempts to invoke non-existent child commands without throwing an error, or throwing the wrong/confusing error. This would make for a terrible UX.
The only way to meet these guarantees without router programs would be to enforce a very rigid set of assumptions on the helpers and effectors, which very early versions of Black Flag did. This resulted in some well-known Yargs API features being disabled, which made for a terrible DX.
Hence the need for distinct router programs, which allow parent commands to recursively route control to child commands 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 bad arguments.
Effectors (and helpers) are essentially vanilla Yargs instances with a registered default command. Like mitochondria is to a cell, effectors are the powerhouses 🔌 of each Black Flag 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
.
Note
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 subcommands entirely.
Black Flag ships with several advanced testing utilities, including
makeRunner
and expectedHelpTextRegExp
.
expectedHelpTextRegExp
generates regular expressions that can match with
high fidelity the result of calling --help
on a command, including matching
the help text shown when an error occurs.
See the tests for examples.
What follows is a diagram illustrating Black Flag's command execution flow from
start to finish. Three commands are executed from the myctl
example in
getting-started.md
:
myctl --verbose
myctl remote --help
myctl remote remove origin
⚡ 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
, which wraps configureProgram
) 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
, which wraps configureProgram
) 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
, which wraps configureProgram
) sets everything
up and calls the root RouterProgram::parseAsync
the same as the previous
examples.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.
Note
The ascii art diagram was generated using https://asciiflow.com