Almost every modern project has a CLI application that helps developers in doing common or repetitive tasks: Angular for example has angular-cli, then there is create-react-app that helps us spinning up a React app in almost no time, vue-cli does the same thing with Vue.js and also webpack has its own webpack-cli.

There are countless benefits to build a custom CLI for our projects. At Immobiliare.it for example we have a lot of teams, each working on different projects and most of the time those projects shares the same stack. The problem is that sometimes some of those teams don't have a real front-end engineer setting up build tools and doing front-end performance optimizations as some of those projects are sometimes "server-side" only projects.

Our goal was to share the benefits we got in new projects with the ones who didn't have a proper front-end stack, but without the hassle of working on each one everytime we wanted to introduce a change.

What i didn't mention is that we already had a CLI app for another project that used rollup and TypeScript to make libraries builds, jest and karma to run tests over those libraries and so on, so our first thought was "Let's share it!" but not everyone needed every command in it, and we suddenly thought that at some point, someone could require a command to do something totally different, and we didn't want to make the work ourselves.

So "Let's share it in a pluggable and modular way!"

Setting things up

We are going to use yarn workspaces to speed up things. I'm assuming that each CLI Plugin will be developed in a different repository, probably by a different team, but since we are creating it we will use a monorepo architecture to build core plugins in a more hipster way 😎

$ mkdir pluggable-cli && cd pluggable-cli
$ yarn init --yes

then let's edit our package.json to make it look like this:

{
  "name": "pluggable-cli",
  "private": true,
  "workspaces": [
    "cli",
    "plugins/*"
  ]
}

The CLI Application

The first thing we are going to create is the actual CLI application. this application doesn't actually expose any command as it's just a wrapper for its plugins.

Lets create a package.json inside ./cli/:

{
    "name": "@pluggable/cli",
    "version": "0.0.0",
    "license": "MIT"
}

For this project we are going to use cosmiconfig to have an easy to use configuration parser and provider and yargs, which is (from their repository) a library that:

[...] helps you build interactive command line tools, by parsing arguments and generating an elegant user interface.
$ cd cli
$ yarn add cosmiconfig yargs

Lets then add a main field and a bin field:

{
  "name": "@pluggable/cli",
  "version": "0.0.0",
  "license": "MIT",
  "bin": {
    "pluggable-cli": "./bin/pluggable-cli"
  },
  "main": "index.js",
  "dependencies": {
    "cosmiconfig": "^5.0.6",
    "yargs": "^12.0.2"
  }
}

So, lets create the cli/bin/pluggable-cli file, which is the entrypoint for our command-line application:

#!/usr/bin/env node

require('../cli.js');

Lets also create a cli/index.js module, which will be the module we referenced as the main field in our cli/package.json. This module only exports cli configuration entries, so we will be able to use them inside any plugin just requiring @pluggable/cli:

const cosmiconfig = require("cosmiconfig");

module.exports = cosmiconfig("pluggable-cli");

and finally our cli/cli.js:

const config = require("./index");
const yargs = require("yargs");

config.plugins.map(plugin => require(plugin)).forEach(({ default: plugin }) => {
  plugin(yargs);
});

yargs
  .showHelpOnFail(false)
  .fail((msg, error) => {
    console.log(error || message);
  })
  .demandCommand()
  .help().argv;

What we are doing here is basically requiring every plugin listed in our configuration file and calling his exported function, passing it our yargs instance.

The configuration file

The core concept about our CLI application is it being pluggable and extendable with plugins. In our clic/cli.js we read a configuration file, extending our app with plugins defined there. Here's an axample pluggable-cli.json configuration file we will use:

{
    "plugins": [
        "@pluggable/plugin-sample"
    ]
}

An actual plugin

So lets get to the point of this whole article: plugins.

Each plugin is basically a function getting as parameter our command line application extending it.

Lets create another package, this time under the plugins directory:

// plugins/plugin-hello-world/package.json
{
    "name": "@pluggable/plugin-hello-world",
    "version": "0.0.0",
    "license": "MIT",
    "main": "index.js",
    "peerDependencies": {
        "yargs": "^12.0.1"
    }
}

And our actual first plugin:

// plugins/plugin-hello-world/index.js

module.exports = yargs => {
    yargs.command(
        'hello',
        'Prints \'Hello, World!\'',
        () => {},
        () => {
            console.log('Hello, World!');
        });
}

We just registered a command that prints 'Hello, World!' each time we call it:

Modular cli example

Wrapping it up

We just created a command line application that is capable of accepting plugins.

Our example is very simple and not very useful, but we can use that approach to share a webpack based build tool with every optimization in place, without the need to rewrite it for every project, or we can build some tools that can run test on our CI, or we can upload our assets to a CDN.
We have infinite possibilities and different teams can take care of just the commands they are interested in, and we don't need to merge them in our core package as they will be always able to just plug their stuff in!