Build a CLI application in JavaScript

Building a CLI application from scratch in JavaScript


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 an src 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.
CLI application
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:
CLI application
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.
CLI application

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

//