UNPKG

@black-flag/extensions

Version:

A collection of set-theoretic declarative-first APIs for yargs and Black Flag

1,461 lines (1,161 loc) 67 kB
<!-- symbiote-template-region-start 1 --> <p align="center" width="100%"> <img width="300" src="https://raw.githubusercontent.com/Xunnamius/black-flag/refs/heads/main/packages/extensions/logo.png"> </p> <p align="center" width="100%"> <!-- symbiote-template-region-end --> A collection of set-theoretic declarative-first APIs for Yargs and <a href="https://github.com/Xunnamius/black-flag">Black Flag</a> <!-- symbiote-template-region-start 2 --> </p> <hr /> <div align="center"> [![Black Lives Matter!][x-badge-blm-image]][x-badge-blm-link] [![Last commit timestamp][x-badge-lastcommit-image]][x-badge-repo-link] [![Codecov][x-badge-codecov-image]][x-badge-codecov-link] [![Source license][x-badge-license-image]][x-badge-license-link] [![Uses Semantic Release!][x-badge-semanticrelease-image]][x-badge-semanticrelease-link] [![NPM version][x-badge-npm-image]][x-badge-npm-link] [![Monthly Downloads][x-badge-downloads-image]][x-badge-downloads-link] </div> <br /> # @black-flag/extensions 🏴 <!-- symbiote-template-region-end --> Black Flag Extensions (BFE) is a collection of surprisingly simple set-theoretic APIs that wrap Black Flag commands' exports to provide a bevy of new declarative features, some of which are heavily inspired by [Yargs's GitHub Issues reports][1]. It's like type-fest or jest-extended, but for Black Flag and Yargs! In exchange for straying a bit from the vanilla Yargs API, BFE greatly increases Black Flag's declarative powers. Note that BFE does not represent a [complete][2] [propositional logic][3] and so cannot describe every possible relation between arguments. Nor should it; BFE makes it easy to fall back to using the Yargs API imperatively in those rare instances it's necessary. <!-- symbiote-template-region-start 3 --> --- <!-- remark-ignore-start --> <!-- symbiote-template-region-end --> <!-- START doctoc generated TOC please keep comment here to allow auto update --> <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE --> - [Install](#install) - [Usage](#usage) - [`withBuilderExtensions`](#withbuilderextensions) - [`withUsageExtensions`](#withusageextensions) - [`getInvocableExtendedHandler`](#getinvocableextendedhandler) - [Examples](#examples) - [Appendix 🏴](#appendix-) - [Differences between Black Flag Extensions and Yargs](#differences-between-black-flag-extensions-and-yargs) - [Black Flag versus Black Flag Extensions](#black-flag-versus-black-flag-extensions) - [Published Package Details](#published-package-details) - [License](#license) - [Contributing and Support](#contributing-and-support) - [Contributors](#contributors) <!-- END doctoc generated TOC please keep comment here to allow auto update --> <!-- symbiote-template-region-start 4 --> <!-- remark-ignore-end --> <br /> ## Install <!-- symbiote-template-region-end --> To install: ```shell npm install @black-flag/extensions ``` ## Usage > [!NOTE] > > See also: [differences between BFE and Yargs][4]. > [!WARNING] > > Most of the examples in this section (not including syntax-highlighted code > blocks) are using hyphen characters followed by a [word joiner character][5] > to prevent breaking examples awkwardly across lines. Be cautious copying and > pasting. ### `withBuilderExtensions` > ⪢ API reference: [`withBuilderExtensions`][6] This function enables several additional options-related units of functionality via analysis of the returned options configuration object and the parsed command line arguments (i.e. `argv`). <!-- example-region wbe-js --> ```javascript import { withBuilderExtensions } from '@black-flag/extensions'; export default function command({ state }) { const [builder, withHandlerExtensions] = withBuilderExtensions( (blackFlag, helpOrVersionSet, argv) => { blackFlag.strict(false); // ▼ The "returned options configuration object" return { 'my-argument': { alias: ['arg1'], demandThisOptionXor: ['arg2'], string: true }, arg2: { boolean: true, demandThisOptionXor: ['my-argument'] } }; }, { disableAutomaticGrouping: true } ); return { name: 'my-command', builder, handler: withHandlerExtensions(({ myArgument, arg2 }) => { state.outputManager.log( 'Executing command with arguments: arg1=${myArgument} arg2=${arg2}' ); }) }; } ``` Note how, in the previous example, the option names passed to configuration keys, e.g. `{ demandThisOptionXor: ['my-argument'] }`, are represented by their exact _canonical_ names as defined (e.g. `'my-⁠argument'`) and not their aliases (`'arg1'`) or camel-case expanded forms (`'myArgument'`). All BFE configuration keys expect canonical option names in this way; passing an alias or a camel-case expansion will result in erroneous behavior. In the same vein, `withBuilderExtensions` will throw if you attempt to add a command option with a name, alias, or camel-case expansion that conflicts with another of that command's options. This sanity check takes into account the following [yargs-parser][7] configuration settings: `camel-case-expansion`, `strip-aliased`, `strip-dashed`. Also note how `withBuilderExtensions` returns a two-element array of the form: `[builder, withHandlerExtensions]`. [`builder`][8] should be exported as your command's [`builder`][8] function **without being invoked**. If you want to implement additional imperative logic, pass a `customBuilder` _function_ to `withBuilderExtensions` as demonstrated in the previous example; otherwise, you should pass an options configuration _object_. On the other hand, `withHandlerExtensions` **should be invoked immediately**, and its return value should be exported as your command's [`handler`][9] function as demonstrated in the previous example. You should pass a `customHandler` to `withHandlerExtensions` upon invocation, though this is not required. If you call `withHandlerExtensions()` without providing a `customHandler`, a placeholder function that throws `CommandNotImplementedError` will be used instead, indicating that the command has not yet been implemented. This mirrors [Black Flag's default behavior for unimplemented command handlers][10]. #### New Option Configuration Keys This section details the new configuration keys made available by BFE, each implementing an options-related unit of functionality beyond that offered by vanilla Yargs and Black Flag. Note that the checks enabled by these configuration keys: - Are run on Black Flag's [second parsing pass][11] except where noted. This allows BFE to perform checks against argument _values_ in addition to the argument existence checks enabled by vanilla Yargs. - Will ignore the existence of the [`default`][12] key ([unless it's a custom check][13]). This means you can use keys like [`requires`][14] and [`conflicts`][15] alongside [`default`][12] without causing unresolvable CLI errors. This avoids a rather unintuitive [Yargs footgun][16]. - Will take into account the following [yargs-parser settings][7] configuration settings: `camel-case-expansion`, `strip-aliased`, `strip-dashed`. Note that `dot-notation` is _not_ currently recognized or considered by BFE, but may be in the future. **Logical Keys** > [!NOTE] > > In the below definitions, `P`, `Q`, and `R` are arguments (or argument-value > pairs) configured via a hypothetical call to > [`blackFlag.options({ P: { [key]: [Q, R] }})`][17]. The [truth values][3] of > `P`, `Q`, and `R` as described in the "Definition" column represent the > existence of each respective argument (and its value) in the `argv` parse > result. `gwav` is a predicate standing for "given with any value," meaning the > argument was given on the command line. | Key | Definition | | :-------------------------- | :-------------------------------------------- | | [`requires`][14] | `P ⟹ (Q ∧ R)` or `¬P ∨ (Q ∧ R)` | | [`conflicts`][15] | `P ⟹ (¬Q ∧ ¬R)` or `¬P ∨ (¬Q ∧ ¬R)` | | [`implies`][18] | `P ⟹ (Q ∧ R ∧ (gwav(Q) ⟹ Q) ∧ (gwav(R) ⟹ R))` | | [`demandThisOptionIf`][19] | `(Q ∨ R) ⟹ P` or `P ∨ (¬Q ∧ ¬R)` | | [`demandThisOption`][20] | `P` | | [`demandThisOptionOr`][21] | `P ∨ Q ∨ R` | | [`demandThisOptionXor`][22] | `P ⊕ Q ⊕ R` | **Relational Keys** | Key | | :-------------------------- | | [`check`][13] | | [`subOptionOf`][23] | | [`looseImplications`][24] | | [`vacuousImplications`][25] | --- ##### `requires` > ⪢ API reference: [`requires`][26] > [!IMPORTANT] > > `requires` is a superset of and replacement for vanilla Yargs's > [`implies`][27]. BFE also has [its own implication implementation][18]. `requires` enables checks to ensure the specified arguments, or argument-value pairs, are given conditioned on the existence of another argument. For example: <!-- example-region requires-1-jsonc --> ```jsonc { "x": { "requires": "y" }, // ◄ Disallows x without y "y": {} } ``` This configuration will trigger a check to ensure that `-⁠y` is given whenever `-⁠x` is given. `requires` also supports checks against the parsed _values_ of arguments in addition to the argument existence checks demonstrated above. For example: <!-- example-region requires-2-jsonc --> ```jsonc { // ▼ Disallows x unless y == 'one' and z is given "x": { "requires": [{ "y": "one" }, "z"] }, "y": {}, "z": { "requires": "y" } // ◄ Disallows z unless y is given } ``` This configuration allows the following arguments: no arguments (`∅`), `-⁠y=...`, `-⁠y=... -⁠z`, `-⁠xz -⁠y=one`; and disallows: `-⁠x`, `-⁠z`, `-⁠x -⁠y=...`, `-⁠xz -⁠y=...`, `-⁠xz`. Note that, when performing a check using the parsed value of an argument and that argument is configured as an array (`{ array: true }`), that array will be searched for said value. Otherwise, a strict deep equality check is performed. ###### `requires` versus `implies` Choose [BFE's `implies`][18] over `requires` when you want one argument to imply the value of another _without_ requiring the other argument to be explicitly given in `argv` (e.g. via the command line). --- ##### `conflicts` > ⪢ API reference: [`conflicts`][28] > [!IMPORTANT] > > `conflicts` is a superset of vanilla Yargs's [`conflicts`][29]. `conflicts` enables checks to ensure the specified arguments, or argument-value pairs, are _never_ given conditioned on the existence of another argument. For example: <!-- example-region conflicts-1-jsonc --> ```jsonc { "x": { "conflicts": "y" }, // ◄ Disallows y if x is given "y": {} } ``` This configuration will trigger a check to ensure that `-⁠y` is never given whenever `-⁠x` is given. `conflicts` also supports checks against the parsed _values_ of arguments in addition to the argument existence checks demonstrated above. For example: <!-- example-region conflicts-2-jsonc --> ```jsonc { // ▼ Disallows y == 'one' or z if x is given "x": { "conflicts": [{ "y": "one" }, "z"] }, "y": {}, "z": { "conflicts": "y" } // ◄ Disallows y if z is given } ``` This configuration allows the following arguments: no arguments (`∅`), `-⁠y=...`, `-⁠x`, `-⁠z`, `-⁠x -⁠y=...`; and disallows: `-⁠y=... -⁠z`, `-⁠x -⁠y=one`, `-⁠xz -⁠y=one`, `-⁠xz`. Note that, when performing a check using the parsed value of an argument and that argument is configured as an array (`{ array: true }`), that array will be searched for said value. Otherwise, a strict deep equality check is performed. ###### `conflicts` versus `implies` Choose [BFE's `implies`][18] over `conflicts` when you want the existence of one argument to override the default/given value of another argument while not preventing the two arguments from being given simultaneously. --- ##### `implies` > ⪢ API reference: [`implies`][30] > [!IMPORTANT] > > BFE's `implies` replaces vanilla Yargs's `implies` in a breaking way. The two > implementations are nothing alike. If you're looking for vanilla Yargs's > functionality, see [`requires`][14]. `implies` will set a default value for the specified arguments conditioned on the existence of another argument. This will _override_ the default value of the specified arguments. Unless [`looseImplications`][24] is set to `true`, if any of the specified arguments are explicitly given in `argv` (e.g. via the command line), their values must match the specified argument-value pairs respectively (similar to [`requires`][14]/[`conflicts`][15]). For this reason, `implies` only accepts one or more argument-value pairs and not raw strings. For example: <!-- example-region implies-1-jsonc --> ```jsonc { "x": { "implies": { "y": true } }, // ◄ x becomes synonymous with xy "y": {} } ``` This configuration makes it so that `-⁠x` and `-⁠x -⁠y=true` result in the exact same `argv`. Further, unlike `requires`, `implies` _makes no demands on argument existence_ and so allows the following arguments: no arguments (`∅`), `-⁠x`, `-⁠y=true`, `-⁠y=false`, `-⁠x -⁠y=true`; and disallows: `-⁠x -⁠y=false`. Note that attempting to imply a value for a non-existent option will throw a framework error. Additionally, if any of the specified arguments have their own [`default`][12]s configured, said defaults will be overridden by the values of `implies`. For example: <!-- example-region implies-2-jsonc --> ```jsonc { "x": { "implies": { "y": true } }, "y": { "default": false } // ◄ y will still default to true if x is given } ``` Also note the [special behavior][25] of `implies` specifically in the case where an argument value in `argv` is strictly equal to `false`. For describing much more intricate implications between various arguments and their values, see [`subOptionOf`][23]. ###### Handling Transitive Implications `implies` configurations **do not cascade transitively**. This means if argument `P` `implies` argument `Q`, and argument `Q` `implies` argument `R`, and `P` is given, the only check that will be performed is on `P` and `Q`. If `P` must imply some value for both `Q` _and `R`_, specify this explicitly in `P`'s configuration. For example: ```diff { - P: { "implies": { Q: true } }, + P: { "implies": { Q: true, R: true } }, Q: { "implies": { R: true } }, R: {} } ``` This has implications beyond just `implies`. **An implied value will not transitively satisfy any other BFE logic checks** (such as [`demandThisOptionXor`][22]) **or trigger any relational behavior** (such as with [`subOptionOf`][23]). The implied argument-value pair will simply be merged into `argv` as if you had done it manually in your command's [`handler`][9]. If this is a problem, prefer the explicit direct relationships described by other [configuration keys][31] instead of relying on the implicit transitive relationships described by `implies`. Despite this constraint, any per-option [`check`][13]s you've configured, which are run last (at the very end of `withHandlerExtensions`), _will_ see the implied argument-value pairs. Therefore, use [`check`][13] to guarantee any complex invariants, if necessary; ideally, you shouldn't be setting bad defaults via `implies`, but BFE won't stop you from doing so. ###### Handling Parser Configuration Like other BFE checks, `implies` _does_ take into account the [yargs-parser settings][7] `camel-case-expansion`, `strip-aliased`, and `strip-dashed`; but _does not_ currently pay attention to `dot-notation` or `duplicate-arguments-array`. `implies` may still work when using the latter parser configurations, but it is recommended you turn them off instead. ###### `implies` versus `requires`/`conflicts` BFE's `implies`, since it sets arguments in `argv` if they are not explicitly given, is a weaker form of [`requires`][14]/[`conflicts`][15]. Choose `requires` over BFE's `implies` when you want one argument to imply the value of another _while_ requiring the other argument to be explicitly given in `argv` (e.g. via the command line). Choose `conflicts` over BFE's `implies` when you think you want to use `implies` but you don't actually need to override the default value of the implied argument and only want the conflict semantics. Alternatively, choose [`subOptionOf`][23] over BFE's `implies` when you want the value of one argument to imply something complex about another argument and/or its value, such as updating the other argument's options configuration. ###### `looseImplications` If `looseImplications` is set to `true`, any of the specified arguments, when explicitly given in `argv` (e.g. via the command line), will _override_ any configured implications instead of causing an error. When `looseImplications` is set to `false`, which is the default, values explicitly given in `argv` must match the specified argument-value pairs respectively (similar to [`requires`][14]/[`conflicts`][15]). ###### `vacuousImplications` By default, an option's configured implications will only take effect if said option is given in `argv` _with a non-`false` value_. For example: <!-- example-region implies-3-jsonc --> ```jsonc { "x": { "boolean": true, "implies": { "y": true } }, "y": { // This example works regardless of the type of y! "boolean": true, //"array": true, //"count": true, //"number": true, //"string": true, "default": false } } ``` If `-⁠x` (or `-⁠x=true`) is given, it is synonymous with `-⁠x -⁠y` (or `-⁠x=true -⁠y=true`) being given and vice-versa. However, if `-⁠x=false` (or `-⁠no-x`) is given, the `implies` key is effectively ignored. This means `-⁠x=false` _does not imply anything about `-⁠y`_; `-⁠x=false -y=true` and `-⁠x=false -y=false` are both accepted by BFE without incident. In this way, the configured implications of [`boolean`][32]-type options are _never [vacuously satisfied][33]_; a strictly `false` condition does not "imply" anything about its [consequent][34]. This feature reduces confusion for end users. For instance, suppose we had a CLI build tool that accepted the arguments `-⁠patch` and `-⁠only-⁠patch`. `-⁠patch` instructs the tool to patch any output before committing it to disk while `-⁠only-⁠patch` instructs the tool to _only_ patch pre-existing output already on disk. The command's options configuration could look something like the following: <!-- example-region implies-4-jsonc --> ```jsonc { "patch": { "boolean": true, "description": "Patch output using the nearest patcher file", "default": true }, "only-patch": { "boolean": true, "description": "Instead of building new output, only patch existing output", "default": false, "implies": { "patch": true } } } ``` The following are rightly allowed by BFE (synonymous commands are grouped): _Is building and patching:_ - `build-tool` - `build-tool -⁠patch` - `build-tool -⁠patch=true` - `build-tool -⁠only-⁠patch=false` - `build-tool -⁠no-⁠only-⁠patch` _Is building and not patching:_ <!-- TODO: remove these "disable" toggles after fixing list-item-style bug --> <!-- lint disable list-item-style --> - `build-tool -⁠patch=false` - `build-tool -⁠no-⁠patch` - _`build-tool -⁠no-⁠patch -⁠no-⁠only-⁠patch`_ (this is the interesting one) <!-- lint enable list-item-style --> _Is patching and not building:_ - `build-tool -⁠only-⁠patch` - `build-tool -⁠only-⁠patch=true` - `build-tool -⁠patch -⁠only-⁠patch` On the other hand, the following rightly cause BFE to throw: - `build-tool -⁠patch=false -⁠only-⁠patch` - `build-tool -⁠no-⁠patch -⁠only-⁠patch` If BFE didn't ignore vacuous implications by default, the command `build-tool -⁠no-⁠patch -⁠no-⁠only-⁠patch` would erroneously cause BFE to throw since `implies: { patch: true }` means "any time `-⁠only-⁠patch` is given, set `{ patch: true }` in `argv`", which conflicts with `-⁠no-⁠patch` which already sets `{ patch: false }` in `argv`. This can be confusing for end users since the command, while redundant, technically makes sense; it is logically indistinguishable from `build-tool -⁠no-⁠only-patch`, which does not throw an error. To remedy this, BFE simply ignores the `implies` configurations of options when their argument value is strictly equal to `false` in `argv`. To disable this behavior for a specific option, set `vacuousImplications` to `true` (it is `false` by default) or consider using [`requires`][14]/[`conflicts`][15]/[`subOptionOf`][23] over `implies`. --- ##### `demandThisOptionIf` > ⪢ API reference: [`demandThisOptionIf`][35] > [!IMPORTANT] > > `demandThisOptionIf` is a superset of vanilla Yargs's [`demandOption`][32]. `demandThisOptionIf` enables checks to ensure an argument is given when at least one of the specified groups of arguments, or argument-value pairs, is also given. For example: <!-- example-region demandThisOptionIf-1-jsonc --> ```jsonc { "x": {}, "y": { "demandThisOptionIf": "x" }, // ◄ Demands y if x is given "z": { "demandThisOptionIf": "x" } // ◄ Demands z if x is given } ``` This configuration allows the following arguments: no arguments (`∅`), `-⁠y`, `-⁠z`, `-⁠yz`, `-⁠xyz`; and disallows: `-⁠x`, `-⁠xy`, `-⁠xz`. `demandThisOptionIf` also supports checks against the parsed _values_ of arguments in addition to the argument existence checks demonstrated above. For example: <!-- example-region demandThisOptionIf-2-jsonc --> ```jsonc { // ▼ Demands x if y == 'one' or z is given "x": { "demandThisOptionIf": [{ "y": "one" }, "z"] }, "y": {}, "z": {} } ``` This configuration allows the following arguments: no arguments (`∅`), `-⁠x`, `-⁠y=...`, `-⁠x -⁠y=...`, `-⁠xz`, `-⁠xz y=...`; and disallows: `-⁠z`, `-⁠y=one`, `-⁠y=... -⁠z`. Note that, when performing a check using the parsed value of an argument and that argument is configured as an array (`{ array: true }`), that array will be searched for said value. Otherwise, a strict deep equality check is performed. Also note that a more powerful implementation of `demandThisOptionIf` can be achieved via [`subOptionOf`][23]. --- ##### `demandThisOption` > ⪢ API reference: [`demandThisOption`][36] > [!IMPORTANT] > > `demandThisOption` is an alias of vanilla Yargs's [`demandOption`][32]. > `demandOption` is disallowed by intellisense. `demandThisOption` enables checks to ensure an argument is always given. This is equivalent to `demandOption` from vanilla Yargs. For example: <!-- example-region demandThisOption-1-jsonc --> ```jsonc { "x": { "demandThisOption": true }, // ◄ Disallows ∅, y "y": { "demandThisOption": false } } ``` This configuration will trigger a check to ensure that `-⁠x` is given. > [!NOTE] > > As an alias of vanilla Yargs's [`demandOption`][32], this check is outsourced > to Yargs, which means it runs on Black Flag's _first and second parsing > passes_ like any other configurations key coming from vanilla Yargs. --- ##### `demandThisOptionOr` > ⪢ API reference: [`demandThisOptionOr`][37] > [!IMPORTANT] > > `demandThisOptionOr` is a superset of vanilla Yargs's [`demandOption`][32]. `demandThisOptionOr` enables non-optional inclusive disjunction checks per group. Put another way, `demandThisOptionOr` enforces a "logical or" relation within groups of required options. For example: <!-- example-region demandThisOptionOr-1-jsonc --> ```jsonc { "x": { "demandThisOptionOr": ["y", "z"] }, // ◄ Demands x or y or z "y": { "demandThisOptionOr": ["x", "z"] }, // ◄ Mirrors the above (discarded) "z": { "demandThisOptionOr": ["x", "y"] } // ◄ Mirrors the above (discarded) } ``` This configuration will trigger a check to ensure _at least one_ of `x`, `y`, or `z` is given. In other words, this configuration allows the following arguments: `-⁠x`, `-⁠y`, `-⁠z`, `-⁠xy`, `-⁠xz`, `-⁠yz`, `-⁠xyz`; and disallows: no arguments (`∅`). In the interest of readability, consider mirroring the appropriate `demandThisOptionOr` configuration to the other relevant options, though this is not required (redundant groups are discarded). The previous example demonstrates proper mirroring. `demandThisOptionOr` also supports checks against the parsed _values_ of arguments in addition to the argument existence checks demonstrated above. For example: <!-- example-region demandThisOptionOr-2-jsonc --> ```jsonc { // ▼ Demands x or y == 'one' or z "x": { "demandThisOptionOr": [{ "y": "one" }, "z"] }, "y": {}, "z": {} } ``` This configuration allows the following arguments: `-⁠x`, `-⁠y=one`, `-⁠z`, `-⁠x -⁠y=...`, `-⁠xz`, `-⁠y=... -⁠z`, `-⁠xz -⁠y=...`; and disallows: no arguments (`∅`), `-⁠y=...`. Note that, when performing a check using the parsed value of an argument and that argument is configured as an array (`{ array: true }`), that array will be searched for said value. Otherwise, a strict deep equality check is performed. --- ##### `demandThisOptionXor` > ⪢ API reference: [`demandThisOptionXor`][38] > [!IMPORTANT] > > `demandThisOptionXor` is a superset of vanilla Yargs's [`demandOption`][32] + > [`conflicts`][29]. `demandThisOptionXor` enables non-optional exclusive disjunction checks per exclusivity group. Put another way, `demandThisOptionXor` enforces mutual exclusivity within groups of required options. For example: <!-- example-region demandThisOptionXor-1-jsonc --> ```jsonc { "x": { "demandThisOptionXor": ["y"] }, // ◄ Disallows ∅, z, w, xy, xyw, xyz, xyzw "y": { "demandThisOptionXor": ["x"] }, // ◄ Mirrors the above (discarded) "z": { "demandThisOptionXor": ["w"] }, // ◄ Disallows ∅, x, y, zw, xzw, yzw, xyzw "w": { "demandThisOptionXor": ["z"] } // ◄ Mirrors the above (discarded) } ``` This configuration will trigger a check to ensure _exactly one_ of `-⁠x` or `-⁠y` is given, and _exactly one_ of `-⁠z` or `-⁠w` is given. In other words, this configuration allows the following arguments: `-⁠xz`, `-⁠xw`, `-⁠yz`, `-⁠yw`; and disallows: no arguments (`∅`), `-⁠x`, `-⁠y`, `-⁠z`, `-⁠w`, `-⁠xy`, `-⁠zw`, `-⁠xyz`, `-⁠xyw`, `-⁠xzw`, `-⁠yzw`, `-⁠xyzw`. In the interest of readability, consider mirroring the appropriate `demandThisOptionXor` configuration to the other relevant options, though this is not required (redundant groups are discarded). The previous example demonstrates proper mirroring. `demandThisOptionXor` also supports checks against the parsed _values_ of arguments in addition to the argument existence checks demonstrated above. For example: <!-- example-region demandThisOptionXor-2-jsonc --> ```jsonc { // ▼ Demands x xor y == 'one' xor z "x": { "demandThisOptionXor": [{ "y": "one" }, "z"] }, "y": {}, "z": {} } ``` This configuration allows the following arguments: `-⁠x`, `-⁠y=one`, `-⁠z`, `-⁠x -⁠y=...`, `-⁠y=... -⁠z`; and disallows: no arguments (`∅`), `-⁠y=...`, `-⁠x -⁠y=one`, `-⁠xz`, `-⁠y=one -⁠z`, `-⁠xz -⁠y=...`. Note that, when performing a check using the parsed value of an argument and that argument is configured as an array (`{ array: true }`), that array will be searched for said value. Otherwise, a strict deep equality check is performed. --- ##### `check` > ⪢ API reference: [`check`][39] `check` is the declarative option-specific version of vanilla Yargs's [`yargs::check()`][40]. This function receives the `currentArgumentValue`, which you are free to type as you please, and the fully parsed `argv`. If this function throws, the exception will bubble. If this function returns an instance of `Error`, a string, or any non-truthy value (including `undefined` or not returning anything), Black Flag will throw a `CliError` on your behalf. All `check` functions are run in definition order and always at the very end of the [second parsing pass][11], well after all other BFE checks have passed and all updates to `argv` have been applied (including from [`subOptionOf`][23] and [BFE's `implies`][18]). This means `check` always sees the _final_ version of `argv`, which is the same version that the command's [`handler`][9] is passed. > [!IMPORTANT] > > `check` functions are skipped if their corresponding argument does not exist > in `argv`. When a check fails, execution of its command's [`handler`][9] function will cease and [`configureErrorHandlingEpilogue`][41] will be invoked (unless you threw/returned a [`GracefulEarlyExitError`][42]). For example: <!-- example-region check-1-js --> ```javascript export const [builder, withHandlerExtensions] = withBuilderExtensions({ x: { number: true, check: function (currentXArgValue, fullArgv) { if (currentXArgValue < 0 || currentXArgValue > 10) { throw new Error( `"x" must be between 0 and 10 (inclusive), saw: ${currentXArgValue}` ); } return true; } }, y: { boolean: true, default: false, requires: 'x', check: function (currentYArgValue, fullArgv) { if (currentYArgValue && fullArgv.x <= 5) { throw new Error( `"x" must be greater than 5 to use 'y', saw: ${fullArgv.x}` ); } return true; } } }); ``` You may also pass an array of check functions, each being executed after the other. This makes it easy to reuse checks between options. For example: > [!WARNING] > > Providing an array with one or more _async_ check functions will result in > them all being awaited concurrently. <!-- example-region check-2-js --> ```javascript export const [builder, withHandlerExtensions] = withBuilderExtensions({ x: { number: true, check: [checkArgBetween0And10('x'), checkArgGreaterThan5('x')] }, y: { number: true, check: checkArgBetween0And10('y') }, z: { number: true, check: checkArgGreaterThan5('z') } }); function checkArgBetween0And10(argName) { return function (argValue, fullArgv) { return ( (argValue >= 0 && argValue <= 10) || `"${argName}" must be between 0 and 10 (inclusive), saw: ${argValue}` ); }; } function checkArgGreaterThan5(argName) { return function (argValue, fullArgv) { return ( argValue > 5 || `"${argName}" must be greater than 5, saw: ${argValue}` ); }; } ``` See the Yargs documentation on [`yargs::check()`][40] for more information. --- ##### `subOptionOf` > ⪢ API reference: [`subOptionOf`][43] One of Black Flag's killer features is [native support for dynamic options][44]. However, taking advantage of this feature in a command's [`builder`][8] export requires a strictly imperative approach. Take, for example, [the `init` command from @black-flag/demo][45]: <!-- example-region subOptionOf-1-js --> ```javascript // Taken at 03/23/2025 from @black-flag/demo "myctl" CLI const PYTHON_DEFAULT_VERSION = '3.13'; const NODE_DEFAULT_VERSION = '23.3'; export function builder(yargs, _helpOrVersionSet, argv) { // Tell Yargs to leave strings that look like numbers as strings yargs.parserConfiguration({ 'parse-numbers': false }); // ▼ This imperative logic is a bit of an eyesore... if (argv?.lang === 'node') { return { lang: { choices: ['node'], default: 'node' }, version: { choices: ['20.18', '22.12', '23.3'], default: NODE_DEFAULT_VERSION } }; } else if (argv?.lang === 'python') { return { lang: { choices: ['python'], default: 'python' }, version: { choices: ['3.11', '3.12', '3.13'], default: PYTHON_DEFAULT_VERSION } }; } return { lang: { choices: ['node', 'python'], // ▼ Having to use a default description is a little suboptimal... defaultDescription: '"python"', // ▼ This imperative logic is a little ugly... default: argv ? 'python' : undefined }, version: { string: true, // ▼ Having to use a default description is a little suboptimal... defaultDescription: `"${PYTHON_DEFAULT_VERSION}"`, // ▼ This imperative logic is a little ugly... default: argv ? PYTHON_DEFAULT_VERSION : undefined } }; } export function handler(argv) { console.log(`> initializing new ${argv.lang}@${argv.version} project...`); // ... } ``` Taking advantage of dynamic options support like like we did above gifts your CLI with help text more meaningful than anything you could accomplish with vanilla Yargs. For example: ```text myctl init --lang node --version=23.3 > initializing new node@23.3 project... ``` ```text myctl init --lang python --version=23.3 Usage: myctl init Options: --help Show help text [boolean] --lang [choices: "python"] [default: "python"] --version [choices: "3.11", "3.12", "3.13"] [default: "3.13"] Invalid values: Argument: version, Given: "23.3", Choices: "3.10", "3.11", "3.12" ``` ```text myctl init --lang fake Usage: myctl init Options: --help Show help text [boolean] --lang [choices: "node", "python"] [default: "python"] --version [string] [default: "3.13"] Invalid values: Argument: lang, Given: "fake", Choices: "node", "python" ``` ```text myctl init --help Usage: myctl init Options: --help Show help text [boolean] --lang [choices: "node", "python"] [default: "python"] --version [string] [default: "3.13"] ``` ```text myctl init --lang node --help Usage: myctl init Options: --help Show help text [boolean] --lang [choices: "node"] [default: "node"] --version [choices: "20.18", "22.12", "23.3"] [default: "23.3"] ``` Ideally, Black Flag would allow us to describe the relationship between `-⁠-⁠lang` and its _suboption_ `-⁠-⁠version` declaratively, without having to drop down to imperative interactions with the Yargs API like we did above. This is the goal of the `subOptionOf` configuration key. **Using `subOptionOf`, developers can take advantage of dynamic options without sweating the implementation details.** > [!NOTE] > > `subOptionOf` updates are run and applied during Black Flag's [second parsing > pass][11]. For example: <!-- example-region subOptionOf-2-js --> ```javascript /** * @type {import('@black-flag/core').Configuration['builder']} */ export const [builder, withHandlerExtensions] = withBuilderExtensions({ x: { choices: ['a', 'b', 'c'], demandThisOption: true, description: 'A choice' }, y: { number: true, description: 'A number' }, z: { // ▼ These configurations are applied as the baseline or "fallback" during // Black Flag's first parsing pass. The updates within subOptionOf are // evaluated and applied during Black Flag's second parsing pass. boolean: true, description: 'A useful context-sensitive flag', subOptionOf: { // ▼ Ignored if x is not given x: [ { when: (currentXArgValue, fullArgv) => currentXArgValue === 'a', update: // ▼ We can pass an updater function that returns an opt object. // This object will *replace* the argument's old configuration! (oldXArgumentConfig, fullArgv) => { return { // ▼ We don't want to lose the old config, so we spread it ...oldXArgumentConfig, description: 'This is a switch specifically for the "a" choice' }; } }, { when: (currentXArgValue, fullArgv) => currentXArgValue !== 'a', update: // ▼ Or we can just pass the replacement configuration object. Note // that, upon multiple `when` matches, the last update in the // chain will win. If you want merge behavior instead of overwrite, // spread the old config in the object you return. { string: true, description: 'This former-flag now accepts a string instead' } } ], // ▼ Ignored if y is not given. If x and y ARE given, since this occurs // after the x config, this update will overwrite any others. Use the // functional form + object spread to preserve the old configuration. y: { when: (currentYArgValue, fullArgv) => fullArgv.x === 'a' && currentYArgValue > 5, update: (oldConfig, fullArgv) => { return { array: true, demandThisOption: true, description: 'This former-flag now accepts an array of two or more strings', check: function (currentZArgValue, fullArgv) { return ( currentZArgValue.length >= 2 || `"z" must be an array of two or more strings, only saw: ${currentZArgValue.length ?? 0}` ); } }; } }, // ▼ Since "does-not-exist" is not an option defined anywhere, this will // always be ignored 'does-not-exist': [] } } }); ``` > [!IMPORTANT] > > You cannot nest `subOptionOf` keys within each other nor return an object > containing `subOptionOf` from an `update` that did not already have one. Doing > so will trigger a framework error. Now we're ready to re-implement the `init` command from `myctl` using our new declarative superpowers: <!-- example-region subOptionOf-3-js --> ```javascript export const [builder, withHandlerExtensions] = withBuilderExtensions( function (blackFlag) { blackFlag.parserConfiguration({ 'parse-numbers': false }); return { lang: { // ▼ These two are fallback or "baseline" configurations for --lang choices: ['node', 'python'], default: 'python', subOptionOf: { // ▼ Yep, --lang is also a suboption of --lang lang: [ { when: (lang) => lang === 'node', // ▼ Remember: updates completely overwrite baseline config... update: { choices: ['node'], default: 'node' } }, { when: (lang) => lang !== 'node', // ▼ ... though we can still reuse the "old" baseline config update(oldOptionConfig) { return { ...oldOptionConfig, choices: ['python'] }; } } ] } }, version: { // ▼ These two are fallback or "baseline" configurations for --version string: true, default: '3.13', subOptionOf: { // ▼ --version is a suboption of --lang lang: [ { when: (lang) => lang === 'node', update: { choices: ['20.18', '22.12', '23.3'], default: '23.3' } }, { when: (lang) => lang !== 'node', update(oldOptionConfig) { return { ...oldOptionConfig, choices: ['3.11', '3.12', '3.13'] }; } } ] } } }; } ); ``` Easy peasy! Another benefit of `subOptionOf`: all configuration relevant to an option is co-located within that option and not spread across some function or file. We don't have to go looking for the logic that's modifying `--version` since it's all right there in one code block. We also don't have to repeat ourselves or pass around `X_DEFAULT_VERSION` variables to hold defaults anymore! See [the examples directory][46] for more `subOptionOf` demonstrations, including a fleshed out version of `myctl` implemented using BFE. #### Support for `default` with `conflicts`/`requires`/etc BFE (and, consequently, BF/Yargs when not generating help text) will ignore the existence of the [`default`][12] key until near the end of BFE's execution. > [!IMPORTANT] > > This means the optional `customBuilder` function passed to > `withBuilderExtensions` will _not_ see any defaulted values. However, your > command handlers will. > [!WARNING] > > An explicitly `undefined` default, i.e. `{ default: undefined }`, will be > deleted from the configuration object by BFE and completely ignored by Black > Flag and Yargs. This differs from BF/Yargs's default behavior, which is to > recognize `undefined` defaults. Defaults are set _before_ any [`check`][13] functions are run, _before_ any [implications][18] are set, and _before_ the relevant command [`handler`][9] is invoked, but _after_ all other BFE checks have succeeded. This enables the use of keys like [`requires`][14] and [`conflicts`][15] alongside [`default`][12] without causing [impossible configurations][47] that throw unresolvable CLI errors. This workaround avoids a (in my opinion) rather unintuitive [Yargs footgun][16], though there are decent arguments in support of vanilla Yargs's behavior. #### Strange and Impossible Configurations Note that **there are no sanity checks performed to prevent options configurations that are unresolvable**, so care must be taken not to ask for something insane. For example, the following configurations are impossible to resolve: ```jsonc { "x": { "requires": "y" }, "y": { "conflicts": "x" } } ``` ```jsonc { "x": { "requires": "y", "demandThisOptionXor": "y" }, "y": {} } ``` Similarly, silly configurations like the following, while typically resolvable, are strange and may not work as expected: ```jsonc { "x": { "requires": "x", "demandThisOptionXor": "x" } } ``` ```jsonc { "x": { "implies": { "x": 5 } } } ``` #### Automatic Grouping of Related Options > [!CAUTION] > > To support this functionality, options must be described declaratively. > [Defining options imperatively][4] will break this feature. BFE supports automatic [grouping][48] of related options for improved UX, which is enabled by default. These new groups are: - **"Required Options"**: options configured with [`demandThisOption`][20]. - **"Required Options (at least one)"**: options configured with [`demandThisOptionOr`][21]. - **"Required Options (mutually exclusive)"**: options configured with [`demandThisOptionXor`][22]. - **"Common Options"**: options provided via `{ commonOptions: [...] }` to `withBuilderExtensions` as its second parameter: `withBuilderExtensions({/*...*/}, { commonOptions });` - **"Optional Options"**: remaining options that do not fall into any of the above categories. An example from [xunnctl][49]: ```text $ x f b --help Usage: xunnctl firewall ban Add an IP from the global hostile IP list. Required Options: --ip An ipv4, ipv6, or supported CIDR [array] Optional Options: --comment Include custom text with the ban comment where applicable [string] Common Options: --help Show help text [boolean] --hush Set output to be somewhat less verbose [boolean] [default: false] --quiet Set output to be dramatically less verbose (implies --hush) [boolean] [default: false] --silent No output will be generated (implies --quiet) [boolean] [default: false] --config-path Use a custom configuration file [string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"] ``` ```text $ x d z u --help Usage: xunnctl dns zone update Reinitialize a DNS zones. Required Options (at least one): --apex Zero or more zone apex domains [array] --apex-all-known Include all known zone apex domains [boolean] Optional Options: --force Disable protections [boolean] --purge-first Delete pertinent records on the zone before recreating them [boolean] Common Options: --help Show help text [boolean] --hush Set output to be somewhat less verbose [boolean] [default: false] --quiet Set output to be dramatically less verbose (implies --hush) [boolean] [default: false] --silent No output will be generated (implies --quiet) [boolean] [default: false] --config-path Use a custom configuration file [string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"] ``` By including an explicit [`group`][48] property in an option's configuration, the option will be included in said group _in addition to_ the result of automatic grouping, e.g.: ```typescript const [builder, withHandlerExtensions] = withBuilderExtensions({ 'my-option': { boolean: true, description: 'mine', default: true, // This option will be placed into the "Custom Grouped Options" group AND // ALSO the "Common Options" group IF it's included in `commonOptions` group: 'Custom Grouped Options' } }); ``` > [!NOTE] > > Options configured with an explicit [`group`][48] property will never be > automatically included in the "Optional Options" group. This feature can be disabled entirely by passing `{ disableAutomaticGrouping: true }` to `withBuilderExtensions` as its second parameter: ```typescript const [builder, withHandlerExtensions] = withBuilderExtensions( { // ... }, { disableAutomaticGrouping: true } ); ``` #### Automatic Sorting of Options > [!CAUTION] > > To support this functionality, options must be described declaratively. > [Defining options imperatively][4] will break this feature. BFE supports automatic alpha-sorting of a command's options in help text for improved UX, similar to how Black Flag sorts commands themselves in help text. This feature is disabled by default, but can be enabled by passing `{ enableAutomaticSorting: true }` to `withBuilderExtensions` as its second parameter: ```typescript const [builder, withHandlerExtensions] = withBuilderExtensions( { // ... }, { enableAutomaticSorting: true } ); ``` ### `withUsageExtensions` > ⪢ API reference: [`withUsageExtensions`][50] This thin wrapper function is used for more consistent and opinionated usage string generation. <!-- example-region wue-1-js --> ```javascript // file: xunnctl/commands/firewall/ban.js return { // ... description: 'Add an IP from the global hostile IP list', usage: withUsageExtensions( "$1.\n\nAdditional description text that only appears in this command's help text." ) }; ``` ```text $ x f b --help Usage: xunnctl firewall ban Add an IP from the global hostile IP list. Additional description text that only appears in this command's help text. Required Options: --ip An ipv4, ipv6, or supported CIDR [array] Optional Options: --comment Include custom text with the ban comment where applicable [string] Common Options: --help Show help text [boolean] --hush Set output to be somewhat less verbose [boolean] [default: false] --quiet Set output to be dramatically less verbose (implies --hush) [boolean] [default: false] --silent No output will be generated (implies --quiet) [boolean] [default: false] --config-path Use a custom configuration file [string] [default: "/home/freelance/.config/xunnctl-nodejs/state.json"] ``` ### `getInvocableExtendedHandler` > ⪢ API reference: [`getInvocableExtendedHandler`][51] Unlike Black Flag, BFE puts strict constraints on the order in which command exports must be invoked and evaluated. Specifically: an extended command's [`builder`][8] export must be invoked twice, with the correct parameters each time, before that extended command's [`handler`][9] can be invoked. This can make it especially cumbersome to import an extended command from a file and then invoke its [`handler`][9], which is dead simple for normal Black Flag commands, and can introduce transitive tight-couplings between commands, which makes bugs more likely and harder to spot. `getInvocableExtendedHandler` solves this by returning a version of the extended command's [`handler`][9] function that is ready to invoke immediately. Said [`handler`][9] expects a single `argv` parameter which is "safely" cloned, merged with several defaults (see [API reference][51]), and then passed-through to your command's handler as-is. One of those defaults is the value of the `context` parameter that was supplied to `getInvocableExtendedHandler`. Similar to `argv`, `context` will be "safely" cloned. > [!TIP] > > A "safe" clone is a [StructuredClone-like operation][52] that passes through > as-is any values that cannot be cloned rather than throwing an error. Since > BFE is leveraging [`safeDeepClone`][52] under the hood, all clone operations > can be tweaked by configuring [`context.state.extensions.transfer`][53] > appropriately (where `context` is the [ExecutionContext][54] instance passed > to `getInvocableExtendedHandler`). > > Setting `context.state.extensions.transfer` is useful when, for instance, you > have an object stored in `context` that should not be deep cloned but passed > through as-is instead. > [!TIP] > > Command [`handler`][9] exports invoked via `getInvocableExtendedHandler` will > receive an `argv` containing the [`$artificiallyInvoked`][55] symbol. This > allows handlers to determine programmatically when the command isn't actually > being invoked by Black Flag, which can b