CLIs

Tooling

A type-safe and interactive command line program, powered by React and Ink. We also encourage the Command Line Interface Guidelines for building better programs.

The CLI provides a simple object oriented framework for building and managing command line programs, with clear separation of commands, middleware, args, a program instance, and more. It further streamlines the development process by utilizing Ink for terminal rendering, as manually handling ANSI escape sequences and terminal widths can be tedious and complicated.

The CLI makes heavy usage of the args package, which will be continually referenced throughout this documentation. It's encouraged to read and understand it first.

Installation#

yarn add @boost/cli react ink

Environment variables#

  • BOOSTJS_CLI_THEME (string) - Name of the theme module (without @boost or boost prefixes) to load for terminal style/color changes.

Events#

EventArgumentsDescription
Command#onAfterRegister, Program#onAfterRegisterpath: CommandPath, command: CommadableCalled after a command has been registered.
Command#onBeforeRegister, Program#onBeforeRegisterpath: CommandPath, command: CommadableCalled before a command has been registered.
Program#onAfterRenderCalled after a component has rendered.
Program#onAfterRunerror?: ErrorCalled after the program and command have been ran.
Program#onBeforeRenderresult: RunResultCalled after a command has run but before a component will render.
Program#onBeforeRunargv: ArgvCalled before the program and command will run.
Program#onCommandFoundargv: Argv, path: CommandPath, command: CommadableCalled when a command has been found after parsing argv.
Program#onCommandNotFoundargv: Argv, path: CommandPathCalled when a command wasn't found after parsing argv.
Program#onExitmessage: string, code: numberCalled when the exit() handler is executed but before the process exits.
Program#onHelpCommandPath?Called when the help menu is rendered.

Program#

The entry point of the command line is commonly referred to as the binary, or script, and is managed by the Program class. This class handles the registration of commands, applying middleware to argv (process.argv), parsing argv into arguments (options, flags, etc), running the found command with these argument, outputing to the terminal, and finally cleaning up or handling failures.

Begin by importing and instantiating the Program class, while passing required and optional options.

  • banner (string) - A large banner to appear at the top of the index help interface.
  • bin (string) - The name of the binary consumers enter on the command line. Must be in kebab-case. (Required)
  • delimiter (string) - The character(s) displayed before command line usage examples.
  • footer (string) - A string of text to display at the bottom of the index help interface.
  • header (string) - A string of text to display at the top of the index help interface, below the banner (if present).
  • name (string) - A human readable name for your program. (Required)
  • version (string) - Current version of your CLI program. Typically the version found in your package.json. This is output when --version is passed. (Required)
import { Program } from '@boost/cli';
import pkg from './package.json';
const program = new Program({
bin: 'boost',
footer: 'Documentation: https://boostlib.dev',
name: 'Boost Examples',
version: pkg.version,
});

Program example

Once commands and optional middleware have been registered, you must run the program with Program#run() or Program#runAndExit(), with the latter automatically passing the exit code to process.exitCode. Both methods require an argv list to run.

program.runAndExit(process.argv);

If you have any logic that should be bootstrapped before the program runs, and you would like to inherit the error handling of the CLI, you may pass an async-aware function as a 2nd argument when running.

program.runAndExit(process.argv, async () => {
// CLI code to bootstrap before running
await bootstrapCli();
});

Package integration#

Now that you have the basics of a program, you can set the bin field in your package.json. This should point to the program-aware file you have defined previously. For example, if my program will be called boost.

{
"bin": {
"boost": "./bin/boost.js"
}
}

If you're writing your program in TypeScript, or non-Node compatible JavaScript, you'll need to down-level compile before releasing your package. A simple alternative approach is to point your binary file to where the compiled program would be found.

bin/boost.js
#!/usr/bin/env node
require('../lib/program.js');

Stand-alone#

Boost offers 2 implementations for how the binary can be executed, the 1st is known as a stand-alone program. This implementation only supports 1 command known as the default command, which is immediately ran when the binary is. It does not support sub-commands.

To create a stand-alone binary, create and instantiate a command, then pass it to Command#default(). The command's path is ignored for this situation.

import { Program } from '@boost/cli';
import StandAloneCommand from './commands/StandAloneCommand';
const program = new Program({
// ...
});
program.default(new StandAloneCommand()).runAndExit(process.argv);

Some good examples of stand-alone binaries are babel, webpack, and tsc.

Multi-command#

The 2nd implementation is opposite the stand-alone program, and is known as a multi-command program. When the binary is ran, and no arguments are passed, a help menu is displayed instead of executing the default command. Otherwise, if arguments are passed, a registered command will be ran based on matching path name.

To create a multi-command binary, create and instantiate multiple commands, and pass them all to Command#register(). In the example below, the boost binary would support the boost install, boost uninstall, and boost build commands.

import { Program } from '@boost/cli';
import InstallCommand from './commands/InstallCommand';
import UninstallCommand from './commands/UninstallCommand';
import BuildCommand from './commands/BuildCommand';
const program = new Program({
// ...
});
program
.register(new InstallCommand())
.register(new UninstallCommand())
.register(new BuildCommand())
.runAndExit(process.argv);

Some good examples of stand-alone binaries are npm, yarn, and docker.

Middleware#

Boost will parse provided argv (a list of string arguments, typically from process.argv) into args (an object of options, flags, params, etc) for easier consumption. This process can be intercepted with Program#middleware(), which allows both argv and args to be read and mutated.

Middleware is a function, that receives the argv list as the 1st argument, and a parse callback as the 2nd argument. It must return an args object, which can be built by executing the parse callback. This allows both before, middle, and after implementations to be possible, as demonstrated below.

import { Program, Middleware } from '@boost/cli';
const example: Middleware = (argv, parse) => {
if (argv.includes('--help')) {
argv.push('--all');
}
return parse();
};
const program = new Program({
// ...
});
program
// Function reference
.middleware(example)
// Inline async function
.middleware(async (argv, parse) => {
const args = await parse();
args.options.flag = true;
return args;
})
.runAndExit(process.argv);

Middleware is async, so the parse callback must be awaited! This also means you can implement your own async functionality, like file system access, or network requests.

Built-in#

Boost provides the following built-in middleware for common scenarios.

  • checkNodeRequirement - Verify that the currently running Node.js process.version satisfies the given semver range. If not, a console error will be logged, or an error will be thrown.
  • checkPackageOutdated - Verify that a package and its provided version is using the latest distribution version by checking against the NPM registry. If not, a console message will be logged.
import { checkNodeRequirement, checkPackageOutdated } from '@boost/cli';
program
// Log a message
.middleware(checkPackageOutdated('@boost/cli', require('@boost/cli/package.json').version))
// Log an error
.middleware(checkNodeRequirement('>=12.10.0'))
// Throw an error
.middleware(checkNodeRequirement('>=12.10.0', true));

Commands#

Commands are self-encapsulated pieces of business logic that are ran when a matching path (a unique argument) is found on the command line. To create a command, import and extend the abstract Command class, and implement a run() method. This method can be async and even render React components!

import { Command } from '@boost/cli';
export default class BuildCommand extends Command {
async run() {
this.log('Starting process...');
await runHeavyAsyncProcess();
this.log('Process finished!');
}
}

However, that's not all required, as a command and it's features must be configured! Features may be defined with a declarative approach using TypeScript decorators, or an imperative approach with static class properties. Both variants will be demonstrated in the examples below.

Config#

All commands support the following metadata configuration, with path and description being mandatory.

  • aliases (string[]) - A list of aliased paths. Will not show up in the help menu, but will match on the command line.
  • allowUnknownOptions (boolean) - Allow unknown options to be parsed, otherwise an error is thrown. Defaults to false.
  • allowVariadicParams (boolean) - Allow variadic params to be parsed, otherwise an error is thrown. Defaults to false.
  • categories (object) - A mapping of sub-command and option categories for this command only. Global options are automatically defined under the global category.
  • category (string) - The category this command belongs to. Will be used to group in the parent command or program. Defaults to no category.
  • deprecated (boolean) - Whether the command is deprecated or not. Will display a tag in the help menu. Defaults to false.
  • description (string) - A description of what the command is and does. Supports basic markdown for bold, italics, and underline. (Required)
  • hidden (boolean) - Whether the command should be hidden from the help menu or not. Will still match on the command line. Defaults to false.
  • path (string) - A unique name in which to match the command on the command line amongst a list of arguments (argv). (Required)
  • usage (string | string[]) - Define one or many usage examples to display in the help menu.

When using decorators, import the Config decorator and apply it to the Command class. The path and description are required, while all other metadata can be passed as an object. Otherwise, just define static class properties of the same name!

import { Command, Config } from '@boost/cli';
@Config('build', 'Build a project', {
aliases: ['make'],
deprecated: true,
})
export default class BuildCommand extends Command {}

Command example

Options#

Options are optional arguments that accept a value on the command line. When a command is ran, each option is set as a class property based on the matching command line value, or the provided default value. Like configuration above, options can be defined declaratively or imperatively, with option types being passed to the 1st Command generic slot.

Declarative: There are 5 Arg decorators to choose from when defining options, all of which are defined on a class property, where the property name becomes the option name. For example, a property of save would become the --save option. Depending on the decorator, they support many option settings, excluding type and description, which are inferred, and default which comes from the property value.

Imperative: If you prefer to use static properties, all options are defined through the single static options property, which requires a mapping of option names to option settings. With this approach, type and description are required, with default either being configured with a setting, or coming from the class property value. For easier type safety, the Options collection type can be used to type the static property.

import { Command, Arg, GlobalOptions } from '@boost/cli';
interface CustomOptions extends GlobalOptions {
flag: boolean;
number: number;
numbers: number[];
string: string;
strings: string[];
}
export default class CustomCommand extends Command<CustomOptions> {
@Arg.Flag('Boolean flag')
flag: boolean = false;
@Arg.Number('Single number', { count: true, short: 'N' })
number: number = 0;
@Arg.Numbers('List of numbers', { deprecated: true })
numbers: number[] = [];
@Arg.String('Single string', { choices: ['a', 'b', 'c'], hidden: true })
string: string = '';
@Arg.Strings('List of strings', { arity: 5, short: 'S' })
strings: string[] = [];
run() {
const { flag, strings } = this;
// ...
}
}

Options example

Unknown options#

By default, unknown options are not allowed and will throw an error. To allow, set the allowUnknownOptions configuration setting to true. When enabled, all unknown options will be set as a string object to the Command#unknown class property.

import { Command, GlobalOptions, Config } from '@boost/cli';
@Config('custom', 'Example', { allowUnknownOptions: true })
export default class CustomCommand extends Command<GlobalOptions> {
run() {
const { foo, bar } = this.unknown;
// ...
}
}

Unknown option example

Global options#

Boost provides the follow options that are always available to all commands.

  • --help, -h (boolean) - Displays a help menu for the chosen command or the program itself. Available under the help class property.
  • --locale (string) - Display errors, messages, and the interface in the chosen locale (if supported). Locale must be in the format of "en" or "en-US". Available under the locale class property.
  • --version, -v (boolean) - Display the current program version and exit. Available under the version class property.

Params#

Params are command line values that will be passed to Command#run() as arguments. When defining params, all param settings are supported, and required are mandatory. Param types are passed to the 2nd Command generic slot.

Declarative: When using decorators, the Arg.Params decorator must be defined on the Command#run() method. It accepts an argument for each param you want to configure, in the order they should be expected.

Imperative: If you prefer to use static properties, all params are defined through the single static params property, which requires an array of param settings. For easier type safety, the Params collection type can be used to type the static property.

import { Command, Arg, GlobalOptions } from '@boost/cli';
type CustomParams = [string, number, boolean];
export default class CustomCommand extends Command<GlobalOptions, CustomParams> {
@Arg.Params<CustomParams>(
{
description: 'String',
label: 'name',
required: true,
type: 'string',
},
{
default: 18,
description: 'Number',
label: 'age',
type: 'number',
},
{
description: 'Boolean',
label: 'active',
type: 'boolean',
},
)
run(name: string, age: number, active: boolean) {
// ...
}
}

Params example

Variadic params#

By default, variadic params are not enabled and will throw an error when an unconfigured param is found. To allow, set the allowVariadicParams configuration setting to true. When enabled, all extra params will spread onto the end of the Command#run() method as strings.

Using the example above, it would look like the following.

import { Command, Config, Arg, GlobalOptions } from '@boost/cli';
type CustomParams = [string, number];
@Config('custom', 'Example', { allowVariadicParams: true })
export default class CustomCommand extends Command<GlobalOptions, CustomParams> {
@Arg.Params<CustomParams>([
// ...
])
run(name: string, age: number, ...params: string[]) {
// ...
}
}

Variadic params example

Rest args#

Rest arguments are all arguments that come after a standalone -- delimiter, and can be accessed using the Command#rest property, which is an array of strings.

Sub-commands#

Of course commands can register their own commands, known as sub-commands -- it's commands all the way down! Sub-commands are configured exactly the same, with the key difference being that their path must be prefixed with their parent command's path, separated by a colon.

For example, say we have a scaffolding command, where each sub-command is the specific template to generate. The parent path would be scaffold, where a child would be scaffold:model, scaffond:controller, so on and so forth. You can see this in action below.

import { Command, Config } from '@boost/cli';
@Config('scaffold:controller', 'Scaffold a controller')
class ScaffoldControllerCommand extends Command {}
@Config('scaffold:model', 'Scaffold a model')
class ScaffoldModelCommand extends Command {}
@Config('scaffold', 'Scaffold a template')
class ScaffoldCommand extends Command {
constructor() {
super();
this.register(new ScaffoldControllerCommand());
this.register(new ScaffoldModelCommand());
}
}

Sub-commands can now be executed on the command line by passing their full path, like so: boost scaffold:model --name User.

Sub-commands example

Rendering components#

This chapter assumes you have knowledge of React, JSX/TSX, and Ink. If you do not, it's highly encouraged to study those topics, but building CLIs with React is not necessarily a requirement as you can use logging instead.

With that being said, components can be rendered from a command by returning a React element from Command#run(), or by calling the Command#render() method. For a quick demonstration, let's implement a component that writes to a file asynchronously.

import fs from 'fs';
import React, { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { Style, useProgram } from '@boost/cli';
interface WriteConfigProps {
data: object;
path: string;
}
export default function WriteConfig({ data, path }: WriteConfigProps) {
const { exit } = useProgram();
const [loading, setLoading] = useState(true);
useEffect(() => {
fs.promises
.writeFile(path, JSON.stringify(data), 'utf8')
.then(() => {
setLoading(false);
})
.catch((error) => {
exit(error);
});
}, [path]);
if (loading) {
return (
<Box>
<Text>Writing config file...</Text>
</Box>
);
}
return (
<Box>
<Text>
Wrote config to file <Style type="success">{path}</Style>
</Text>
</Box>
);
}

Then we implement the command that returns and renders the component.

import { Command, Config, Arg } from '@boost/cli';
import WriteConfig from './components/WriteConfig';
@Config('config', 'Manage configuration files')
export default class ConfigCommand extends Command {
@Arg.Params({
description: 'Path to file',
type: 'string',
required: true,
})
async run(path: string) {
const data = await loadConfigFromSomeSource();
await this.render(<WriteConfig data={data} path={path} />);
// Or...
return <WriteConfig data={data} path={path} />;
}
}

Using the render() method allows for multiple and or different components to be rendered within the same run cycle. Returning a component will only use 1. However, both can be used together!

Shorthand registration#

Sometimes classes may be overkill for commands, so Boost offers a feature known as shorthand commands, where only objects and functions are used. To utilize shorthand commands, call either the Program#register() or Command#register() methods, with a unique path, settings object (config, options, params, etc)), and run function.

import { Program, TaskContext } from '@boost/cli';
interface BuildOptions {
minify: boolean;
}
type BuildParams = [string];
program.register<BuildOptions, BuildParams>(
'build',
{
description: 'Build a project',
options: {
minify: { description: 'Minify source files', type: 'boolean' },
},
params: [
{ description: 'Name of project', label: 'name', type: 'string' }
]
},
function build(this: TaskContext, options: BuildOptions, params: BuildParams, rest: string[]) => {
// ...
},
);

Besides a different API, the arguments to the run function are also different. Instead of spread params like in a class, they are an options object, list of params, and list of rest arguments respectively.

If you want to access the logger, be sure to use a function declaration instead of an anonymous function, so that context binding works correctly!

Tasks#

Tasks are reusable functions that can be executed within any command, while gaining contextual and limited access to that command's instance. This promotes reusability and composition while avoiding inheritance related issues.

To use a task, create a function with any arguments and function body that you'd like. The function body has access to the parent command's options, logger, and rest arguments through this. If using TypeScript, the this special argument should be typed.

import fs from 'fs';
import { TaskContext } from '@boost/cli';
// Write a JSON blob to a file defined by a --path option
export default async function writeJson(
this: TaskContext<{ path: string }>,
data: unknown,
pretty: boolean = false,
) {
await fs.promises.writeFile(
this.path,
pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data),
'utf8',
);
this.log('Wrote file to %s', this.path);
}

Now that are task is created, we can now execute it within a command using Command#runTask(). This method requires the task as a function reference, and all it's required arguments.

import { Command, Config, Arg } from '@boost/cli';
import writeJson from './tasks/writeJson';
@Config('init', 'Initialize project')
export default class InitCommand extends Command {
@Arg.String('Path to config file')
path: string;
async run() {
const data = await loadConfigFromSomeSource();
await this.runTask(writeJson, data);
}
}

Tasks are a command only feature and cannot be executed from within a React component.

Categories#

Categories are a mechanism for grouping commands and options in the help menu for easier readability. They're shared between both commands and options for interoperability purposes, and can be defined globally with Program#categories(), or per command through categories configuration. To make use of categories, define a mapping of keys to category names and optional weights, like so.

program.categories({
// Explicit weight
cache: {
name: 'Caching',
weight: 60,
},
// Automatic weight
error: 'Error handling',
});
import { Command, Config } from '@boost/cli';
@Config('custom', 'Example', {
categories: {
cache: {
name: 'Caching',
weight: 60,
},
error: 'Error handling',
},
})
export default class CustomCommand extends Command {
// ...
}

Categories are sorted by weight first, then alphabetical second, so define weight when you want strict control of the category order. Uncategorized items have a weight of 0, and the built-in globals have a weight of 100.

Now that categories have been defined, be sure to set the category on your commands and options using the category setting! Here's an example using decorators.

import { Command, Config, Arg, GlobalOptions } from '@boost/cli';
interface BuildOptions extends GlobalOptions {
cache: boolean;
}
@Config('build', 'Build a project', { category: 'build' })
export default class BuildCommand extends Command<BuildOptions> {
@Arg.Flag('Write output to cache', { category: 'cache' })
cache: boolean = false;
// ...
}

Logging#

Boost integrates its very own logger so that logs can easily be sent to the configured stdout and stderr. The logger is accessible using Command#log() and associated methods.

import { Command } from '@boost/cli';
class CustomCommand extends Command {
run() {
this.log('Normal log');
this.log.error('Failed log');
this.log.debug('Debug log');
// ...
}
}

The logger is also accessible within a component by using the ProgramContext, like so.

import React, { useContext } from 'react';
import { Box } from 'ink';
import { ProgramContext } from '@boost/cli';
function CustomComponent() {
const { log } = useContext(ProgramContext);
log('Normal log');
log.error('Failed log');
log.debug('Debug log');
// ...
return <Box>Loading...</Box>;
}

It's highly encouraged to use the logger instead of the native console, so that logged messages do not interrupt the React rendering process, and write to the configured streams!

Themes#

Themes allow consumers to alter the color of text and backgrounds for elements rendered with the Style component. Themes are simply NPM modules that return an object of hexcodes or ANSI colors, and can be enabled by defining the BOOSTJS_CLI_THEME environment variable.

BOOSTJS_CLI_THEME=nyan <program>

When defining a theme, we'll attempt to load from @boost/theme-<name> or boost-theme-<name>, otherwise we throw an error. Third-party modules are currently not supported.