Building a CLI application from scratch in JavaScript
Posted on: December 18, 2019 by James John
In this tutorial, we are going to be building a CLI application from scratch in JavaScript.
To follow in this tutorial, you need to have node.js installed on your machine since the JavaScript code we will be writing will not be executed on the browser.
You will also need some basic knowledge of Javascript to continue this tutorial.
So, let’s get started.
We will be building a simple billing application.
Initialization
Create a new folder called biller. From your terminal, move into the directory and initialize npm by running these commands:
cd biller
npm init -y
npm install chalk
This tutorial will also use ES6 modules, so we would need to install ESM to take care of that for us.
npm install chalk
Create a new file: index.js
in the project’s root directory. This will act as the entry route of our application.
Add the following to index.js.
#!/usr/bin/env node
console.log('The biller app')
On line 1 is something called a shebang.
The shebang #!
is a human-readable instance of a magic number consisting of the byte string 0x23
0x21
, which is used by the exec() family of functions to determine whether the file to be executed is a script or a binary. Or the shebang is a way of telling UNIX-like systems to use the node interpreter located at /usr/bin/env
in executing the file.
This will also make npm run the code using the node executable.
To run this code, modify the package.json file to include the following:
{
...
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"biller": "./index.js"
},
"keywords": [],
...
}
What to take note of here is the bin
field.
What this does is on install, it creates a symbolic link for index.js — which was specified on line 7 above, to be accessed globally (global installation) or from ./node_modules/bin if the installation wasn’t global.
To use this locally, run:
npm link
If you monitor the output from your terminal while npm link
is running, you will see information regarding the symbolic link that is being created.
When that is done, we can run the app on the terminal running
biller
Your terminal should output
The biller app
If you have an error, ensure that the shebang is on the first line.
If you did not add the shebang, nothing will be outputted on the screen.
Congratulations!!!
You have built your first CLI application. Now let’s add some functionality to our billing app.
Building the CLI
Create ansrc
folder. This folder will house all the logic for our billing application.
In the src folder, create a cli.js file.
Add the following to cli.js:
export function cli(args) {
console.log(args);
}
We are exporting a function called cli
. This function will be imported in index.js which was created earlier on.
Modify index.js to look like:
#!/usr/bin/env node
// modify require to use esm which allows ES6 imports.
require = require('esm')(module);
// import the cli function from cli.js
require('./src/cli').cli(process.argv);
On line 4, we are reassigning require to use esm
instead.
The cli
function is imported on line 7. We are passing process.argv to the cli
function. process.argv
contains all the arguments that will be passed when running the app from the terminal.
On your terminal, run:
biller
to see the content of process.argv.
It will output something like:
Try running this instead:
biller help
It will output:
As we can see, adding more flags when running biller will push them to the process.argv
array.
We want to have something like:
biller bill --id 2 amount 100
Hence, we need to be able to parse the contents of process.argv
to extract just what we need.
We’ll create a new function in cli.js to handle the parsing.
Add the following to cli.js underneath the cli
function.
// cli.js
function parseArguments(args) {
args.splice(0, 2);
const operation = {
type: args[0]
}
args.forEach(arg => {
if (arg.includes('--id')) {
const userId = Number(args[args.indexOf(arg) + 1]);
if (Number.isNaN(userId)) {
throw new Error('Item after `id` must be a number');
}
operation.userId = userId;
}
if (arg.includes('--amount')) {
const amount = Number(args[args.indexOf(arg) + 1]);
if (Number.isNaN(amount)) {
throw new Error('Item after `amount` must be a number');
}
operation.amount = amount;
}
});
if (!(operation.hasOwnProperty('userId') && operation.hasOwnProperty('amount'))) {
throw new Error('Must specify userId and amount via --id `id` and --amount `amount`')
}
return operation
}
There’s a lot happening here since we are doing some validation as well, but it’s nothing too complex.
On line 3, we are removing the first two elements in the process.argv
array which is what will be received by the parseArguments
function. We can do this because we are sure that the first 2 elements are not user-defined and we don’t need them.
On line 5, we are setting operation.type
to be the first element of the array. If biller was run with the following arguments:
biller bill --id 2 --amount 100
, args[0]
will be bill.
On line 7, we are looping through the args array to get all other arguments.
Remember that we are only concerned with --id
and --amount
so we will ignore all other arguments.
From line 8 to line 14, we are checking if --id
was sent as a part of the argument, and we get the item passed after it. The item passed after --id
should be a number representing the user id. We have validation in place to ensure that it is a number. If it is not a number, we throw an exception with the appropriate message.
If the user passed the correct value after --id
, then the number will be stored as operation.userId
.
A similar thing is done for --amount
in from line 15 to line 21.
After parsing the arguments, our parseArguments
function will return an object containing the type of operation that is being carried out, the userId
for which the operation is to be carried upon, and the amount.
Let’s use our parseArguments
function in the cli
function.
// cli.js
export function cli(args) {
const operation = parseArguments(args);
console.log(operation);
}
Running biller will output:
Now that we can parse the arguments correctly, we need to have a store for our user data.
We will store the user data in a JSON file, and update it as required.
In the src folder, create a store.json file with the following content:
[
{
"id":1,
"name":"James John James",
"balance":200000,
"canWithdraw":true
},
{
"id":2,
"name":"Bosun Egberinde",
"balance":500,
"canWithdraw":true
}
]
We can now fetch the content of store.json in our cli
function.
Modify the cli
function to fetch the content of store.json by adding the following to the end of the function.
const store = require('./store.json');
After fetching the contents of store.json, we can now implement the billing functionality.
Modify the cli
function to look like:
import * as fs from 'fs';
export function cli(args) {
// parse arguments
const operation = parseArguments(args);
// load from store
const store = require('./store.json')
// perform billing
if (operation.type === 'bill') {
const currentUser = store.find(item => item.id === Number(operation.userId));
// check if user exists
if (!currentUser) {
throw new Error(`No user with id ${operation.userId}`)
}
// check if user can be billed
if (currentUser.canWithdraw && currentUser.balance >= Number(operation.amount)) {
// update user balance
currentUser.balance -= operation.amount;
currentUser.canWithdraw = !(currentUser.amount === 0);
// update store with new user data to reflect the current amount
fs.writeFile(`${__dirname}/store.json`, JSON.stringify(store), function(err) {
if (err) {
throw new Error(err.message);
}
console.log(`Successfully billed ${currentUser.name} ${operation.amount}`);
})
} else {
throw new Error(`Unable to bill ${currentUser.name}`)
}
}
}
We imported fs
in line 1 since we will be writing the updated contents of store
to store.json.
The new additions to the cli
function starts from line 9.
First, we check the type of operation to be carried out. If it is a billing operation, we proceed with the billing. Currently, no other operation is supported.
Next, we check if there is any user with the id that was passed as an argument. If no user is found, we throw an exception.
If a user is found, we check if we can bill the user. To bill the user, the current user balance must be greater than or equal to the amount to be billed, and the canWithdraw
property of the user must be true
. If this check fails, we throw an exception on line 30.
If the user is billable, we bill the user — reduce his balance, and set his withdrawal status.
Next, we update the store
with the new user details, using fs.writeFile
.
We throw an exception if something wrong or unexpected happened while updating store.json.
If this is successful, we print a success message to the user.
Great! Our biller is working correctly.
However, success messages should be coloured with a great colour say green, and we are currently throwing exceptions in our application which will break the app.
We can, however, handle the exceptions in a good way.
First, let’s change the colour of our success messages.
Import chalk
in cli.js.
Add the following just below the import for fs
.
import chalk from 'chalk';
Update the console.log statement to:
console.log(chalk.greenBright(`Successfully billed ${currentUser.name} ${operation.amount}`));
Running biller will now output:
Now to handle exceptions, modify index.js to look like:
#!/usr/bin/env node
// modify require to use esm which allows ES6 imports.
require = require('esm')(module);
const chalk = require('chalk');
try {
// import the cli function from cli.js
require('./src/cli').cli(process.argv);
} catch(error) {
console.log(chalk.redBright(`Error: ${error.message}`))
}
We wrap the call to cli
in a try … catch block.
This way, we can catch all exceptions and just output the error message to the user.
Congratulations!!!
You have been successfully built a CLI application from scratch. If you had issues while going through this tutorial, leave a comment below and I will make sure to respond appropriately. The source code for this can be found here.Share on social media
//