CLI Architecture in NodeJS
By: Christian Clausen
Tags: Typescript, Command Line Interface, Cli, Architecture

Software Craftmanship

CLI Architecture in NodeJS

How to create great command-line tools with great architecture.

When it comes to excellent command-line tools, one stands out in my mind: Git. The Git CLI uses modes, then flags, sometimes with arguments, and then the command argument. This structure is flexible, friendly to use, and pleasant to work with. It is, however, not trivial to implement in a generalized way, especially if we want it to be strongly typed.

I have implemented a small but sophisticated library for parsing command-line arguments in the Git style. In this post, I detail how to use it and its key features. If you want to look at the source code behind it all, you can check it out over at my GitHub: https://github.com/thedrlambda/typed-cmdargs.

Let’s examine how the library works by showing how we would implement a small CLI tool for managing repositories on GitHub from the command line.

Combining functions with modes and arguments

When we have multiple tasks to perform from the command line, we have three options.

1. We could make a separate tool for each.

createrepo fib

listrepos

2. We could make flags that specify which task we want to perform.

repo --create fib

repo --listrepos

3. We could make the first argument special, and have that select which task to perform. This first argument is what we call the mode.

repo create fib

repo list

Having modes means we can combine as many functions as we want into our tool, without them affecting each other in any way.

Notice that one of the commands above take an argument ( fib ) while the other does not.

My library relies on modes, and we can set up two modes like above with this code:

let argParser = new ArgumentParser()

.push("create", {

desc: "Creates a new repository",

arg: "name",

construct: (arg) => new Create(arg),

flags: {},

})

.push("list", {

desc: "Lists existing repositories",

construct: () => new List(),

flags: {},

});

Each push corresponds to a mode, and needs to return a Command in the construct, so the intention is to create a class for each mode, like this:

class Create implements Command {

constructor(private name: string) {}

execute() {

// Code for creating a repo

}

}

class List implements Command {

constructor() {}

execute() {

// Code for fetching and displaying repos

}

}

Instructing modes with flags

When creating GitHub repos we have an option whether to make it public or private. We can expose this option in our CLI through flags.

Simple flags

In the case of private, it is simple to add to our command. We need to supply a default value and what happens when the flag is set — the override value. Further, some flags may be so common we want to provide the user with a convenient one letter version too. In my library, it looks like this:

.push("create", {

desc: "Creates a new repository",

arg: "name",

construct: (arg, params) => new Create(arg, params),

flags: {

private: {

desc: "Make the repository private",

short: "p",

overrideValue: true,

defaultValue: false,

},

},

});

When we define flags like this, they are automatically put into a params object that can be passed to the constructor of the mode-class. This object contains bindings from each flags long name to its value. So this call would give this value:

repo create -p fib

{ private: true } repo create --private fib

{ private: true } repo create fib

{ private: false }

The defaultValue and overrideValue can be anything as long as they are the same type.

Flags with arguments

Another option we have when creating a GitHub repo is whether to include a default .gitignore file. This option is not like private because we need to know which programming language the repo is supposed to use. My library supports flags taking string arguments if we add an arg field giving the argument a name and changing the overrideValue to a function.

To get the cleanest architecture, we should make classes for these flags, so we don’t need to check their values but instead can invoke an act method. It looks like this:

interface IgnoreAction {

act(): void;

}

class DownloadIgnoreFile implements IgnoreAction {

constructor(private language: string) {}

act() {

// code to download the file

}

}

class DoNothing implements IgnoreAction {

act() {}

}

let argParser = new ArgumentParser()

.push("create", {

desc: "Creates a new repository",

arg: "name",

construct: (arg, params) => new Create(arg, params),

flags: {

ignore: {

desc: "Download a .gitignore file",

short: "i",

arg: "language",

overrideValue: language => new DownloadIgnoreFile(language),

defaultValue: new DoNothing(),

},

},

});

Doing multiple things with chaining

We have now discussed the basic features of the library, and we can make friendly CLI tools. But I also decided to add some extra UX features.

Flags

The first is inspired again by Git, where my most frequent command is git commit -am “…” . The flag in this case is the combination of the two flags -a and -m but combined into one. I think this is a neat feature, so I decided to put it into the tool automatically, so combining the example above, we already support out of the box, running commands like:

repo create -pi javascript fib

Combining the two flags -p and -i . Note: You cannot chain long flags. If you chain flags that take arguments, the arguments are passed in the order they appear.

Commands

Flags are not the only thing we can chain. While working with the library I discovered that some commands are often run after each other — like git commit followed by git push . If we do this with two calls, we have to initialize everything each time, which can be slow. To remedy this, my library automatically allows chaining modes, which equates to calling the tool multiple times. In our running example, the tool automatically supports creating two repos and then listing them using this command:

repo create fib create fac list

Equivalent to:

repo create fib

repo create fac

repo list

Helping the user

Being able to chain commands means we cannot make a help mode, because calling repo help create would be interpreted as:

repo help

repo create

Therefore help has to be handled separately at the core. The library support displaying help in a standard way through the method helpString . Here is the code I use as entry for my CLI tools:

if (process.argv[0].endsWith("node.exe")) process.argv.splice(0, 1);

process.argv.splice(0, 1);

if (process.argv[0] === "help") {

console.log(argParser.helpString(process.argv[1]));

} else {

let cmds = argParser.parse(process.argv);

cmds.forEach((cmd) => cmd.execute());

}

It is not perfect, but it is an excellent start.

Conclusion

We now have a great CLI tool, complete with modes, simple flags, flags with arguments, chaining flags and commands, and a neat help menu. Further, our architecture is decoupled and easily extendable. A feature that we have only mentioned is that everything is entirely type-safe! Another important property for me is that it uses no external libraries to run, which means it is very lightweight, and easy to verify that there is nothing dangerous in its 100 lines.

For much more about software architecture, check out my refactoring book: