DEV Community

Cover image for How to write a CLI in node.js
Lex Ned
Lex Ned

Posted on

How to write a CLI in node.js

I never created a CLI in node.js before. I wanted to build something useful but easy to implement. I don't remember how I came up with writing a CLI for a key-value store. It seemed this would be a great small project for learning.
Now that I knew what to do, I had to find a name for it. All I could come up with is key-value-persist. This name is uninspiring, but it does the job. It is descriptive enough. Maybe I should have added the "cli" suffix to it?

Starting out

I like it when I know what to do from the get-go. I feel this starts building momentum.

npm init -y
Enter fullscreen mode Exit fullscreen mode

I now had the project initialized. Next, I had to investigate what node.js modules to use. It turns out that "commander" is one of the most used for building CLIs.

npm install --save commander
Enter fullscreen mode Exit fullscreen mode

How about the key-value store? It was time to search on npm for a solution. That's how I found "keyv". It is a key-value store with a simple interface and multiple storage options. Exactly what I needed.

npm install --save keyv
npm install --save @keyv/sqlite
Enter fullscreen mode Exit fullscreen mode

I decided to go with the SQLite storage for simplicity.
I also wanted to test the implementation, so I installed jest.

npm install --save-dev jest
Enter fullscreen mode Exit fullscreen mode

Project structure

At first, I just had a file that contained simple logic.

const commander = require('commander');
const commandPackage = require('../package.json');
const Keyv = require('keyv');

commander
    .version(commandPackage.version)
    .description(commandPackage.description)
    .usage('[options]')
    .option('-n, --namespace <namespece>', 'add key value pair to namespace', 'local')
    .option('-s, --set <key> <value>', 'set value for key')
    .option('-g, --get <key>', 'get value for key')
    .option('-d, --delete <key>', 'delete key value pair')
;

commander.parse(process.argv);
const keyv = new Keyv(`sqlite://${__dirname}/data.sqlite`, {namespace: commander.namespace});
keyv.set('test', 'val').then(() => {
    keyv.get('test').then((val) => {
        console.log(val);
    });
});
Enter fullscreen mode Exit fullscreen mode

As you can see, I did not integrate the data persisting with the CLI. I wanted to know if they worked on their own. I could figure out the integration later.
After verifying that these node.js modules can do the job, I wondered how to structure the project. I had two things to take care of: the CLI and the data persistence. That's how I came up with the directory structure of the project.

.
├── src
│   ├── command
│   └── data-persistence
└── test
    ├── command
    └── data-persistence
Enter fullscreen mode Exit fullscreen mode

Building the CLI

Building the CLI was similar to what the "commander" documentation was describing. I only wrapped the functionality in a new object. You know, for when you want to change the node.js module responsible for the CLI.

const commander = require('commander');
const commandPackage = require('../../package.json');

function Command() {
    const command = new commander.Command()
    command
        .version(commandPackage.version)
        .description(commandPackage.description)
        .usage('[options]')
        .arguments('<key> <value>')
        .option('-s, --set <key> <value>', 'set value for key')
        .option('-g, --get <key>', 'get value for key')
        .option('-d, --delete <key>', 'delete key value pair')
    ;

    this.command = command;
}

Command.prototype.parse = function (args) {
    this.command.parse(args);
}

module.exports = {
    Command
}
Enter fullscreen mode Exit fullscreen mode

I instantiated the "commander" in the constructor, defined the command options, and exposed a method for parsing the command arguments.

Then I had to create the data persister. I wrote methods for getting, setting, and deleting data.

const Keyv = require('keyv');

function Persister() {
    this.keyv = new Keyv(`sqlite://${__dirname}/../../data/data.sqlite`);
}

Persister.prototype.set = function(key, value) {
    return this.keyv.set(key, value);
}

Persister.prototype.get = function (key) {
    return this.keyv.get(key);
}

Persister.prototype.delete = function(key) {
    return this.keyv.delete(key);
}

module.exports = {
    Persister
}
Enter fullscreen mode Exit fullscreen mode

Then I had to make the command work with the persister. I had to call the proper action in the persister given a command option.

const {Persister} = require('./data-persistence/persister');
const {Command} = require('./command/command');

const command = new Command();
const persister = new Persister();
command.parse(process.argv);
Enter fullscreen mode Exit fullscreen mode

At this point, I did not have a way to find what option and what key-value pair I sent to the command. I had to add the missing methods to the command object.

Command.prototype.isGetCommand = function () {
    return !!this.command.get;
}

Command.prototype.isSetCommand = function () {
    return !!this.command.set;
}

Command.prototype.isDeleteCommand = function () {
    return !!this.command.delete;
}

Command.prototype.getKey = function () {
    if (this.isGetCommand()) {
        return this.command.get;
    }

    if (this.isSetCommand()) {
        return this.command.set;
    }

    if (this.isDeleteCommand()) {
        return this.command.delete;
    }

    throw new Error('The key is not defined');
}

Command.prototype.getValue = function () {
    return this.command.args.length !== 0 ? this.command.args[0] : "";
}
Enter fullscreen mode Exit fullscreen mode

Next, I could add the logic that called the persister based on a command option.

if (command.isGetCommand()) {
    persister.get(command.getKey()).then((value) => {
        if (value) {
            process.stdout.write(`${value}\n`);
        }
    });
}

if (command.isSetCommand()) {
    persister.set(command.getKey(), command.getValue());
}

if (command.isDeleteCommand()) {
    persister.delete(command.getKey());
}
Enter fullscreen mode Exit fullscreen mode

I had almost everything working. Next, I wanted to show the help information. It was for when the command options were not valid.

Command.prototype.isCommand = function () {
    return this.isGetCommand() ||
        this.isSetCommand() ||
        this.isDeleteCommand();
}

Command.prototype.showHelp = function () {
    this.command.help();
}
Enter fullscreen mode Exit fullscreen mode

The main file was getting bigger. I did not like how it turned out. I decided to extract this functionality to a separate object. That's how I came up with the command-runner object.

function CommandRunner(command, persister) {
    this.command = command;
    this.persister = persister;
}

CommandRunner.prototype.run = function (args) {
    this.command.parse(args);

    if (!this.command.isCommand()) {
        this.command.showHelp();
    }

    if (this.command.isGetCommand()) {
        this.persister.get(this.command.getKey()).then((value) => {
            if (value) {
                process.stdout.write(`${value}\n`);
            }
        });
    }

    if (this.command.isSetCommand()) {
        this.persister.set(this.command.getKey(), this.command.getValue());
    }

    if (this.command.isDeleteCommand()) {
        this.persister.delete(this.command.getKey());
    }
}

module.exports = {
    CommandRunner
}
Enter fullscreen mode Exit fullscreen mode

I'm passing the command and the persister to it. I took this decision for easier testing. It also permits changing the implementation for the command and persister objects without changing the integration part. Now my main file was simpler.

const {Persister} = require('./data-persistence/persister');
const {Command} = require('./command/command');
const {CommandRunner} = require('./command/command-runner');

const command = new Command();
const persister = new Persister();
const runner = new CommandRunner(command, persister);
runner.run(process.argv);
Enter fullscreen mode Exit fullscreen mode

Testing

I decided to write unit tests only. I did not want to complicate things. I did not want to create a test database just for creating integration tests.
When writing tests, I had two issues. One was that the "commander" module was exiting the process on certain occasions. The other one was that I had to capture the command output. In both cases, I used jest spies.

const {Command} = require('../../src/command/command');

describe("Command", () => {
    describe("#parse", () => {

        test("parses valid options", () => {
            const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
            const command = new Command();
            command.parse(['-g', 'test-key']);
            expect(consoleErrorSpy).toHaveBeenCalledTimes(0);
        });

        test("exits with error on non existent option", () => {
            const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
            const processExitSpy = jest.spyOn(process, 'exit').mockImplementation();

            const command = new Command();
            command.parse([
                    'app',
                    'kvp',
                    '-b'
                ]
            );
            expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
            expect(consoleErrorSpy).toHaveBeenCalledWith("error: unknown option '-b'");
            expect(processExitSpy).toHaveBeenCalledTimes(1);
            expect(processExitSpy).toHaveBeenCalledWith(1);
        });

        test("exits with error on non existent option argument", () => {
            const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
            const processExitSpy = jest.spyOn(process, 'exit').mockImplementation();

            const command = new Command();
            command.parse([
                    'app',
                    'kvp',
                    '-g'
                ]
            );
            expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
            expect(consoleErrorSpy).toHaveBeenCalledWith("error: option '-g, --get <key>' argument missing");
            expect(processExitSpy).toHaveBeenCalledTimes(1);
            expect(processExitSpy).toHaveBeenCalledWith(1);
        });
    });
});
Enter fullscreen mode Exit fullscreen mode

The remaining tests don't introduce new concepts. I will not present them here. You can check them out at https://github.com/thelexned/key-value-persist.

Installing the command globally

I wrote the app and the tests. Now I had to find a way to install it globally. It seems that npm has this functionality. But before installing it, I had to add a bin attribute to the package.json file. For this, I wrote a script that would execute the CLI's main file.

#!/usr/bin/env node
require('../src/index.js');
Enter fullscreen mode Exit fullscreen mode

Then I added the bin attribute to package.json.

"bin": {
  "kvp": "./bin/kvp"
}
Enter fullscreen mode Exit fullscreen mode

The only thing left was to install the CLI globally.

npm link
Enter fullscreen mode Exit fullscreen mode

I could now run the CLI from anywhere.

kvp --help
Enter fullscreen mode Exit fullscreen mode

TLDR

It might take you less time reading the code https://github.com/thelexned/key-value-persist.

Top comments (0)