Skip to content

Latest commit

 

History

History
686 lines (509 loc) · 19.1 KB

getting-started.md

File metadata and controls

686 lines (509 loc) · 19.1 KB

Black Flag: Getting Started

What follows is a simple step-by-step guide for building, running, and testing a brand new CLI tool from scratch. We shall call our new invention myctl.

Tip

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.

This guide is split into two main sections:


Building and Running Your CLI

Let's make a new CLI project!

Tip

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
CJS example
echo '{"name":"myctl","version":"1.0.0","type":"commonjs","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'));
CJS 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, it lives at the apex of our commands directory, and it's always named index.js (or index.mjs, index.cjs, index.ts, index.mts, index.cts). 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), all discoverable command files support a subset of the following extensions in precedence order: .js, .mjs, .cjs, .ts (but not .d.ts), .mts, .cts. To keep things simple, we'll be using ES modules as .js files; note the "type" property 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 subcommands or (2) output help text for the command if said command has one or more subcommands.

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('short')
  • yargs::usage(defaultUsageText)
    • Defaults to this.
    • Note that, as of [email protected], calling yargs::usage("...") multiple times (such as in configureExecutionPrologue) will concatenate each invocation's arguments into one long usage string (delimited by newlines). To work around this for "short" versus "full" help text output, we use yargs::usage(null) to reset the current usage text.
  • 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:

// These @type comments give us intellisense support, but they're optional 🙂
// See ./commands/remote/add.js for my preferred export syntax/style.

/**
 * @type {Extract<import('@black-flag/core').Configuration['builder'], Function>}
 */
export function builder(blackFlag) {
  return blackFlag.strict(false);
}

/**
 * @type {Extract<import('@black-flag/core').RootConfiguration['handler'], Function>}
 */
export function handler(argv) {
  console.log('Ran root command handler');
}

/**
 * Note that `usage` is just a freeform string used in help text. The `command`
 * export, on the other hand, supports the Yargs DSL for defining positional
 * parameters and the like.
 *
 * @type {import('@black-flag/core').RootConfiguration['usage']}
 */
export const usage = 'Usage: $0 command [options]\n\nCustom description here.';
CJS example
module.exports = {
  builder: function (blackFlag) {
    return blackFlag.strict(false);
  },

  handler: function (argv) {
    console.log('Ran root command handler');
  },

  usage: 'Usage: $0 command [options]\n\nCustom description here.'
};

Tip

The Yargs DSL for declaring and defining positional parameters is described here.

Tip

Looking for more in-depth examples? Check out examples/ for a collection of recipes solving all sorts of common CLI tasks using Black Flag, including leveraging TypeScript and reviewing various different ways to define command modules.

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

Well, that was easy. Let's run our CLI now:

./cli.js --help
Usage: myctl command [options]

Custom description here.

Commands:
  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 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 natural 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.

Tip

Black Flag offers a stronger sorting guarantee than yargs::parserConfiguration({ 'sort-commands': true }).

Now let's try a grandchild command:

./cli.js remote show --help
Usage: myctl remote show

Options:
  --help  Show help text                                               [boolean]

Phew. Alright, but what about trying some commands we know don't exist?

./cli.js remote bad horrible
Usage: myctl remote

Commands:
  myctl remote add
  myctl remote remove
  myctl remote show

Options:
  --help  Show help text                                               [boolean]

Invalid subcommand: you must call this with a valid subcommand 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.

Note

Each invocation of runProgram()/makeRunner()() configures and runs your entire CLI from scratch. Other than stuff like the require cache, there is no shared state between invocations unless you explicitly make it so. This makes testing your commands "in isolation" dead simple and avoids a common Yargs footgun.

const { makeRunner } = require('@black-flag/core/util');

// makeRunner is a factory function that returns runProgram functions with
// curried arguments.
const run = makeRunner({ commandModulesPath: `${__dirname}/commands` });

afterEach(() => {
  // Since runProgram (i.e. what is returned by makeRunner) sets
  // process.exitCode before returning, let's unset it after each test
  process.exitCode = undefined;
});

describe('myctl (root)', () => {
  it('emits expected output when called with no arguments', async () => {
    expect.hasAssertions();

    const logSpy = jest
      .spyOn(console, 'log')
      .mockImplementation(() => undefined);

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    await run();

    expect(errorSpy).not.toHaveBeenCalled();
    expect(logSpy.mock.calls).toStrictEqual([['Ran root command handler']]);
  });

  it('emits expected output when called with unknown arguments', async () => {
    expect.hasAssertions();

    const logSpy = jest
      .spyOn(console, 'log')
      .mockImplementation(() => undefined);

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    await run('--unknown');
    await run('unknown');

    expect(errorSpy).not.toHaveBeenCalled();
    expect(logSpy.mock.calls).toStrictEqual([
      ['Ran root command handler'],
      ['Ran root command handler']
    ]);
  });

  it('still terminates with 0 exit code when called with unknown arguments', async () => {
    expect.hasAssertions();

    await run('--unknown-argument');

    expect(process.exitCode).toBe(0);
  });
});
CJS example
const { makeRunner } = require('@black-flag/core/util');

// makeRunner is a factory function that returns runProgram functions with
// curried arguments.
const run = makeRunner({ commandModulesPath: `${__dirname}/commands` });

afterEach(() => {
  // Since runProgram (i.e. what is returned by makeRunner) sets
  // process.exitCode before returning, let's unset it after each test
  process.exitCode = undefined;
});

describe('myctl (root)', () => {
  it('emits expected output when called with no arguments', async () => {
    expect.hasAssertions();

    const logSpy = jest
      .spyOn(console, 'log')
      .mockImplementation(() => undefined);

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    await run();

    expect(errorSpy).not.toHaveBeenCalled();
    expect(logSpy.mock.calls).toStrictEqual([['Ran root command handler']]);
  });

  it('emits expected output when called with unknown arguments', async () => {
    expect.hasAssertions();

    const logSpy = jest
      .spyOn(console, 'log')
      .mockImplementation(() => undefined);

    const errorSpy = jest
      .spyOn(console, 'error')
      .mockImplementation(() => undefined);

    await run('--unknown');
    await run('unknown');

    expect(errorSpy).not.toHaveBeenCalled();
    expect(logSpy.mock.calls).toStrictEqual([
      ['Ran root command handler'],
      ['Ran root command handler']
    ]);
  });

  it('still terminates with 0 exit code when called with unknown arguments', async () => {
    expect.hasAssertions();

    await run('--unknown-argument');

    expect(process.exitCode).toBe(0);
  });
});

Tip

In our tests above, we took a behavior-driven approach and tested for errors by looking at what console.error should be outputting. This is how users of our CLI will experience errors too, making this the ideal testing approach in many cases. runProgram/makeRunner are configured for this approach out of the box in that they never throw/reject, even when an error occurs. Instead, they trigger the configured error handling behavior (which defaults to console.error), which is what our tests check for.

However, in many other cases, a purely test-driven approach is required, where we're not so interested in what the user should experience but in the nature of the failure itself. To support this, makeRunner supports the errorHandlingBehavior option. Setting errorHandlingBehavior to "throw" will cause your curried runner functions to throw/reject in addition to triggering the configured error handling behavior.

It's up to you to choose which approach is best.

Finally, let's run our tests:

NODE_OPTIONS='--no-warnings --experimental-vm-modules' npx jest --testMatch '**/test.cjs' --restoreMocks
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.

Important

As of March 2025, we need to use NODE_OPTIONS='--experimental-vm-modules' until the Node team unflags virtual machine module support in a future version.

Tip

We use --restoreMocks to ensure mock state doesn't leak between tests. We use --testMatch '**/test.cjs' to make Jest see our CJS files.

Neat! 📸