dispute
TypeScript icon, indicating that this package has built-in type declarations

0.5.1 • Public • Published
dispute logo

Dispute

A tool for building command line interfaces.

Travis CI master build status npm package version npm type definitions

Purpose

Dispute is a modern alternative to commander and yargs. While it supports all the usual CLI features (subcommands, validation, help page generation, etc), dispute focuses on two main qualities:

  • Reusability: commands are just functions, and can be used outside the framework.
  • Testability: dispute exposes an interface that makes testing a breeze (both unit & integration style).

Example

Say you're building a command-line interface that reads and validates a config file. Here's how it might look with dispute:

#!/usr/bin/env node
import { createCli, ExitCode } from 'dispute'
import pkg from '../package.json'
import fs from 'fs-extra'
 
const cli = createCli({
  commandName: 'validate-config',
  packageJson: pkg,
  cli: {
    args: '[config-file]',
    async command(options, configFile = '.config.json') {
      const contents = await fs.readFile(configFile, 'utf8')
      const validationWarnings = validateFile(contents)
 
      if (!validationWarnings) {
        console.log("Hooray, it's valid!")
        return
      }
 
      console.log('Sorry, that looks invalid :(')
      console.log(validationWarnings)
      throw new ExitCode(1)
    },
  },
})
 
cli.execute()

cli.execute() is what actually runs your CLI. It parses, validates, & runs the command using process.argv.

For more examples, browse the examples/ folder.

API

Table of Contents

createCli(...)

Validates the given config and returns a few different ways to interact with the CLI. Most of this section is about the config format and how to structure your commands.

The config consists of two parts: metadata and commands. The metadata is made up of your package.json file and the name of your command (i.e. how your users will call it). Those are the only required fields.

// This is the simplest possible config.
createCli({
  commandName: 'eslint',
  packageJson: require('../package.json'),
})

Note: package.json is used to print the version number. Other fields like homepage or bugs.url might be used in the future.

Next, you tell dispute what commands are supported using config.cli. Every command has the same format:

{
  // This is what gets called.
  command: undefined || Function,
 
  // What flags does the command support? e.g. '--port=3000' or '--color'
  options: undefined || Object,
 
  // What arguments does the command accept?
  args: undefined || string,
 
  // An optional command description printed with the --help flag.
  description: undefined || string,
 
  // Any nested commands... more on this later.
  subCommands: undefined || Object,
}

Most of these are optional. The only exception is command and subCommands. You can specify either or both, but at least one must be provided. Let's look at .command first.

Commands take an options object (defined by .options) followed by any number of arguments (defined by .args). If no options are defined, options will always be an empty object.

{
  command(options, ...args) {
    // options: {}
    // args: []
  }
}

Options are a mapping between option names and how they should be parsed. Here's the high-level structure:

{
  // How should the user pass these options?
  usage: string,
 
  // If the option accepts an argument, how should it be parsed?
  // By default arguments are interpreted as plain strings.
  parseValue: undefined || Function
 
  // Optionally show this description in the --help page.
  description: undefined || string,
}

usage defines how the flags are named and whether an argument is allowed. It looks something like this:

{
  usage: '-p, --port <number>',
}

In the example above, if the user wants to pass the port 3000 as an option, they could use -p 3000 or --port 3000. The <...> delimiters mean an argument is required. If the number should be optional, use --port [number] instead. This is a pattern used throughout dispute, as well as most CLI frameworks.

Now bringing it back to .command, here's how options fit in:

cli: {
  options: {
    portNumber: { usage: '--port <number>' },
  },
 
  command({ portNumber = 3000 }) {
    const server = new http.Server()
    server.listen(portNumber);
  },
}
 
// `portNumber` is set via `--port`:
// $ cmd --port 8080

Note: there's no way to specify an option's default value. Use ES2015 default assignment instead (as shown in the example).

Options support two other fields: a description which is shown in the help page, and parseValue, which is used to parse the option's argument from a string into something else.

You can implement parseValue(...) however you like, but you probably won't need to. Dispute ships with several parsers supporting validation out of the box.

import { parseValue } from 'dispute'
 
{
  options: {
    // Valid: --port 8080, --port 3000
    // Throws: --port string, --port Infinity
    port: {
      usage: '--port <number>',
      parseValue: parseValue.asNumber,
    },
 
    // --color=yes, --color=on, --color=true
    useColor: {
      usage: '--color [enabled]',
      parseValue: parseValue.asBoolean,
    },
  },
}

By default, the value parser is parseValue.asString.

Excellent. Now, moving on to command.args! By default, commands don't accept any arguments, and dispute will warn the user if they try to pass any.

There are a few different types of arguments. Optional, required, and variadic. You can mix them however you choose (so long as required arguments don't follow optional... that wouldn't make much sense).

Dispute will map those arguments onto your .command function following the options object.

{
  // The first argument is required, the second is optional,
  // and any number of arguments can follow.
  args: '<required> [optional] [variadic...]',
  command(options, required, optional = 'default value', ...variadic) {
    // Implementation
  },
}

Note: there's a subtle difference between <args...> and [args...]. The first requires 1 or more parameters, the second requires 0 or more.

Finally, .subCommands tells dispute what commands are callable from beneath this one. git is probably the most common example of this. Many of its commands have other commands beneath it, like git remote which has git remote add ... and git remote set-url.

.subCommands maps the command name to another command object.

{
  options: {
    verbose: { usage: '-v, --verbose' },
  },
 
  command({ verbose = false }) {
    // git remote [-v|--verbose]
  },
 
  subCommands: {
    add: {
      args: '<remote-name> <remote-url>',
      command(options, remoteName, remoteUrl) {
        // git remote add
      },
    },
 
    'set-url': {
      args: '<remote-name> <remote-url>',
      command(options, remoteName, remoteUrl) {
        // git remote set-url
      },
    },
  },
}

A couple of things to note, in case you're worried or wondering:

  • The options of each command only apply to that specific command.
  • Dispute only runs one command. In this example, git remote set-url only calls the set-url command. Nothing else.
  • Subcommands can contain more subcommands. Nest as deeply as you like.

And there you have it! That's everything you might want to know about dispute's configuration. To bring it all together, here's the full format:

createCli({
  commandName: string,
  packageJson: Object,
  cli: {
    options: {
      [optionName]: {
        usage: '-l, --long-flag <required-argument>',
        description: 'Shown in the help page',
        parseValue: Function,
      }
    },
 
    description: 'Shown in the help page',
 
    args: '<required> [optional] [variadic...]',
 
    command(options, ...args) {
      // Implementation
    },
 
    subCommands: {
      [commandName]: Object,
    },
  },
})

createCli(...).execute(...)

Parses, validates, and runs the command using process.argv.slice(2). Optionally, pass a string array and it'll parse that instead.

.execute() returns a promise. If command failed or never ran (which happens if the user passes --version or --help), .execute() will reject with the error instance. Don't worry about catching the error though, dispute handles that automatically.

If the command runs successfully, the resolve value will contain whatever the command returned as the .output property. The resolve value has other data, but think carefully before using any of it.

cli.execute().then(result => {
  console.log('Command finished and returned:', result.output);
})

createCli(...).createTestInterface()

Returns a CLI interface more amenable to unit & integration tests. If the command throws an ExitCode, this won't kill the test process and you can handle it just like a normal error.

The test function accepts any number of arguments and parses them like process.argv. The return value is a promise that resolves with whatever the command returns.

Not that I think docker should use dispute (or JavaScript for that matter), but if they did, here's what the tests might look like:

const docker = createCli(config)
const cli = docker.createTestInterface()
 
describe('docker run', () => {
  it('starts a container', async () => {
    await cli('docker', 'run', 'node:alpine')
 
    expect(container.start).toHaveBeenCalledWith('node:alpine')
  })
 
  it('fails if the container tag is invalid', async () => {
    container.findByTagName.mockResolvedValue(null)
    const fail = () => cli('docker', 'run', 'bad-container-name')
 
    await expect(fail).rejects.toMatchObject({ exitCode: 1 })
  })
})

createCli(...).createApi()

Note: this feature is experimental.

createApi() returns a programmatic interface for your CLI, mirroring subcommands and options into a typical JS API. This has a few advantages:

  • Zero-cost to ship a JavaScript API for your command line interface
  • Reuses your terminal API contract, so...
    • Your users already know the API
    • There's almost no added API surface

The generated wrapper does some pre-processing to map normal JS practices into what your command expects. One of the most notable differences is that the options object comes last:

const yarn = cli.createApi();
 
yarn.global.add('package-name', {
  cacheFolder: '/tmp/yarn-cache',
  dev: true,
})

It also transforms your kebab-case flag names (like --cache-folder) into camelCase option names.

Backwards Compatibility

The return value is whatever your command returns, so if you change it, be careful not to break backwards compatibility.

ExitCode(...)

One of the ways a command communicates failure is through the exit code. If it's non-zero, then something horrible happened. Maybe the config file didn't exist, a spawned process died unexpectedly, or perhaps a value was just out of range. In many other CLIs the common approach is to call process.exit(1). That's a terrible idea for many reasons:

  • Mocking out process.exit in a test environment is dangerous and tedious.
  • Error handling is non-existent. You call process.exit and your program is done.
  • Since failures can't be handled by the caller, reusing code becomes dangerous and inflexible.

That's why dispute includes ExitCode. JavaScript already has a way to model unexpected failures: by throwing errors. If nothing else catches an ExitCode, dispute will forward the code to process.exit and terminate the process.

import { ExitCode, createCli } from 'dispute'
 
createCli({
  // ...config
  cli: {
    command() {
      console.error('Something went wrong')
      throw new ExitCode(1)
    },
  },
})

Note: if your command runs asynchronously, return a promise. Rejecting with an ExitCode will have the same effect.

Naming

I chose the name "dispute" because it's a command-line argument parser and I wanted a clever synonym. Plus, it's an unspoken rule that every good npm package has to be 2 syllables.

Projects Using Dispute

If you're using dispute in a public project, submit a pull request!

Package Sidebar

Install

npm i dispute

Weekly Downloads

3

Version

0.5.1

License

MIT

Unpacked Size

125 kB

Total Files

96

Last publish

Collaborators

  • psychollama