Skip to content

Writing commands

Every team, repo, and workspace has unique needs. oneRepo shines when it comes to doing what you need at the right time.

oneRepo commands are an extended version of the Yargs command module, written as ESM and TypeScript compatible modules. By default, command files can be placed in a commands/ folder at the repository root and within any workspace and they will automatically be registered. This directory can be changed by setting the commands.directory setting in the root configuration.

Commands are required to implement the following exports:

ExportTypeDescription
namestring | Array<string>The command’s name. If provided as an array, all values will be considered aliases for the same command.

Any value can also be '$0', which is a special token that allows this command to be the default for the parent command.
descriptionstring | falseA helpful description. If set to false, the command will be hidden from help output unless --show-advanced is included with --help.

Note: unlike in Yargs, this export must be description and not describe.
builderBuilder<T>A function that helps parse the command-line arguments
handlerHandler<T>Asynchronous function that is invoked for the command

Let’s create a custom command for our team for creating new branches using a standard naming format. This will help make it quick and easy when working on many different things to organize and avoid conflicts with each other when pushing our branches for peer review.

  1. Create a command

    ./commands/new-branch.ts
    import type { Builder, Handler } from 'onerepo';
    export const command = 'new-branch';
    export const description = 'Create a new branch using our team’s standard naming format.';
    export const builder: Builder = (yargs) => yargs.usage(`$0 ${command}`);
    export const handler: Handler = async (argv) => {
    // TODO
    };
  2. Add arguments

    For our branch names, we want to follow the common structure: <username>/[issue-number]/<description>, where [issue-number] is optional. Let’s start by setting up the input options for the issue number and description.

    If you’re using TypeScript, be sure to add the type for the arguments that will be found by the builder and passed through to the handler. This can be done by defining each as Builder<Args> and Handler<Args>, respectively. By doing so, the builder will be checked via tsc to ensure that all options are accounted for correctly and the first argument passed to our handler function (argv) will include the appropriate shape.

    Note the difference between optional and required arguments: the required description argument includes an option demandOption set to true:

    ./commands/basic.ts
    6 collapsed lines
    import type { Builder, Handler } from 'onerepo';
    export const command = 'new-branch';
    export const description =
    'A basic command that shows the minimum requirements for writing commands with oneRepo';
    type Args = {
    issue?: number;
    description: string;
    };
    export const builder: Builder<Args> = (yargs) =>
    yargs
    .usage(`$0 ${command}`)
    .option('issue', {
    type: 'number',
    description: 'The GitHub issue number for this change.',
    })
    .option('description', {
    type: 'string',
    description: 'A short and concise description of the work.',
    demandOption: true,
    });
    export const handler: Handler<Args> = async (argv, { logger }) => {
    const { issue, description } = argv;
    logger.info(`TODO: create new branch for #${issue} with description "${description}"`);
    };
    Terminal window
    $ one new-branch --issue 123 --description "my issue description"
    INFO TODO: create new branch for #123 with description "my issue description"
    â–  âś” Completed 48ms
  3. Getting your username

    You may have noticed that we skipped the <username> in our input arguments for creating new branches. That’s because we should be able to get the username from the operating system and reduce the extra burden on developers needing to add that in for every branch.

    To get the username, we can use Node’s os.userInfo() command:

    ./commands/basic.ts
    import { userInfo } from 'node:os';
    23 collapsed lines
    import type { Builder, Handler } from 'onerepo';
    export const command = 'new-branch';
    export const description =
    'A basic command that shows the minimum requirements for writing commands with oneRepo';
    type Args = {
    issue?: number;
    description: string;
    };
    export const builder: Builder<Args> = (yargs) =>
    yargs
    .usage(`$0 ${command}`)
    .option('issue', {
    type: 'number',
    description: 'The GitHub issue number for this change.',
    })
    .option('description', {
    type: 'string',
    description: 'A short and concise description of the work.',
    demandOption: true,
    });
    export const handler: Handler<Args> = async (argv, { logger }) => {
    const { issue, description } = argv;
    const { username } = userInfo();
    logger.info(
    `TODO: create new branch for ${username} with issue #${issue} and description "${description}"`,
    );
    };

    Now, when running our command, we can see our username. For this example, we’ll use the creator of JavaScript, Brendan Eich’s username, brendaneich:

    Terminal window
    $ one new-branch --issue 123 --description "my issue description"
    INFO TODO: create new branch for brendaneich with issue #123 and description "my issue description"
    â–  âś” Completed 48ms
  4. Logic & scripts

    Now that we have the basic plumbing and all of the input information we need, it’s time to actually make our command create new branches.

    ./commands/basic.ts
    2 collapsed lines
    import { userInfo } from 'node:os';
    import { run } from 'onerepo';
    24 collapsed lines
    import type { Builder, Handler } from 'onerepo';
    export const command = 'new-branch';
    export const description =
    'A basic command that shows the minimum requirements for writing commands with oneRepo';
    type Args = {
    issue?: number;
    description: string;
    };
    export const builder: Builder<Args> = (yargs) =>
    yargs
    .usage(`$0 ${command}`)
    .option('issue', {
    type: 'number',
    description: 'The GitHub issue number for this change.',
    })
    .option('description', {
    type: 'string',
    description: 'A short and concise description of the work.',
    demandOption: true,
    });
    export const handler: Handler<Args> = async (argv) => {
    const { issue, description } = argv;
    const { username } = userInfo();
    // Ensure the description is suitable for git branch names by removing non-word characters
    const sanitizedDescription = description.replace(/[\W]+/g, '-');
    // Construct the branch name replacing empty or no issue with 'no-issue'
    const branchName = `${username}/${issue || 'no-issue'}/${sanitizedDescription}`;
    // Use oneRepo's subprocessing, `run` to run commands:
    await run({
    name: 'Create branch',
    cmd: 'git',
    args: ['checkout', '-b', branchName, 'origin/main'],
    });
    };

    One last time, let’s run our command and create a new branch for adding the command:

    Terminal window
    $ one new-branch --issue 123 --description "create new-branch command"
    ┌ Create branch
    â”” âś” 985ms
    â–  âś” Completed 1028ms
    $ git rev-parse --abbrev-ref HEAD
    brendaneich/123/create-new-branch-command

Commands may be nested for organization purposes. Nesting can help create a logical hierarchy and flow for users when working with various aspects of your monorepo’s tooling. Using our previous example of new-branch it may make sense to create multiple commands for working with branches.

In this example, let’s make our new branch command accessible by running one branch new and set up the plumbing for more sub-commands of branch in the future.

  1. Add a parent command

    To create a parent command that can have nested sub-commands, use the .commandDir() function in the builder to reference a relative path from the current file that holds the sub-commands. Let’s assume we want to create a suite of git branch management commands to make it easier for developers to use standard branch naming conventions and more:

    ./commands/branch.ts
    5 collapsed lines
    import type { Builder, Handler } from 'onerepo';
    export const command = 'branch';
    export const description = 'A suite of branch management commands';
    export const builder: Builder = (yargs) =>
    yargs.usage(`$0 ${command}`).commandDir('./branch').demandCommand(1);

    This function uses the same logic for finding commands as all other commands are found. You can configure various aspects of this logic using the RootConfig #commands setting.

    Notice that we also have not defined or exported a handler function, however, we have defined demandCommand(1). This signals to the CLI that when this command is run without a sub-command, that it should return the help documentation and exit immediately.

  2. Move the sub-command

    Next ensure we have a sub-directory ./commands/branch, as previously used for the commandDir() argument in our builder. Then we can create our new command with the basic 4-export structure command, description, builder, and handler

    Terminal window
    mkdir -p commands/branch
    git mv commands/new-branch.ts commands/branch/new.ts

    We should now have a file tree that looks like this:

    • Directorycommands/
      • Directorybranch/
        • new.ts
      • branch.ts
  3. Update the command name

    Lastly, we need to remember to update the command’s name. We do this by changing the command export variable:

    ./commands/branch/new.ts
    3 collapsed lines
    import { userInfo } from 'node:os';
    import { run } from 'onerepo';
    import type { Builder, Handler } from 'onerepo';
    export const command = 'new-branch';
    export const command = 'new';
    37 collapsed lines
    export const description =
    'A basic command that shows the minimum requirements for writing commands with oneRepo';
    type Args = {
    issue?: number;
    description: string;
    };
    export const builder: Builder<Args> = (yargs) =>
    yargs
    .usage(`$0 ${command}`)
    .option('issue', {
    type: 'number',
    description: 'The GitHub issue number for this change.',
    })
    .option('description', {
    type: 'string',
    description: 'A short and concise description of the work.',
    demandOption: true,
    });
    export const handler: Handler<Args> = async (argv) => {
    const { issue, description } = argv;
    const { username } = userInfo();
    // Ensure the description is suitable for git branch names by removing non-word characters
    const sanitizedDescription = description.replace(/[\W]+/g, '-');
    // Construct the branch name replacing empty or no issue with 'no-issue'
    const branchName = `${username}/${issue || 'no-issue'}/${sanitizedDescription}`;
    // Use oneRepo's subprocessing, `run` to run commands:
    await run({
    name: 'Create branch',
    cmd: 'git',
    args: ['checkout', '-b', branchName, 'origin/main'],
    });
    };

    That’s it! We can now run our new branch command using the parent/sub-command pattern:

    Terminal window
    $ one branch new --issue 123 --description "create new-branch command"
    ┌ Create branch
    â”” âś” 985ms
    â–  âś” Completed 1028ms
    $ git rev-parse --abbrev-ref HEAD
    brendaneich/123/create-new-branch-command
  4. Adding more commands

    Just like with the previous commands/branch/new.ts command file. We can continue adding commands to the commands/branch directory and they will automatically become sub-commands of one branch.

oneRepo provides a robust API and suite of tools to flesh out your commands. Please refer to the full API documentation for available methods, namespaces, and interfaces.

oneRepo exports a handful of builders for common input arguments and getters for file and workspace querying based on the builders’ input arguments.

commands/list-workspaces.ts
import { builders } from 'onerepo';
import type { Builder, Handler } from 'onerepo';
export const command = 'list-workspaces';
export const description = 'A basic command lists workspaces based on input arguments';
type Argv = builders.WithWorkspaces & builders.WithAffected;
export const builder: Builder<Argv> = (yargs) =>
builders.withWorkspaces(builders.withAffected(yargs));
export const handler: Handler<Argv> = async (argv, { getWorkspaces, logger }) => {
// Get a list of workspaces given the input arguments
const workspaces = await getWorkspaces();
logger.info(workspaces.map((ws) => ws.name).join('\n'));
};
  1. Import the builders namespace from onerepo
  2. For TypeScript, ensure you include the builder types in your arguments type definition.
  3. Wrap the return value from your exported builder with the builders helpers.
  4. The getters from the handler extra arguments will automatically be affected by the input arguments used.

Adding the withWorkspaces and withAffected builder composition functions to our exported builder, a few options are automatically added to our command. Looking at the --help output will describe each one of them in detail:

Terminal window
$ one list-workspaces --help
11 collapsed lines
A basic command that shows the minimum requirements for writing commands with
oneRepo
Global:
--dry-run Run without actually making modifications or destructive
operations [boolean] [default: false]
-v, --verbosity Set the verbosity of the script output. Increase
verbosity with `-vvv`, `-vvvv`, or `-vvvvv`. Reduce
verbosity with `-v` or `--silent` [count] [default: 2]
--show-advanced Pair with `--help` to show advanced options. [boolean]
-h, --help Show this help screen [boolean]
Options:
--affected Select all affected workspaces. If no other inputs are
chosen, this will default to `true`. [boolean]
--staged Use files on the git stage to calculate affected files or
workspaces. When unset or `--no-staged`, changes will be
calculated from the entire branch, since its fork point.
[boolean]
-a, --all Run across all workspaces [boolean]
-w, --workspaces List of workspace names to run against [array]

Finally, running our command, we can see list of workspaces based on those added input argument flags:

Terminal window
$ one my-command --workspace some-workspace --affected
┌ Get workspaces from inputs
â”” âś” 101ms
INFO workspace-a
INFO workspace-b
â–  âś” Completed 128ms

The more explanation and context that you can provide, the better it will be for your peers using commands. Consider adding epilogues and examples along with the required description.

You can also generate Markdown documentation of the full CLI using the docgen plugin!

oneRepo provides a robust Logger to all commands and methods. This logger is responsible for tracking output and ensuring that all subprocess output is buffered and redirected appropriately for a better debugging experience.

The logger instance is primarily available in command handlers via the HandlerExtra. Logger verbosity is controlled via the global counter argument --verbosity, or -v.

export const handler: Handler = async (argv, { logger }) => {
// -v (1)
logger.error('This will output an error (and also set cause this run to exit with code 1)');
logger.info('This will output an important informative message.');
// -vv (2, default)
logger.warn('This will output a warning');
// -vvv (3)
logger.log('This is log output');
// -vvvv (4)
logger.debug('This is extra debug information');
};

Generally speaking, it is best practice to wrap your logs in steps. This will help for better scannability and timing when debugging any issues:

export const handler: Handler = async (argv, { logger }) => {
const step = logger.createStep('Setup');
step.debug('do some work and write to the logger via the `step`');
// Ensure you await the step to end so all logs are written out of the buffer
await step.end();
};

oneRepo includes advanced child process spawning via the run and batch functions. These async functions work like Node.js child_process.spawn, but are promise-based asynchronous functions that handle redirecting and buffering output as well as failure tracking for you. These should be used in favor of the direct Node.js builtins.

If the command you’re trying to run is installed by a third party node module through your package manager (NPM, Yarn, or pNPM), you are encouraged to use graph.packageManager.run and graph.packageManager.batch functions. These will determine the correct install path to the executable and avoid potential issues across package manager install locations.

import { run } from 'onerepo';
export const handler: Handler = async (argv) => {
await run({
name: 'Do some work',
cmd: 'sleep',
args: ['5'],
});
};

Often it will make sense to run many things at once. The batch function handles automatic resource sharing and prevents running too many processes at once for your current machine.

import { batch } from 'onerepo';
import type { Handler, RunSpec } from 'onerepo';
export const handler: Handler = async (argv) => {
const processes: Array<RunSpec> = [
{ name: 'Say hello', cmd: 'echo', args: ['"hello"'] },
{ name: 'Say world', cmd: 'echo', args: ['"world"'] },
];
const results = await batch(processes);
expect(results).toEqual([
['hello', ''],
['world', ''],
]);
};

oneRepo includes many functions for reading and writing files that ensure safe operation. Developers should use these as much as possible. Check the file API documentation for a full list of available helpers.

commands/my-command.ts
import { builders } from 'onerepo';
4 collapsed lines
export const command = 'my-command';
export const description = 'A useful description for my command';
type Argv = builders.WithWorkspaces & builders.WithAffected;
export const builder: Builder<Argv> = (yargs) =>
builders.withAffected(builders.withWorkspaces(yargs));
export const handler: Handler<Argv> = (argv, { getWorkspaces }) => {
// Get a list of workspaces given the input arguments
const workspaces = getWorkspaces();
};

By using the builders helpers, your command will automatically have extra input arguments available that help limit which workspaces will be returned from the getWorkspaces function. By default, --affected will be set to true unless you specify another option when invoking the command.

Default usage
one my-command
Run for workspaces 'foo' and 'bar' only
one my-command --workspaces foo bar
Force run against all workspaces
one my-command --all

Avoiding writing tooling that is easily mis-interpreted and prone to breaking by writing functional tests around your custom commands. The oneRepo suite includes the ability to set up a mock environment to run commands with CLI flags and assert on their behaviors within both Jest and Vitest.

To get started, install the @onerepo/test-cli package as a development dependency:

Terminal window
npm install --save-dev @onerepo/test-cli
commands/__tests__/my-command.test.ts
import * as oneRepo from 'onerepo';
import { getCommand } from '@onerepo/test-cli';
import * as MyCommand from '../my-command';
const { build, run } = getCommand(MyCommand);
describe('my-command', () => {
test('can test the builder', async () => {
const argv = await build('--arg1 --arg2=foo');
expect(argv).toMatchObject({
/* ... */
});
});
test('can test the handler', async () => {
vi.spyOn(oneRepo, 'run').mockResolvedValue(['', '']);
await run('--arg1 --arg2=foo');
expect(oneRepo.run).toHaveBeenCalledWith(
expect.objectContaining({
cmd: 'foo',
args: ['...'],
}),
);
});
});

Did you also know that oneRepo plugins are written nearly identical to the way commands are custom written in your monorepo? The official oneRepo plugins are the best source for up-to-date, working examples of commands.