Build An Interactive Command-Line Application with Node.js

Rowland Ekemezie

JavaScript has evolved over the last five years with the introduction of Node.js. Basically, It serves as a language that can be used for Front end application, server side application, desktop application, mobile application, etc.

The ecosystem is growing really fast with different innovation and technologies springing out from it. StackOverflow's recent survey shows that Javascript is by far the most commonly used programming language on earth.

That said, over the past 5 years, Node.js has allowed developers to write command-line tools that are interactive and very easy to use.

If you have used git, heroku, gulp, grunt or any other package that allows you to bootstrap your project like create-react-app, angular-cli, yeoman, etc, you've used the power of Node.js in writing command-line tools. I think it's easy and simple to start writing your own command-line tools today with Node.js and its libraries for increased productivity.

Project description

We will be building an interactive command-line contact management system that allows users to create, read, and manage their contacts from the shell(command prompt). This will allow users to perform basic CRUD operation from the shell with ease.

Here is a demo of the application we're going to build

We will use Node.js as the core framework, commander.js for command-line interfaces, inquirer.js for gathering user input from the command-line, and MongoDB for data persistence.

Technologies

1) Node.js - A server-side platform wrapped around the JavaScript language with the largest package(npm) ecosystem in the world.

2) Commander.js - An elegant and light-weight command-line library for Node.js.

3) Inquirer.js - A collection of powerful interactive command-line interfaces.

4) MongoDB - A free and opensource NoSQL document-oriented database.

Here is a picture of what the application workflow looks like. Fig: Project workflow

Steps to building an interactive command-line application with Node.js

1) Project setup

2) Define application logic

3) Handle command-line arguments

4) Interactive run time user inputs

5) Convert application to a shell command

6) More application logic

Step 1 of 5: Project setup

Ensure the version of your installed Node.js is >=6. Run node --version on your terminal to verify. We will use yarn, a package manager for Javascript developed by Facebook to manage our project dependencies. If you don't have it installed, run

$ npm install -g yarn

You can also use npm to run your application as yarn is just a personal preference though with some advantages.

Let's create a project directory and initialize it as a Node.js application.

$ mkdir contact-manager # Create project directory
$ cd contact-manager && yarn init # Initialize the directory as a Node.jS application and follow the prompt
$ yarn add mongoose inquirer commander # Add the dependencies we need for the project

Now that we have our Node.js application initialized, you will see package.json in your project's root directory with updated dependencies to get started.

{
  "name": "contacto",
  "version": "1.0.0",
  "description": "A command-line contact management system",
  ............
 "dependencies": {
    "commander": "^2.9.0",
    "inquirer": "^3.0.6",
    "mongoose": "^4.9.2"
  }
}

Step 2 of 5: Define application logic

In this section, we will define our schema, model, and the controller functions that handles user input and persist them to the database.

This step requires that your MongoDB server is running.

Let's create logic.js in the project directory

const mongoose = require('mongoose'); // An Object-Document Mapper for Node.js
const assert = require('assert'); // N.B: Assert module comes bundled with Node.js.
mongoose.Promise = global.Promise; // Allows us to use Native promises without throwing error.

// Connect to a single MongoDB instance. The connection string could be that of remote server
// We assign the connection instance to a constant to be used later in closing the connection
const db = mongoose.connect('mongodb://localhost:27017/contact-manager');

// Converts value to lowercase
function toLower(v) {
  return v.toLowerCase();
}

// Define a contact Schema
const contactSchema = mongoose.Schema({
  firstname: { type: String, set: toLower },
  lastname: { type: String, set: toLower },
  phone: { type: String, set: toLower },
  email: { type: String, set: toLower }
});

// Define model as an interface with the database
const Contact = mongoose.model('Contact', contactSchema);

/**
 * @function  [addContact]
 * @returns {String} Status
 */
const addContact = (contact) => {
  Contact.create(contact, (err) => {
    assert.equal(null, err);
    console.info('New contact added');
    db.disconnect();
  });
};

/**
 * @function  [getContact]
 * @returns {Json} contacts
 */
const getContact = (name) => {
  // Define search criteria. The search here is case-insensitive and inexact.
  const search = new RegExp(name, 'i');
  Contact.find({$or: [{firstname: search }, {lastname: search }]})
  .exec((err, contact) => {
    assert.equal(null, err);
    console.info(contact);
    console.info(`${contact.length} matches`);
    db.disconnect();
  });
};

// Export all methods
module.exports = {  addContact, getContact };

Basically, we define our schema and interact with the database via our model. We then define controller functions( addContact, getContact, etc) that would be called depending on the user's interaction with the application.

You will notice we're using toLower function to convert the values to lowercase before saving to the database. Also, like in any other contact management system we want to allow users to make case-insensitive inexact matches. However, if you want to make case-insensitive exact matches, see below for options.

// case-insensitive inexact matches
const search = new RegExp(name, 'i');
// case-insensitive exact match 
const search = new RegExp(`^${name}$`, 'i');

For the purpose of this demo, we're only searching by firstname and lastname fields.

Step 3 of 5: Handle command-line arguments

To test our progress so far, we need a mechanism for accepting users' input and passing it to our controller functions defined in the step above.

Commander.js is our friend for reading command-line inputs and saves us the stress of managing different options and parsing values. It comes with Git-like sub-commands which makes it interactive and easy to use. It allows us to define option, alias, command, version, action, description, etc. We will see them in action in a bit.

Let's create contact.js in the project directory

const program = require('commander');
// Require logic.js file and extract controller functions using JS destructuring assignment
const { addContact, getContact } = require('./logic');

program
  .version('0.0.1')
  .description('Contact management system');

program
  .command('addContact <firstame> <lastname> <phone> <email>')
  .alias('a')
  .description('Add a contact')
  .action((firstname, lastname, phone, email) => {
    addContact({firstname, lastname, phone, email});
  });

program
  .command('getContact <name>')
  .alias('r')
  .description('Get contact')
  .action(name => getContact(name));

program.parse(process.argv);

Whoop! We're all set up. Before we test the application let's understand the key idea here. commander.js' API exposes some functions which are chainable.

.command() allows you to specify the command name with optional parameters. In our own case, they're not optional because we specified them using <>. To make any parameter optional, use [the parameter goes in here] instead.

.action() takes a callback and runs it each time the command name is specified.

Let's test it out in the terminal.

$ node contact.js --help # Shows you program description, usage, commands, actions, and aliases
$ node contact.js --version # Shows the version number specified

Now that we've gotten a hang of the program, let's try adding a contact to our database from the terminal Usage: contact [options] [command]

$ node contact.js addContact John Doe 013-452-3134 john.doe@contacto.com

The parameters to addContact are space separated and since each of the parameters is marked as required using <>, you must provide all the parameters.

We can also use alias instead of the full command name. Now, let's retrieve the contact we just added

$ node contact.js r john # Notice we used r which is an alias for getContact.

Step 4 of 5: Interactive runtime user inputs

We can accept and parse command-line arguments, thanks to commander.js. However, we can enhance the user experience by allowing a user respond to questions in a more interactive way. Let's use inquirer.js for user interface and inquiry session flow.

First we define the questions to be presented to the user and based on the answer, we save the contact.

Let's update contact.js

.........
const { prompt } = require('inquirer'); // require inquirerjs library

// Craft questions to present to users
const questions = [
  {
    type : 'input',
    name : 'firstname',
    message : 'Enter firstname ...'
  },
  {
    type : 'input',
    name : 'lastname',
    message : 'Enter lastname ...'
  },
  {
    type : 'input',
    name : 'phone',
    message : 'Enter phone number ...'
  },
  {
    type : 'input',
    name : 'email',
    message : 'Enter email address ...'
  }
];
.........

program
  .command('addContact') // No need of specifying arguments here
  .alias('a')
  .description('Add a contact')
  .action(() => {
    prompt(questions).then(answers =>
      addContact(answers));
  });
..........

This looks a lot cleaner with some interactive flow. We can now do away with the parameters. prompt launches an inquiry session by presenting the questions to the user. It returns a promise, answers which is passed to our controller function, addContact.

Let's test our program again and respond to the prompt

$ node contact.js a 
# The Above command lauches a prompt
? Enter firstname .. John
? Enter lastname .. Doe
? Enter phone number .. +145****
? Enter email address .. john.doe@contacto.com
New contact added

Step 5 of 6: Convert application to a shell command

We are able to accept command-line arguments in an interactive way but what about we make our tool a regular shell command? The good news is, it's easy to achieve.

First, we add #!/usr/bin/env node at the top of contact.js, which tells the shell how to execute our script, otherwise the script is started without the node executable.

#!/usr/bin/env node

const program = require('commander');
..............

Next, we configure the file to be executable. Let's update our package.json file with the necessary properties

"name": "contacto",
...........
"preferGlobal": true,
  "bin": "./contact.js",
..........

First, we set preferGlobal to true because our application is primarily a command-line application that should be installed globally. And then we add bin property which is a map of command name to local file name. Since, we have just one executable the name should be the name of the package.

Finally, we create a symlink from contact.js script to /usr/local/bin/contact by running

$ yarn link # creates a symbolic link between project directory and executable command.

Let's test that our tool now works globally. Open a new terminal and ensure you're not in your project directory.

$ contacto --help # Returns usage instructions.
$ contacto r john # Returns user detail.

Yes, our tool is ready to be shipped to the world. What a user needs to do is simply install it on their system and everything would be fine.

Step 6 of 6: More application logic

Now, we are able to add and get contact(s). Let's add the functionality to list, update, and delete contacts in the application. We have a problem though. How do we get a specific contact to update/delete since we don't have unique contraints? The only unique key is the _id. For this demo, let's use the _id from the database. This requires we first search for a contact and then select the _id of the contact we want to delete/update.

Let's update logic.js.

const mongoose = require('mongoose'); // An Object-Document Mapper for Node.js
const assert = require('assert'); // N.B: Assert module comes bundled with NodeJS.
mongoose.Promise = global.Promise; // Allows us to use Native promises without throwing error.

// Connect to a single MongoDB instance. The connection string could be that of remote server
// We assign the connection instance to a constant to be used later in closing the connection
const db = mongoose.connect('mongodb://localhost:27017/contact-manager');

// Convert value to to lowercase
function toLower(v) {
  return v.toLowerCase();
}

// Define a contact Schema
const contactSchema = mongoose.Schema({
  firstname: { type: String, set: toLower },
  lastname: { type: String, set: toLower },
  phone: { type: String, set: toLower },
  email: { type: String, set: toLower }
});

// Define model as an interface with the database
const Contact = mongoose.model('Contact', contactSchema);

/**
 * @function  [addContact]
 * @returns {String} Status
 */
const addContact = (contact) => {
  Contact.create(contact, (err) => {
    assert.equal(null, err);
    console.info('New contact added');
    db.disconnect();
  });
};

/**
 * @function  [getContact]
 * @returns {Json} contacts
 */
const getContact = (name) => {
  // Define search criteria
  const search = new RegExp(name, 'i');

  Contact.find({$or: [{firstname: search }, {lastname: search }]})
  .exec((err, contact) => {
    assert.equal(null, err);
    console.info(contact);
    console.info(`${contact.length} matches`);
    db.disconnect();
  });
};

/**
 * @function  [getContactList]
 * @returns {Sting} status
 */
const updateContact = (_id, contact) => {
  Contact.update({ _id }, contact)
  .exec((err, status) => {
    assert.equal(null, err);
    console.info('Updated successfully');
    db.disconnect();
  });
};

/**
 * @function  [deleteContact]
 * @returns {String} status
 */
const deleteContact = (_id) => {
  Contact.remove({ _id })
  .exec((err, status) => {
    assert.equal(null, err);
    console.info('Deleted successfully');
    db.disconnect();
  })
}

/**
 * @function  [getContactList]
 * @returns [contactlist] contacts
 */
const getContactList = () => {
  Contact.find()
  .exec((err, contacts) => {
    assert.equal(null, err);
    console.info(contacts);
    console.info(`${contacts.length} matches`);
    db.disconnect();
  })
}

// Export all methods
module.exports = {   
  addContact, 
  getContact, 
  getContactList,
  updateContact,
  deleteContact 
};

Also, update contact.js.

#!/usr/bin/env node

const program = require('commander');
const { prompt } = require('inquirer');

const { 
  addContact,
  getContact,
  getContactList,
  updateContact,
  deleteContact
} = require('./logic'); 

const questions = [
  {
    type : 'input',
    name : 'firstname',
    message : 'Enter firstname ..'
  },
  {
    type : 'input',
    name : 'lastname',
    message : 'Enter lastname ..'
  },
  {
    type : 'input',
    name : 'phone',
    message : 'Enter phone number ..'
  },
  {
    type : 'input',
    name : 'email',
    message : 'Enter email address ..'
  }
];

program
  .version('0.0.1')
  .description('contact management system')

program
  .command('addContact')
  .alias('a')
  .description('Add a contact')
  .action(() => {
    prompt(questions).then((answers) =>
      addContact(answers));
  });

program
  .command('getContact <name>')
  .alias('r')
  .description('Get contact')
  .action(name => getContact(name));

program
  .command('updateContact <_id>')
  .alias('u')
  .description('Update contact')
  .action(_id => {
    prompt(questions).then((answers) =>
      updateContact(_id, answers));
  });

program
  .command('deleteContact <_id>')
  .alias('d')
  .description('Delete contact')
  .action(_id => deleteContact(_id));

program
  .command('getContactList')
  .alias('l')
  .description('List contacts')
  .action(() => getContactList());

// Assert that a VALID command is provided 
if (!process.argv.slice(2).length || !/[arudl]/.test(process.argv.slice(2))) {
  program.outputHelp();
  process.exit();
}
program.parse(process.argv)

It's time to test things out in the terminal

$ contacto l # Lists all the contact in the app
$ contacto u <id of the contact to update> # Lauches inquiry session to get the new data to update with.
$ contacto d <id of the contact to delete> # Deletes a contact.

If you fail to provide any of the values, it's set to '"".

Good news! You have built a command-line tool with Node.js.

Wrap up

Now, we know it's easy to build command-line tools with Node.js. The libraries used in this tutorial are not compulsory for building command-line application. We only used them to avoid being distracted with some tiny details. You can use Child process module from Node.js to achieve the same result natively.

Yes, think of the tool we built and its use; think about its flaws and improve it.

Rowland Ekemezie

3 posts

Automated system enthusiast, addictive learner, human capital development advocate, writer, and a Software Engineer.