README
Saffron is an opinionated integration between Yargs and Cosmiconfig, two best-in-class tools for building robust command-line applications in Node. General familiarity with these tools is recommended before using Saffron.
The core feature of Saffron is the utilization of yargs.config()
to pass data loaded by Cosmiconfig into Yargs, where Yargs can then perform
normalization, validation, and set defaults... all in one place.
In addition to this, Saffron applies some opinionated settings to both Yargs and Cosmiconfig to encourage consistency and best practices.
Rationale
Yargs is arguably the best command-line argument parser in the Node ecosystem. However, its API has grown tremendously over the years to support the myriad idiosyncratic use-cases of its users, leading to bloat. And while it does have limited support for loading configuration files, it only supports files in the JSON format.
Saffron focuses on what it thinks is the most flexible, robust Yargs API, the
command module API,
which supports almost every Yargs use-case while only involving a single Yargs
method (.command()
)
Cosmiconfig is an extremely powerful and configurable utility for adding support for configuration files to an application in several different formats, giving users the ability to choose between JSON, YAML, and JavaScript-based configuration files with a single tool.
Saffron aims to integrate these two tools, solving for many common cases, applying sensible defaults where it can, and generally making it as easy as possible to write robust CLIs in as few lines of code as possible.
Install
To install Saffron:
npm install @darkobits/saffron
Getting Started
Let's imagine we are building a CLI 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.
Let's build-out a quick CLI for this application to make sure options/arguments are being processed per the above requirements.
package.json (abridged)
{
"name": "@fluffykins/spline-reticulator",
"version": "0.1.0",
"description": "Reticulates splines using various algorithms.",
"dependencies": {
"@darkobits/saffron": "^X.Y.Z"
}
}
cli.js
import cli from '@darkobits/saffron';
cli.command({
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,
enum: ['RTA-20', 'RTA-21', 'RTA-22'],
default: 'RTA-21'
});
},
handler: ({argv}) => {
// This is where we would normally call our application's main function, but
// 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 okay:
Command Line
$ 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] [choices: "RTA-20", "RTA-21", "RTA-22"] [default: "RTA-21"]
-v, --version Show version number [boolean]
-h, --help Show help [boolean]
Notice that the description used above was derived from the description
field
of our package.json
. This is a sensible default, but can be customized by
providing a description
option in our command definition.
We can verify that our CLI works as expected by calling it in various ways, and ensuring it uses a configuration file when present:
Command Line
$ spline-reticulator 402B
{
'$0': 'spline-reticulator',
_: [],
spline: '402B'
algorithm: 'RTA-21',
}
Because this invocation was valid, Yargs invoked our handler, which logged-out the parsed arguments object. Let's provide an invalid algorithm and ensure that Yargs catches this mistake:
Command Line
$ 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] [choices: "RTA-20", "RTA-21", "RTA-22"] [default: "RTA-21"]
-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.
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
-- that is, the part after the /
or
the entire name if it doesn't have a scope. 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-reticulator.yml
algorithm: RTA-22
Command Line
$ 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 RTA-22
, which was loaded from our
.spline-reticulator.yml
file. Wowza!
For more information about supported configuration file formats, see the
config.searchPlaces
option.
API
Saffron consists of 2 functions: command
and init
. The command
function is
almost identical to Yargs' command()
function (object form) with several
additional options. The init
function is analogous to "calling" yargs.argv
to initialize the argument parser.
When building CLIs with Saffron, you should call command
once for each command
you need to register for your application, then call init
.
command(options: SaffronOptions): void
Saffron's command
function accepts a single options object, the API for which
is very similar to that of Yargs' command module API,
which configures each command for an application using a single object as
opposed to the more idiomatic Yargs approach of chaining method calls.
The interface for this object is defined below.
command
Type: string
Required: No
Default: '*'
Name of the command being implemented. If your application only implements a
"root" command and does not take any positional arguments, this option can be
omitted. If your application does take positional arguments, they must be
defined as part of this option using <>
to wrap required arguments and []
to
wrap optional arguments. Positionals may then be further annotated in the
builder function using .positional
.
See: Positional Arguments
Note: This option is a pass-through to Yargs' command
option.
aliases
Type: Array<string>
Required: No
Default: N/A
Only practical when implementing sub-commands, this option allows you to specify a list of aliases for the command.
See: Command Aliases
Note: This option is a pass-through to Yargs' aliases
option.
description
Type: string
Required: No
Default: See below.
Description for the command. If left blank, Saffron will use the description
field from your project's package.json
.
Note that if you use .usage()
in your builder function, it will override this description.
Note: This option is a pass-through to Yargs' describe
option.
builder
Type: function
Required: Yes
Default: N/A
Similar to the builder
option used when defining a Yargs command module. The
signature of this function in Saffron differs slightly from the Yargs version;
it is passed a single object with the following keys:
Key | Description |
---|---|
command |
Yargs command builder. |
config |
Parsed configuration from Cosmiconfig. |
configPath |
Path where Cosmiconfig found a configuration file, if any. |
configIsEmpty |
True if a configuration file was found, but was empty. It is often a good idea to warn users in this case. |
packageJson |
Normalized package.json for the application. Useful if you want to print the current version somewhere, for example. |
packageRoot |
Absolute path to the application's root (the directory containing package.json ) |
This function will be passed an object that will allow you to further configure
a command. The API exposed by this object is almost identical to that of Yargs
itself, but the context is scoped to the command defined by command
, making
this API preferable to using the global Yargs object. The .positional()
and .option()
methods will be the most-used in your builder to define the arguments for the
command.
config
Type: object | false
Required: No
Default: See below.
Settings for Cosmiconfig, which is responsible for locating and parsing your application's configuration file. In addition to the below options, this object may carry any valid Cosmiconfig option.
Alternatively, this option may be set to false
to disable configuration file
support entirely.
config.auto
Type: boolean
Required: No
Default: true
By default, after loading an application's configuration file, Saffron will call
Yargs' .config()
method, passing it the data from the configuration file. This
will have the effect of allowing configuration data to serve as default values
for any arguments the application accepts. This is referred to as
auto-configuration.
If an application's command-line argument schema and configuration schema
differ, auto-configuration would not be desirable. In such cases, auto
may be
set to false
, and Saffron will only load an application's configuration file
and pass its contents to builder
and handler
functions without calling
.config()
.
config.fileName
Type: string
Required: No
Default: See below.
By default, Saffron will use the un-scoped portion of your application's name
from its package.json
. If you would prefer to use a different base name for
configuration files, you may provide your own fileName
option. This is
equivalent to the moduleName
value used throughout the Cosmiconfig documentation.
config.key
Type: string
Required: No
Default: N/A
For complex applications with multiple sub-commands, it may be desirable to scope configuration for a particular sub-command to a matching key in the application's configuration file. If this option is set, Saffron will only use data under this key (rather than the entire file) to configure the command.
config.searchPlaces
Type: Array<string>
Required: No
Default: See below.
Saffron overrides the default searchPlaces
option in Cosmiconfig
with the below defaults, where fileName
is the base file name for your
application as derived from package.json
or as indicated at config.fileName
(see above). Using our example package name of @fluffykins/spline-reticulator
,
the below snippet has been annotated with examples of the exact file names
Saffron would search for.
[
// Look for a "spline-reticulator" key in package.json.
'package.json',
// Look for .spline-reticulator.json in JSON format.
`.${fileName}.json`,
// Look for .spline-reticulator.yaml in YAML format.
`.${fileName}.yaml`,
// Look for .spline-reticulator.yml in YAML format.
`.${fileName}.yml`,
// Look for spline-reticulator.config.js that exports a configuration object.
`${fileName}.config.js`
];
For comparison, the default Cosmiconfig searchPlaces
can be found here.
config.searchFrom
Type: string
Required: No
Default: process.cwd()
Directory to begin searching for a configuration file. Cosmiconfig will then walk up the directory tree from this location until a configuration file is found or the root is reached.
strict
Type: boolean
Required: No
Default: true
Whether to configure Yargs to use strict mode. In strict mode, any additional options passed via the CLI or found in a configuration file will cause Yargs to exit the program and report an error. This is generally a good idea because it helps catch typos from user input.
However, if your application's configuration file supports more options than you would like to consume via the CLI, then you will need to disable strict mode. Also be aware that because these additional options will not be defined in your builder, Yargs will not perform any validation on them.
handler
Type: function
Required: Yes
Default: N/A
Similar to the handler
option used when defining a Yargs command module. The
signature of this function in Saffron differs slightly from the Yargs version;
it is passed a single object with the following keys:
Key | Description |
---|---|
argv |
Parsed/merged/validated arguments/configuration from Yargs and Cosmiconfig. |
config |
Parsed configuration from Cosmiconfig. |
configPath |
Path where Cosmiconfig found a configuration file, if any. |
configIsEmpty |
True if a configuration file was found, but was empty. It is often a good idea to warn users in this case. |
packageJson |
Normalized package.json for the application. Useful if you want to print the current version somewhere, for example. |
packageRoot |
Absolute path to the application's root (the directory containing package.json ) |
init(cb?: (yargs) => void): void
This function should be called once all commands have been configured to
initialize the Yargs parser, equivalent to accessing yargs.argv
. It accepts an
optional callback that will be passed the global yargs
object which you can
use to perform any global operations with Yargs.
cli.ts
import cli from '@darkobits/saffron';
cli.command({
// ...
});
cli.init();
// Or:
cli.init(yargs => {
// Advanced/custom Yargs config here.
});
TypeScript Integration
Saffron is written in TypeScript and leverages Yargs' excellent TypeScript support. If you have created a type definition for your application's configuration/command-line arguments, it may be passed to Saffron as a type argument and Saffron will ensure that the objects passed to your builder and handlers are appropriately typed:
import cli from '@darkobits/saffron';
interface SplineReticulatorOptions {
spline: string;
algorithm: 'RTA-20' | 'RTA-21' | 'RTA-22';
}
cli.command<SplineReticulatorOptions>({
handler: ({argv}) => {
// Here, TypeScript will know that argv.spline is of type 'string' and that
// argv.algorithm is an enum.
}
});
cli.init();
If the schema for your application's arguments and configuration differs, a second type argument may be provided to distinguish the argument schema from the configuration schema:
import cli from '@darkobits/saffron';
interface SplineReticulatorArguments {
spline: string;
algorithm: 'RTA-20' | 'RTA-21' | 'RTA-22';
}
interface SplineReticulatorConfiguration {
// ...
}
cli.command<SplineReticulatorArguments, SplineReticulatorConfiguration>({
builder: ({config}) => {
// Here, config will be of type SplineReticulatorConfiguration.
},
handler: ({argv, config}) => {
// Here, argv will be of type SplineReticulatorArguments and config will be
// of type SplineReticulatorConfiguration.
}
});
cli.init();
Caveats
- If your application has required positional arguments, these must be provided via the command-line. This is a Yargs limitation.
Addenda
Why Saffron?
Cosmiconfig is an excellent space-themed configuration loader. Yargs is an excellent pirate-themed argument parser. Saffron is a space pirate from the excellent Firefly series.
See Also
- Command-Line Interface Guidelines – An open-source guide to help you write better command-line programs.