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 areRTA-20
,RTA-21
, andRTA-22
. If omitted, the default algorithm should beRTA-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:
{
"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"
}
}
Because our example package name
is scoped, we'll be using the object form for bin
to ensure that when this package is installed by the end-user, NPM creates a symlink named spline-reticulator
. If we used a string, NPM would instead use the full package name. If your application does not use a scoped name, you can use the string form to declare bin
.
See the NPM documentation on bin
for more information.
Next, we can define our command-line interface:
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.
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.
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.
export default async context => {
console.log('Configuration file got context:', context);
// Perform any async configuration tasks here.
return {
spline: '417G',
algorithm: context.supportedAlgorithms[0]
};
}
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 ourhandler
.
Now, let's see what modifications we need to make to our CLI's implementation to support this use-case:
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