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
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
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
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
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);
});
});
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
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
}
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
}
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);
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] : "";
}
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());
}
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();
}
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
}
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);
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);
});
});
});
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');
Then I added the bin attribute to package.json.
"bin": {
"kvp": "./bin/kvp"
}
The only thing left was to install the CLI globally.
npm link
I could now run the CLI from anywhere.
kvp --help
TLDR
It might take you less time reading the code https://github.com/thelexned/key-value-persist.
Top comments (0)