Page cover

Example Application

Let's imagine we are building a command-line interface for a Node application that will help us reticulate splines. The CLI has the following requirements:

  • It must be provided 1 positional argument, spline, indicating the spline to be reticulated.

  • It may be provided 1 optional named argument, algorithm, indicating the reticulation algorithm to use. Valid algorithms are RTA-20, RTA-21, and RTA-22. If omitted, the default algorithm should be RTA-21.

  • These options may be provided as command-line arguments or supplied via a configuration file, .spline-reticulator.yml located in or above the user's current directory.

Part 1: CLI Implementation

Let's build-out a quick CLI for this application to make sure options/arguments are being processed per the above requirements.

First, we should have a well-formed package.json in our project. The name, version, and description fields will be read by Saffron to set sensible defaults and to report the current version of your application when the --version flag is passed. Additionally, the bin field should point to the file that Node should use as the entry-point to your application:

package.json
{
  "name": "@fluffykins/spline-reticulator",
  "version": "1.2.3",
  "description": "Reticulates splines using various algorithms.",
  "files": [
    "dist"
  ],
  "bin": {
    "spline-reticulator": "dist/bin/cli.js"
  },
  "dependencies": {
    "@darkobits/saffron": "^X.Y.Z"
  }
}

Next, we can define our command-line interface:

cli.ts
import * as cli from '@darkobits/saffron';

cli.command({
  // <> here indicates a required positional argument, while [] indicates an
  // optional positional argument.
  command: '* <spline>',
  builder: ({ command }) => {
    // Add some additional information about the "spline" positional argument.
    command.positional('spline', {
      description: 'Identifier of the spline to reticulate.',
      type: 'string'
    });

    // Define the "algorithm" named argument.
    command.option('algorithm', {
      description: 'Reticulation algorithm to use.',
      type: 'string',
      required: false,
      choices: ['RTA-20', 'RTA-21', 'RTA-22'],
      default: 'RTA-21'
    });
  },
  handler: ({ argv }) => {
    // This is where we would normally call the function that implements spline
    // reticulation, but for now let's just log the configuration we got from
    // Saffron.
    console.log(argv);
  }
})

// Once we have registered all commands for our application, be sure
// to call init.
cli.init();

First, lets try invoking spline-reticulator --help to make sure our usage instructions look good. Note that if your project requires a build step, you may need to invoke your CLI with a command like node ./dist/bin/cli.js --help.

$ spline-reticulator --help

spline-reticulator <spline>

Reticulates splines using various algorithms.

Positionals:
  spline  Identifier of the spline to reticulate.                            [string]

Options:
  --algorithm    Reticulation algorithm to use.
                                                                             [string]
  -v, --version  Show version number                                        [boolean]
  -h, --help     Show help                                                  [boolean]

Note the name and description used above was derived from the name (minus any scope) and description fields of our application's package.json. These are sensible defaults, but can be customized by calling command.scriptName() from our builder and by providing a description property in our command definition, respectively:

cli.command({
  command: '* <spline>',
  // Useful if your CLI defines multiple commands.
  description: 'Override description from package.json!',
  builder: ({ command }) => {
    // Advanced: Tell Yargs to use something other than $0
    // when printing usage instructions.
    command.scriptName('reticulate');
  }
});

We can verify that our CLI works as expected by calling it in various ways, and ensuring it uses a configuration file when present:

$ spline-reticulator 402B

{
  '$0': 'spline-reticulator',
  _: [],
  spline: '402B'
  algorithm: 'RTA-21',
}

Because this command passed Yargs' validation, our handler was invoked, which logged the parsed arguments object. Let's provide an invalid algorithm and ensure that Yargs catches the mistake:

$ spline-reticulator 402B --algorithm RTA-16

spline-reticulator <spline>

Reticulates splines using various algorithms.

Positionals:
  spline  Identifier of the spline to reticulate.                            [string]

Options:
  --algorithm    Reticulation algorithm to use.                              [string]
  -v, --version  Show version number                                        [boolean]
  -h, --help     Show help                                                  [boolean]

Invalid values:
  Argument: algorithm, Given: "RTA-16", Choices: "RTA-20", "RTA-21", "RTA-22"

Notice the Invalid values: section at the end, indicating the erroneous reticulation algorithm.

Part 2: Basic Configuration File

Let's try adding a configuration file and ensure that Saffron loads it correctly. By default, Saffron will use the un-scoped portion of the name field from your project's package.json. Since our package is named @fluffykins/spline-reticulator, Saffron will use spline-reticulator as the base name when searching for configuration files. One of the default supported configuration file types would thus be .spline-reticulator.yml. If this file is found in or above the directory from which we invoked our application, it would be loaded and merged with any arguments provided.

.spline-reticularor.yml
algorithm: RTA-22
$ spline-reticulator 402B

{
  '$0': 'spline-reticulator'
  _: [],
  spline: '402B',
  algorithm: 'RTA-22',
}

Notice we did not provide an --algorithm argument, and the default algorithm RTA-21 has been superseded by the one we provided in our configuration file, RTA-22. Merging configuration and command-line arguments (auto-configuration) can be disabled if it does not suit your application's requirements. See Part 3 below for a demonstration.

When using automatic configuration merging, options provided at the command line always take precedence over options provided by the configuration file.

Part 3: JavaScript Configuration File

To demonstrate more of Saffron's capabilities, let's add some additional requirements to our application:

  • The end-user may need to fetch the spline to reticulate from a remote server.

  • Our application may pass some context to the user to help them define their configuration.

To satisfy these requirements, we can allow users to configure our application using a JavaScript (or TypeScript) configuration file. Using the same base name from package.json, Saffron (via Cosmiconfig) will search for a file named spline-reticulator.config.ts at or above the user's current directory.

spline-reticulator.config.js
export default async context => {
  console.log('Configuration file got context:', context);
  
  // Perform any async configuration tasks here.
  
  return {
    spline: '417G',
    algorithm: context.supportedAlgorithms[0]
  };
}

All JavaScript and TypeScript extensions are supported, and your end-users may write their configuration files using any language features or syntax they choose, so long as their local project's tooling is configured to support those features. For example, Saffron will automatically use a local tsconfig.json to transpile TypeScript configuration files.

There's a lot going on here, but what matters is:

  • The user can define their configuration as an async function.

  • The user can act based on the contents of a configuration context (or any function signature you want) provided by our handler.

Now, let's see what modifications we need to make to our CLI's implementation to support this use-case:

cli.ts
import * as cli from '@darkobits/saffron';

// [1] Factor-out our supported algorithms.
const supportedAlgorithms = ['RTA-20', 'RTA-21', 'RTA-22'];

cli.command({
  command: '* <spline>',
  // [2] Our configuration will now be of type "function", so we'll disable
  // auto-merging of configuration with command-line arguments. Don't worry;
  // if you forget to do this, Saffron will issue a warning.
  config: { auto: false },
  builder: ({ command }) => {
    command.positional('spline', {
      description: 'Identifier of the spline to reticulate.',
      type: 'string'
    });

    command.option('algorithm', {
      description: 'Reticulation algorithm to use.',
      type: 'string',
      required: false,
      // [3] Use our variablized list of algorithms.
      choices: supportedAlgorithms,
      default: 'RTA-21'
    });
  },
  // [4] The "config" property passed to our handler will be the value that
  // was default-exported from the user's configuration file.
  handler: async ({ argv, config }) => {
    // [5] If the user exported a function, invoke the function, passing it a
    // context object containing our list of supported algorithms. We should
    // always await this call in the event the function is async. Otherwise,
    // assume the user exported an object and use it directly.
    const resolvedConfig = typeof config === 'function'
      ? await config({ supportedAlgorithms })
      : config;
    
    console.log('CLI got arguments:', argv);
    console.log('CLI got configuration:', resolvedConfig);
  }
});

cli.init();

And that's it! ✨

  • We variablized our list of supported algorithms so there is a single source of truth for the CLI and the context object we pass to configuration functions.

  • We disabled auto-configuration to support our more advanced use-case.

  • We added support for invoking and await-ing configuration functions that will be passed a list of our supported algorithms.

  • Despite all these changes, we retained the ability for users to continue to use a basic YAML configuration file if they so desire.

Wrapping Up

Typically, by the time an application has grown sophisticated enough to require configuration files that involve asynchronous functions, its command-line arguments and configuration schema will have little to no overlap, and Saffron's auto-merging feature will likely remain disabled. This also means that Yargs will no longer be validating our configuration. You could, however, use a tool like deepmerge to recursively merge argv and resolvedConfig into a single object. Validation libraries like ow or ajv could also be used to ensure our resolved configuration satisfies a particular schema.

To learn more about how to use Saffron in detail, see the API Reference.

Last updated