Node.js4 min read

Building a CLI app with Node.js

Cli app is a great way for developers and technical people to interact with your application. In this article, we will learn how to build 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 intract with the console and output the result to the console.

01Prerequisites

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

02Setting up the project

Clone the starter

To help us get started faster we are going to clone a starter project from Github that supports typescript and eslint.

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

Install dependencies

pnpm install
pnpm install

03Reading arguments with process.argv

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.

node index.js --version
node 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]
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}
1if (process.argv.includes('--version')) {
2 console.log('0.0.1');
3}

04Using npm packages

Why not parse by hand?

Using the process.argv array to parse the arguments passed to the file could be a bit slow and 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 this packages anyway to help us write this app faster.

The packages we'll use

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

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

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

pnpm install commander chalk inquirer
pnpm install commander chalk inquirer

05Building 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 , to even get help. Commander.js could actually help us on 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.

Create the program

First let's start by creating a program from commander

1import { Command } from 'commander';
2
3const program = new Command();
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.
2version('0.0.1').
3description('A simple cli app that lists and shows the content of a directory');
1program.
2version('0.0.1').
3description('A simple cli app that lists and shows the content of a directory');

Parse and run it

Now that we have the basics we can call parse method on the program instance to parse the input from the command line and pass process.argv.

program.parse(process.argv);
program.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
1pnpm run dev --version
2
3# output 0.0.1

Add a --list option

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

List the files in a directory

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:`๐Ÿ“„ ${fileList}` , 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}
32const readFile = (path) => {
33 fs.readFile(path, "utf-8", (err, data) => {
34 if (err) {
35 console.log(chalk.red("Error", err.message));
36 return;
37 }
38 console.log(data);
39 });
40}
41
42// if the show option is passed we show the content of the file
43if(options.show) {
44 readFile(`${process.cwd()}/${options.show}`)
45}
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:`๐Ÿ“„ ${fileList}` , 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}
32const readFile = (path) => {
33 fs.readFile(path, "utf-8", (err, data) => {
34 if (err) {
35 console.log(chalk.red("Error", err.message));
36 return;
37 }
38 console.log(data);
39 });
40}
41
42// if the show option is passed we show the content of the file
43if(options.show) {
44 readFile(`${process.cwd()}/${options.show}`)
45}

Now we can run our app and see the result.

pnpm run dev --list
pnpm run dev --show README.md
pnpm run dev --list
pnpm run dev --show README.md

Make it interactive with inquirer

Pretty neat right? its 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 inquirer
24 .prompt([
25 {
26 type: "list",
27 name: "file",
28 message: "Select a file to show the content",
29 choices: fileList,
30 },
31 ])
32 .then((answers) => {
33 const file = answers.file;
34 if (file.startsWith("๐Ÿ“‚")) {
35 return readDirectoryFiles(`${path}${file.replace("๐Ÿ“‚ ", "/")}`);
36 }
37 console.log(chalk.green(file));
38 readFile(`${path}${file.replace("๐Ÿ“„ ", "/")}`);
39 });
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}
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 inquirer
24 .prompt([
25 {
26 type: "list",
27 name: "file",
28 message: "Select a file to show the content",
29 choices: fileList,
30 },
31 ])
32 .then((answers) => {
33 const file = answers.file;
34 if (file.startsWith("๐Ÿ“‚")) {
35 return readDirectoryFiles(`${path}${file.replace("๐Ÿ“‚ ", "/")}`);
36 }
37 console.log(chalk.green(file));
38 readFile(`${path}${file.replace("๐Ÿ“„ ", "/")}`);
39 });
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 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.

pnpm run dev --list
pnpm 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.

06Wrapping up

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 interface. you can find the source code for this blog here

Keep reading