A Google ZX based CLI that also utilizes Commander's API to provide an intuitively organized CLI with easy to use declarative syntax.
If your project is in common it will not work. There are no plans to support CJS as of now. ZLI was designed to be used in an MJS mono-repo with CJS subprojects. As CJS doesn't support top level async/await, it is not preferable for NodeJS scripting since so much tooling requires reaching out to the OS.
Also of note, while there probably shouldn't be any issues using this in linux, as of now only OS X has been tested. Support for other OS's isn't a high priority at the moment, but we're always open to PR's for adding support.
ZX is a great scripting tool that has allowed our engineering team to quickly create tooling that can leverage our node-based ecosystem. While ZX provides some rudimentary argument handling, it is nowhere near as rich as Commander's. Initially, we added commander to our scripts for this, but this provided no over-arching organization, nor did it provide easy discovery of the whole suite of scripts that had been written. ZLI was written to solve this while also providing a declarative syntax for describing commands, subcommands, and arguments.
ZLI, as stated above, provides a simple declarative way of describing the command hierarchy and options with automatic built-in help output. It also provides a suite of functional hooks for handling options and environment flags and a suite of helpers to handle common scripting functionality.
$ npm i @venicemusic/zli
$ gh repo clone danajanezic/zli
$ cd <your project name>
$ npm i <location you cloned zli to>
Once installed you have to configure it:
$ npx zli --configure
The documentation will assume that you used all the default configuration values.
To see an example project that uses ZLI go here.
From there you can do the following:
$ ./zli --help
$ ./zli show-globals --docs
$ ./zli show-globals --list
Let's create a command with the following signature:./zli secrets --fetch-all
:
First let's create a file in our command root called secrets.js
Inside this let's add the following:
export const OPTS = {
name: 'secrets',
description: 'Commands for fetching/updating secrets from secret store'
options: [
{
name: 'fetch-all',
description: 'Fetch all secrets from store',
type: OPTION_TYPES.BOOLEAN,
}
]
}
This tells the ZLI interpreter the command structure we want. The interpreter looks for exported constant object declarations named "OPTS". The key name at the top level of this object tells it the command name, the description tells it what to display in the help output for this command. The options array are all argument options for the command which follow the same name/description pattern but require an extra field: type which tells the interpreter what type of options it is. To see available types you can check the source code.
At this point we can actually now run the following:
$ ./zli secrets --help
__________.____ .___
\____ /| | | |
/ / | | | |
/ /_ | |___| |
/_______ \|_______ \___|
\/ \/
Usage: ZLI secrets [options]
Commands for fetching/updating secrets from secret store
Options:
-f, --fetch-all Fetch all secrets from store
-h, --help display help for command
So now that we have told the interpreter what our command looks like, we probably want to be able to have our script actually do something when invoked. So let's now add the following to our file:
useOpts(() => {
echo('DO THE THING TO FETCH');
}, 'fetchAll');
The useOpts
handler provides a way of scoping code to only be called when the arguments passed
as strings. Note here that Commander transforms 'fetch-all' to 'fetchAll' as the argument name. Since
ZLI is built on GoogleZX, the entire ZX API is within the scope of your scripts. Hence echo
being available.
The useOpts
handler can be nested within other useOpts
handlers or the runtime handlers which
you can learn more about by running $ ./zli show-globals --docs
. If you want a block of code to run
when multiple options are provided just pass each option name as a subsequent argument to the end
of useOpts.
Let's now make our command require a password option to be provided:
export const OPTS = {
name: 'secrets',
description: 'Commands for fetching/updating secrets from secret store',
options: [
{
name: 'fetch-all',
description: 'Fetch all secrets from store',
type: OPTION_TYPES.BOOLEAN,
},
{
name: 'password',
description: 'Password for secret store',
type: OPTION_TYPES.PASSWORD,
required: true,
}
]
}
Notice now we have two option items in our array with the new one being for a password. We can see here that the type is OPTION_TYPES.PASSWORD which tells the interpreter not only that the user will be providing a value, but that value should be treated as a password. As of now there is no special treatment, but it is on the roadmap to make sure that if there is logging of this value (like say when an error is thrown) that it is masked by default, so it's still worth using this type over OPTION_TYPES.INPUT for password values. Next we see that there is a new field named required. This tells the interpreter not to proceed if the user has not provided this options.
$ ./zli secrets --fetch-all
ERROR:
Missing required argument(s): password
Now let's update our code to use the password:
useOpts(({ password }) => {
echo(`Authenticating with password ${password}`);
echo('DO THE THING TO FETCH');
}, 'fetchAll', 'password');
So as you can see we added password to the args for useOpts and now our
callback function takes an argument: ({ password }) => {}
. ZLI passes all the
arguments to the callback function's first argument as an object. If we wanted to just
scope our callback within the password and test fetchAll independently we could
rewrite our usage of useOpts to be the following should we so choose:
useOpts(({ password, fetchAll }) => {
echo(`Authenticating with password ${password}`);
if (fetchAll) {
echo('DO THE THING TO FETCH');
}
}, 'password');
Or we could also do rewrite it like this:
useOpts(({ password, fetchAll }) => {
echo(`Authenticating with password ${password}`);
useOpts(() => {
echo('DO THE THING TO FETCH');
}, 'fetchAll');
}, 'password');
It's up to you're preference. Very often the pattern we end up using is creating
functions to handle specific operations and then wire up those functions with useOpts
.
The idea behind useOpts is to prevent garden path if/then code for handling all
the possible configurations of options.
If you need to code to always run inside a command script you just write it like you would normally:
//ONLY CALLED when options 'fetchAll' and 'password' are present
useOpts(({ password }) => {
echo(`Authenticating with password ${password}`);
echo('DO THE THING TO FETCH');
}, 'fetchAll', 'password');
//Always called if no required options are defined or required options are defined and provided by user
echo(`You should see this output whether or not you provide the fetch options`);
$ ./zli secrets -p 12341234
You should see this output whether or not you provide the fetch options
Now it needs to be pointed out that that code will not be called if the password is not provided because the interpreter will not execute your script if a required field is not provided. This hints at the underlying design goal of minimizing side-effects. That is, you should be able to do very dangerous things in your scripts that will never run unless explicitly told to. Implementing this goal as well as the goal of providing simple declarative ways of describing command structure meant that the CLI's interpreter works in two primary phases: 1. parse all the exported Opts objects to determine command structure and which script to run, 2. evaluate script within the context provided by the interpreter.
As a result of this two-phased execution, code outside of export const OPTS = {}
does not run
during the first phase. This means you cannot use imports inside of it unless you use the async import()
function or the require
function provided by ZX (since ZX is within the scope of OPTS too).
Let's say we need to call oursecrets
script from another script. ZLI provides the withZliCmd
hook for just
this use-case. In our new script we will want to do the following:
withZliCmd(secretsCmd => {
// Don't hardcode passwords this is just an example
secretsCmd({fetchAll: true, password: 1234});
}, 'secrets.js');
Like useOpts
, withZliCmd
takes a callback as it's first argument which when executed
is provided the command as a function to call with the arguments you would normally pass
as cli options. The rest of the arguments to withZliCmd
are the path to the command
you want to use. This example assumes that it is in a file that is next to 'secrets.js'.
If you are having trouble figuring out why commands, subcommands and/or options aren't as expected you can run the following command to see the a representation of the command hierarchy:
# ./zli --show-arg-name-map
> ZLI
----> [
[ 'v', 'verbose' ],
[ 'l', 'local' ],
[ 'd', 'develop' ],
[ 's', 'staging' ],
[ 'p', 'prod' ]
]
ZLI > secrets
----> [ [ 'f', 'fetch-all' ], [ 'p', 'password' ] ]
ZLI > show-globals
----> [ [ 'd', 'docs' ], [ 'l', 'list' ] ]
If you're CLI grows to be many files you might find that caching the command tree will provide a performance lift. To do so you can run:
$ ./zli --write-cache
This will create a file named .zli.cache.js
which you'll probably want to add to your
.gitignore file because the command paths will be absolute in the file and most definitely
won't work on other computers.
If you do this, remember to run the write command any time the cli commands change to update the cache, otherwise the CLI won't know about new commands that have been added since the cache was last updated.
There is a known issue where multiline line backtick string literals are not serialized
properly. This is an issue with thecode-stringify
package and is the reason why
this feature is still experimental.