May 15th 2023

Building a CLI app with Node.js

In this blog, we are going to build a simple CLI app that lists files in a directory and shows the content of a file in that directory. We will use Node.js to build this app. The aim of this blog is to show how we can interact with the console and output the result to the console.

Prerequisites

  • Node.js
  • npm or pnpm (I am using pnpm in this blog)
  • git

Getting started

To help us get started faster, we are going to clone a starter project from GitHub that supports TypeScript and ESLint.

1git clone git@github.com:henoktsegaye/node-ts-starter.git

Installing dependencies

1pnpm install

When running node on the terminal, anything passed while executing the file could be accessed from the

process.argv
array. The first element of the array is the path to the node executable while the second element is the path to the file that is being executed; the rest of the elements in the array are the arguments passed to the file. For example, if we run the following command on the terminal:

1node index.js --version

The

process.argv
array will look like something like this:

1[
2 '/usr/local/bin/node', // path to the node executable
3 '/Users/USERNAME/node-ts-starter/index.js', // path to the file that is being executed
4 '--version' // arguments passed to the file
5]

We can use this array to access the arguments passed and parse the arguments passed to do whatever we wanted to do (based on the arguments passed). For example, if we want to print the version of the app we can do something like this:

1if (process.argv.includes('--version')) {
2 console.log('0.0.1');
3}

Using npm packages

Using the

process.argv
array to parse the arguments passed to the file could be a bit slow, tedious, and error-prone if we have a lot of arguments to parse. Slow process There are a couple of great npm packages that could actually help us write this CLI app faster, but we could choose not to use those packages and write our own parser instead. In this blog, we are going to use some of these packages anyway to help us write this app faster.

Here are a couple of packages that we are going to use in this blog:

  • commander - helps us to build a command line interface
  • chalk - helps us to style the output to the console
  • inquirer - helps us to build interactive command line interfaces

Once we have installed dependencies from the starter project, we can start to install our own dependencies.

1pnpm install commander chalk inquirer

Building the app

A CLI app is different in the way that the user interacts with the app; there is no UI or nice interface the user could click around. Instead, users have to type commands to do everything, even to get help.

Commander.js
could actually help us with this. Commander handles parsing input from the command line that the user types and helps us to define custom commands and options for our app. Let's see this in action. First, let's start by creating a program from commander:

1import { Command } from 'commander';
2
3const program = new Command();

We created a program instance from commander. Now we can start to define commands and options for our app. Let's start by defining a version for our app and a description.

1program
2.version('0.0.1')
3.description('A simple CLI app that lists and shows the content of a directory');

Now that we have the basics, we can call the

parse
method on the program instance to parse the input from the command line and pass
process.argv
.

1program.parse(process.argv);

Now we can run our app and see the version that we defined.

1pnpm run dev --version
2
3# output 0.0.1

Now let's define an option that lists the content of a directory. We can do that by calling the

option
method on the program object.

1program
2.version('0.0.1')
3.description('A simple CLI app that lists and shows the content of a directory')
4.option('-l, --list', 'list the content of a directory')
5.option('-s, --show <path>', 'show the content of a file')
6.parse(process.argv);

Now that we have defined the options for the CLI app, we can start to implement the logic for each option.

1import fs from 'fs';
2import chalk from 'chalk';
3
4// previous code here
5
6const options = program.opts();
7
8// if the list option is passed, we list the content of the directory
9if (options.list) {
10 fs.readdir(process.cwd(), { withFileTypes: true }, (err, files) => {
11 if (err) {
12 console.log(err);
13 return;
14 }
15
16 const fileList = files.map((file) => {
17 if (file.isDirectory()) {
18 return { name: `πŸ“‚ ${file.name}`, type: 'directory' };
19 }
20 return { name: `πŸ“„ ${file.name}`, type: 'file' };
21 });
22
23 fileList.forEach((file) => {
24 if (file.type === 'directory') {
25 console.log(chalk.blue(file.name));
26 } else {
27 console.log(chalk.green(file.name));
28 }
29 });
30 });
31}
32
33const readFile = (path) => {
34 fs.readFile(path, "utf-8", (err, data) => {
35 if (err) {
36 console.log(chalk.red("Error", err.message));
37 return;
38 }
39 console.log(data);
40 });
41}
42
43// if the show option is passed, we show the content of the file
44if (options.show) {
45 readFile(`${process.cwd()}/${options.show}`);
46}

In this code snippet:

  • fs.readdir(process.cwd(), { withFileTypes: true }, (err, files) => {...})
    : This reads the contents of the current working directory. The withFileTypes: true option ensures that the resulting files array includes file type information.
  • The
    fileList
    array is constructed to store file names with their respective icons (folders and files).
  • The
    forEach
    loop iterates over the fileList and prints each file or directory name in color-coded output using chalk.
  • fs.readFile(path, "utf-8", (err, data) => {...})
    : This reads the file at the specified path. The utf-8 encoding ensures the file is read as a string.
  • The
    if (err)
    block handles errors, such as the file not being found, and prints a red error message using chalk. Now we can run our app and see the result.
1pnpm run dev --list
2pnpm run dev --show README.md

Pretty neat, right? It's as easy as that! But wait, there is more. Now that we have the basics, we want to add a prompt on the

--list
option to ask the user to select a directory/file to show the content of the directory/file. Let's see this in action.

1import fs from 'fs';
2import chalk from 'chalk';
3import inquirer from 'inquirer';
4
5const options = program.opts();
6
7// Previous code here ...
8
9// separate the logic for reading a file to a function (make this easier to read/test)
10const readDirectoryFiles = (path: string) => {
11 fs.readdir(path, { withFileTypes: true }, (err, files) => {
12 if (err) {
13 console.log(err);
14 return;
15 }
16
17 const fileList = files.map((file) => {
18 if (file.isDirectory()) {
19 return `πŸ“‚ ${file.name}`;
20 }
21 return `πŸ“„ ${file.name}`;
22 });
23
24 inquirer
25 .prompt([
26 {
27 type: "list",
28 name: "file",
29 message: "Select a file to show the content",
30 choices: fileList,
31 },
32 ])
33 .then((answers) => {
34 const file = answers.file;
35 if (file.startsWith("πŸ“‚")) {
36 return readDirectoryFiles(`${path}/${file.replace("πŸ“‚ ", "")}`);
37 }
38 console.log(chalk.green(file));
39 readFile(`${path}/${file.replace("πŸ“„ ", "")}`);
40 });
41 });
42};
43
44// if the list option is passed, we list the content of the directory
45if (options.list) {
46 readDirectoryFiles(process.cwd());
47}

In the above code

1.Mapping Files and Directories:

  • const fileList = files.map((file) => {...})
    : This part of the code maps through the files array obtained from
    fs.readdir
    and creates a new array called
    fileList
    . Each file or directory is prefixed with an icon (πŸ“‚ for directories and πŸ“„ for files) to visually distinguish them in the prompt.

2.Prompting the User:

  • inquirer.prompt([...]).then((answers) => {...})
    : The
    inquirer.prompt
    method is used to display a prompt to the user. The prompt method takes an array of questions, and each question is an object. Here, we have one question:
  • type: "list": This specifies that the input type is a list, which will display a list of choices for the user to select from.
  • name: "file": This assigns a name to the answer. The answer can be accessed using this name.
  • message: "Select a file to show the content": This is the message displayed to the user.
  • choices:
    fileList
    : This provides the list of choices that the user can select from, which we created earlier.

3.Handling User Selection:

  • then((answers) => {...})
    : Once the user makes a selection, the .then method is called with the answers object, which contains the user’s selection.
  • const file = answers.file;
    : This extracts the selected file or directory from the answers object.
  • if (file.startsWith("πŸ“‚")) {...}
    : This checks if the selected item is a directory (starts with πŸ“‚). If it is, the function calls readDirectoryFiles recursively with the new path to list the contents of the selected directory.
  • console.log(chalk.green(file));
    : If the selected item is a file, it logs the file name in green using chalk.
  • readFile(${path}/${file.replace(β€œπŸ“„ β€œ, β€œβ€)});
    : This calls the readFile function to read and display the content of the selected file, removing the πŸ“„ prefix from the file name.

In this new way, we should be able to iterate over directories to list files and select files to view the content. We can run our app and see the result.

1pnpm run dev --list

You should see something like this: list

Now you can navigate through the directories and select a file to view the content.

Conclusion

In this blog, we have learned how to build a simple CLI app with Node.js. We have learned how to use commander to define commands and options for our app. We have also learned how to use chalk to style the output to the console and inquirer to build interactive command line interfaces. You can find the source code for this blog here.

Hello fellow developer πŸ‘‹πŸΎ


Looking for expert assistance with your web application development or code review?

Feel free to send me a message or email to discuss your specific needs