Dev Diary: 5 Tips for Building Beautiful CLIs with Node.js, Yargs, & Ink

John O'Sullivan
Eximchain

--

Back when we first came out with a product which could build a dapp from an ABI, the dream was to integrate that functionality into the center of the blockchain developer’s workflow: their Truffle projects.

The Truffle Suite of developer tooling is an essential part of prototyping smart contracts. If we were going to successfully shorten the development cycle, users couldn’t be copy-pasting ABIs from build files to browsers. With our new dappbot command on npm, you can create a hosted, scalable interface in under 5 minutes for any contract compiled & deployed through Truffle.

Usage

As a Truffle user, you are now just three commands away from creating a dapp. Run the following commands from the root of your project:

  1. npm install @eximchain/dappbot-cli -g : Install the CLI, which will automatically show you some sample commands.
  2. dappbot signup : Interactively create an account, verify your email, and log in for the first time — your login is stored in a dappbotAuthData.json file.
  3. dappbot truffle: Walks you through choosing:
    1. Which of your smart contracts would you like to make a dapp for?
    2. Which one of the contract’s deployed networks should the dapp talk to?
    3. What are you naming your dapp? (e.g. hub.dapp.bot/my-new-dapp).

The CLI also supports all of the API’s non-payment methods under the dappbot api subcommand. For instance, you can inspect your current set of dapps by calling dappbot api private/listDapps:

4 Key Dependencies

We wrote @eximchain/dappbot-cli in Typescript, standing on top of four key dependencies:

  • yargs is “a node.js library fer hearties tryin’ ter parse optstrings.” It comes out of the box with pretty help output, strong sub-command support, and a middleware system for pre-processing arguments before they get to the handler. More on those later.
  • ink is “React for CLIs. Build and test your CLI output using components.” When you run an npm install and see dynamic output, most of it disappearing when the command completes, you’re seeing ink in action. If you’ve done any amount of string munging to make pretty CLIs, you’ll see why ink is an absolute godsend.
  • @eximchain/dappbot-types is our open-source Typescript library which collects all the key types from across the DappBot system. It comes with type guards for validating object shapes, factory functions to produce sample objects, and constants describing every part of the API.
  • @eximchain/dappbot-api-client is our open-source node library for interacting with the DappBot API. It just needs a place to store authentication data and is then able to perform all requests to the DappBot API. It leverages our types package so that every function is type checked.

Yargs + Ink Project Structure

Our previous usage with a yargs CLI was to configure one or two commands right on its main export, but for more involved CLIs like this one, yargs has .commandDir(). Rather than fully inlining every command like this:

You can instead create a set of nested modules, where each module exports a key corresponding to the values above:

This module structure makes it easy to lay out nested subcommands while keeping each file tidy. Our overall file structure looks like the tree below, where each file within src/rootCmds has exports like the sample above:

At startup, src/cli.tsx calls yargs.commandDir('./rootCmds') to load each of the top-level commands. When src/rootCmds/api.tsx is loaded, it then calls yargs.commandDir() on each of the authCmds, privateCmds, and publicCmds directories, mounting all of their commands beneath dappbot api.

All of the actual React code lives within src/ui, distinct from the yargs config code. Each command module imports the interface it needs, configures it, then calls ink’s render in the handler. For example, below is the handler function for the simplest command, dappbot goto <DappName>. It renders a success message, then uses open() to open the correct URL in the user’s default browser.

Each and every handler function has a render call like the one on line 5.

This layout made it easy to separate concerns about individual command configuration from the rendered results, allowing more code reuse across commands.

Without further ado, here are 5 things we learned on the path to ensuring this CLI has a clean user experience:

1. Customize command name(s) with “bin” object

In our previous node CLIs, like abi2api, the command name matched the package name. When that was the case, we declared the package as a binary command by setting package.json's "bin" key to the built index filepath, as shown below, which made the command name default to the package’s.

// package.json
{
"name": "abi2api",
"bin": "build/index.js",
...
}

In this new project, we didn’t want the CLI to take the @eximchain/dappbot package name, but we also wanted the command to be as straightforward as possible. We looked at how Typescript does it, as their command name is tsc, and discovered npm’s syntax for declaring multiple commands:

// package.json
{
"name": "@eximchain/dappbot-cli",
"bin": {
"dappbot": "build/cli.js"
},
...
}

This package.json syntax lets you declare multiple commands pointing to different JS files, along with customizing all of their names. The more you know!

2. Onboarding via hidden commands & “postinstall”

We want our users to be able to get going without ever leaving the command line; that means the traditional “onboarding process” needs to be triggered by the user installing the package. Our solution was to output a custom onboarding message after the user installs the package, showing the key commands they need to successfully get started. We used two different features to accomplish this:

Run npm i -g @eximchain/dappbot-cli and you will see our CLI onboarding information.

First, we added a command dappbot onboarding which would output this custom message. This let us create a custom message which is simpler than the full command help text. Plus, we could add fun text art through ink-big-text! However, we didn’t want this command to appear in help messages. Luckily, yargs has an idiomatic way to hide commands:

By providing false instead of a string, yargs know not to display this command in the help message.

Finally, we triggered this new command via the postinstall script in package.json.

// package.json
{
...,
"scripts": {
"postinstall": "node build/cli.js onboarding",
...,
}
}

npm supports both pre- and post- scripts for any built-in or custom commands. Setting our postinstall script to node build/cli.js onboarding let us automatically trigger the message for new users. Note that the build file is called directly; this behaves correctly in development, whereas dappbot onboarding would only work when the package is actually installed globally.

3. Automatically Loading Files with Middleware

The CLI accepts a few options which represents paths to files, like when you’re manually performing the API call to update a Dapp:

$ dappbot api private/updateDapp --abiPath ./path/to/Abi.json

This problem came up multiple times, so we used middleware and a naming convention to solve it generically for all commands. The function below checks every option passed to every command, and if its name ends in “Path”, it loads the contents of the file there as a string — throwing an error if none was found.

This solution made for ergonomic development, as the command was able to simply configure an AbiPath option for a filename, then the handler could check for its contents on AbiFile:

4. Streamline per-command setup with renderProps

One of the big differences about using React in a command-line app is how much more often you call the render() function. In most browser React apps, render() is called once within a root index.js file, or a script tag on index.html. Our CLI, however, essentially has a separate “mount point” for each sub-command.

Yargs feels somewhat like writing an Express router, where each handler is responsible for returning its own response. The middleware system also reminded me of Express. The downside of this is that each command needs to render the entire UI, including all necessary wrappers and setup logic.

In our case, this means initializing the API client with a mechanism for storing authentication data (like the setter from React’s useState()). The renderProp pattern worked well here, as the top-level App component setup all the boilerplate, then command rendering it could use that boilerplate in the render function. This made for some short api handler functions, like this one for dappbot api public/viewDapp:

The API instance provided as an argument to renderFunc is fully configured and has the user’s stored authentication data, if any is present. <App />'s implementation creates API and invisibly solves a second problem: including the <RequestProvider />. The comments on this streamlined version of the component walk through the key boilerplate setup:

The most important pieces to note are:

  • line 11, where we use args.authFile to initialize the authData state
  • line 30, where we configure react-request-hook’s <RequestProvider>
  • line 34, where we call renderFunc along with the freshly configured API.

5. Manage Auth with Hooks, Middleware, & “fs”

Last but not least, the thorniest problem we solved when building this CLI client was managing authentication and persistent sessions. Our solution has a few key pieces which work together to provide a seamless development experience.

Persisting Auth Data with “fs”

First, we needed a way to conveniently persist authentication across run-throughs. Our naive solution was to just accept a path to an authData file, but it wasn’t great to make the user manage the location of their authentication. Instead, we settled on the tiny authStorage.ts service below.

Notice how on line 4, we form the file path based on the __dirname variable which corresponds to this file’s directory (dappbot-cli/build/services).

When you npm install --global, the package gets saved to a shared node_modules located alongside your node binary. The exact location of this varies depending on your configuration (e.g. using a node version manager), but it is stable location where we can always check for the current user’s file. This saveAuthToFile function is used around the CLI, wherever we want to persist authData we just received from the server.

Loading & Requiring Auth with Middlewares

We then have two key middlewares for managing auth. First, addDefaultAuthPath checks for a manually provided authPath option and then adds the AUTH_FILE_PATH if none exists. We mount this on the top-level command, ensuring that every sub-command has access to an authFile:

Next, we wanted to easily restrict some sub-commands to only be called when the user is logged in. Rather than just getting an Unauthorized error, it’s cleaner to get an error which lets the user know what they need to do. This heavily-commented version of the requireAuthData() middleware shows how we validate the authFile and bail out the process if necessary.

These validators all come from our shared types package; we are performing the exact same checks which happen on our React web client. The requireAuthData() middleware is mounted on a per-command basis, letting us separately decide which ones should require active login:

The builder function exported by `src/rootCmds/privateCmds/listDapps.tsx`.

Auto-Refresh Auth with React Hooks & renderProps

Finally, we completed the cycle by automatically refreshing stale authData. The refresh loop is one of those frustrating things which good toolsets keep in the background. Endusers don’t want to think about it, and neither do developers creating new subcommands. We were able to enable this clean user and developer experience by making three key upgrades to the <App> container component. Let’s walk through them:

  1. On lines 7–10, we upgraded the API’s setAuthData() to also persist new authData to the filesystem, guaranteeing that when it is called, the new value is persisted on the next run.
  2. On lines 14–18, we added an effect which checks if the API is stale, and if necessary, calls its API.refreshAuth() helper method. This will internally perform the login request and call the provided setAuthData() function, updating the file.
  3. On lines 20–22, we made <App> wait until the authData is no longer stale before calling the renderFunc. If we have to wait, we show the user a loader. This means that the renderFunc will never have even a single render with stale authData, simplifying our mental model when developing individual sub-commands

The middlewares, hook, and file system work together to create a seamless development and user experience. Here is the handler for dappbot api private/listDapps, not having to worry about authentication:

and here is the clean UX from the GIF at the beginning of the post:

Wrapping Up

We hope that this blog post shines some light for future developers who are building their own node.js CLIs. The decision has paid off in spades for us, making it much easier to test and interact with DappBot. If you go down this path and find that one of these tips help you, let us know in the comments.

Happy Hacking!

About Eximchain

Eximchain enables businesses to connect, transact, and share information more efficiently and securely through our blockchain utility infrastructure. Using Eximchain blockchain technology, enterprises can eliminate traditional supply chain barriers and integrates actors big and small into an efficient, transparent, and secure global network.

See Eximchain’s GitHub

Follow us on Twitter

--

--

John O'Sullivan
Eximchain

Full stack engineer entrepreneur who believes in good governments with strong governance, currently building bridges to blockchains at @Eximchain .