Building a TypeScript CLI with Node.js and Commander

mangelosanto

Matt Angelosanto

Posted on November 3, 2022

Building a TypeScript CLI with Node.js and Commander

Written by Stanley Ulili✏️

The command line has thousands of tools, such as awk, sed, grep, and find available at your disposal that cut development time and automate tedious tasks. Creating a command line tool in Node.js isn't very complicated, thanks to a powerful library like Commander.js.

Pairing Node.js with TypeScript helps you catch bugs early during the development process so that you can ship CLIs that are more reliable and have fewer bugs.

In this tutorial, we will talk about the CLI is and how to use Commander.js with TypeScript to build one. We will then make the CLI globally accessible so that users can access it anywhere in their system.

Jump ahead:

Prerequisites

To follow this tutorial, you will need:

Why Commander.js?

A command-line interface, often referred to as a CLI, is a program that allows users to type instructions and interact with a script that processes the input and produces an output. Node.js has a lot of packages that allows you to build CLIs, like args, minimist, and oclif.

Commander.js provides a lot of features that allow you to succinctly build command-line interfaces. Furthermore, the Node.js community provides libraries such as Chalk and Figlet that complement Commander.js CLIs to make them look visually appealing.

We will use Commander.js because of the following features:

  • Support for sub-commands
  • Support for various command-line options, such as required, variadic, or optional
  • Custom event listeners
  • Automated help

Understanding the command-line interface

Before we dive into building CLIs, let's look at how an existing CLI works.

If you are following this tutorial, you probably have Node.js installed on your machine. Node.js provides a CLI that you can access by typing node in the terminal:

node
Enter fullscreen mode Exit fullscreen mode

Typing the command allows you to access the Node.js read–eval–print loop (REPL) where you can enter and execute JavaScript code.

You can modify the Node.js CLI to do something else with the use of command-line flags, or options. Exit the REPL with CTRL+D, then check the Node.js version with the -v option:

node -v
// v18.11.0
Enter fullscreen mode Exit fullscreen mode

As you can see in the output, passing the -v option changed the behavior of the node CLI to show the Node.js version. You can also the long-form options:

node --version
// v18.11.0
Enter fullscreen mode Exit fullscreen mode

Other Node.js CLI options require an argument to be passed along with the option. For example, the -e option, which is a short form of --eval, accepts an argument of a string that contains JavaScript code. Node executes the code and logs the result in the terminal:

node -e "console.log(4 * 2)"
// 8
Enter fullscreen mode Exit fullscreen mode

The -e option returns an error if no argument is passed:

node -e
// node: -e requires an argument
Enter fullscreen mode Exit fullscreen mode

Now that we have an idea of how a CLI works. Let's look at the Commander.js terminology for the Node CLI options we have seen far:

  • Boolean option: These options don't require arguments. -v is an example of a boolean option; other familiar examples are ls -l or sudo -i
  • Required option: These options require arguments. For example, node -e "console.log(4 * 2)" throws an error if an argument isn't passed
  • Option-argument: These are the arguments passed to an option. In the node -e "console.log(4 * 2)" command, "console.log(4 * 2)" is an option-argument; another example is git status -m "commit message", where the "commit message" is an option-argument for the -m option

Now that you have an idea of what a CLI is, we will create a directory and configure it to use TypeScript and Commander.js.

Getting started and configuring TypeScript

In this section, we will create a directory for the project, initialize it as an npm package, install all the necessary dependencies, and configure TypeScript.

To begin, create the directory for the project:

mkdir directory_manager
Enter fullscreen mode Exit fullscreen mode

Change into the directory:

cd directory_manager
Enter fullscreen mode Exit fullscreen mode

Initialize the directory as an npm project:

npm init -y
Enter fullscreen mode Exit fullscreen mode

This creates a package.json file, which contains important information about your project and track dependencies.

Next, run the following command:

npm install commander figlet
Enter fullscreen mode Exit fullscreen mode

Commander.js is our library for building a CLI, and Figlet will be used for turning CLI text into ASCII art.

Next, download the TypeScript and ts-node packages:

npm install @types/node typescript --save-dev
Enter fullscreen mode Exit fullscreen mode

Now, create a tsconfig.json file in your text editor and add the following configuration settings for TypeScript:

{
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist",
    "strict": true,
    "target": "es6",
    "module": "commonjs",
    "sourceMap": true,
    "esModuleInterop": true,
    "moduleResolution": "node"
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's go over some of the options:

  • rootDir: A directory that will contain the TypeScript files(.ts files) for the CLI, which we’ll keep in the src directory
  • outDir: A directory that will contain TypeScript-compiled JavaScript source code. We will use the dist directory
  • strict: This disables optional typing and ensures that all the TypeScript code you write has types
  • target: The version of ECMAScript to which TypeScript should compile JavaScript

For a comprehensive look at all the options, visit the TypeScript documentation.

Next, in the package.json file, create a build script that you will use to compile TypeScript:

{
  ...
  "scripts": {
    // add the following line
    "build": "npx tsc",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

To compile TypeScript, you will run the build script with npm run build, which will run the npx tsc command that compiles TypeScript to JavaScript.

We have now configured TypeScript and added a script for compiling TypeScript. We will start building a CLI next.

Creating a CLI with TypeScript

In this section, we will begin building a CLI using TypeScript and Commander.js. It will look as follows: Screenshot of the CLI

The CLI will be used to manage a directory and it will have a -l option that will list directory contents in a table format. For each item, it will display its name, size, and date of creation. It will also have a -m for creating directories and a -t option for creating empty files.

Now that you have an idea of what we will be building, we will break the CLI into smaller chunks and start building each part.

Creating the name of the CLI

In this section, we will create the name of the CLI and use the Figlet package to turn it into ASCII art text.

It will look like this when finished: Screenshot of CLI name in ASCII Art

In your project directory, create the src directory and navigate into it:

mkdir src && cd src
Enter fullscreen mode Exit fullscreen mode

This directory will contain the TypeScript files. You might recall, we specified this directory in the rootDir option when we configured TypeScript with the tsconfig.js file earlier in the tutorial.

Next, create an index.ts file and add the following contents:

const figlet = require("figlet");

console.log(figlet.textSync("Dir Manager"));
Enter fullscreen mode Exit fullscreen mode

In the first line, we import the Figlet module. Next, we invoke the figlet.textSync() method with the string Dir Manager as the argument to turn the text into ASCII Art. Finally, we log the text in the console.

To verify that the changes work, save your file. Compile the TypeScript file to JavaScript with the following command:

npm run build
Enter fullscreen mode Exit fullscreen mode

When TypeScript finishes compiling, you will see output like this:

// output
> typescript_app@1.0.0 build
> npx tsc
Enter fullscreen mode Exit fullscreen mode

If successful, you won't see any errors here.

You may also recall, we added an outDir option and set it to the dist directory in the tsconfig.json file. After compiling TypeScript, the directory will be created automatically in the root directory.

Change into the dist directory:

cd ../dist
Enter fullscreen mode Exit fullscreen mode

List the directory contents:

ls

// output
index.js  index.js.map
Enter fullscreen mode Exit fullscreen mode

You will see that the index.js file has been created. You can run the file with Node.js as follows:

node index.js
Enter fullscreen mode Exit fullscreen mode

Upon running the command, you will see the CLI name in ASCII art: Screenshot of the CLI name

Now, go back to the root directory:

cd ..
Enter fullscreen mode Exit fullscreen mode

Going forward, we won't log into the dist directory to run the file. We will do it while in the root directory as node dist/index.js.

Now that you can create the name of the CLI in ASCII text, we will create the CLI options.

Creating the CLI options using Commander.js

In this section, we will use Commander.js to create a description for the CLI and its options.

We will create the following options: Using Commander.js to create a description for the CLI and its options. The -V option will invoke the Commander.js version() method, and the -h will be provided by default. We are now left to define three options:

  • -l / --ls : Modifies the CLI to list directory contents in a table. It will also accept an optional directory path argument
  • -m / --mkdir: Used to create a directory. It will require an option-argument, which is the name of the directory to be created
  • -t / --touch: Modifies the CLI to create an empty file. It will require an option-argument, which is the name of the file

Now that we know the options we’ll be creating, we will define them using Commander.js.

Defining options using Commander.js

In your text editor, open the index.ts file and add the following code to import and initialize Commander.js:

const { Command } = require("commander"); // add this line
const figlet = require("figlet");

//add the following line
const program = new Command();

console.log(figlet.textSync("Dir Manager"));
Enter fullscreen mode Exit fullscreen mode

In the first line, we import the Commander.js module and extract the Command class. We then set the program variable to an instance of the Command class. The class gives us several methods that can be used to set the version, description, and CLI options.

Next, define the CLI options in your index.ts file:

...

program
  .version("1.0.0")
  .description("An example CLI for managing a directory")
  .option("-l, --ls  [value]", "List directory contents")
  .option("-m, --mkdir <value>", "Create a directory")
  .option("-t, --touch <value>", "Create a file")
  .parse(process.argv);

const options = program.opts();
Enter fullscreen mode Exit fullscreen mode

In the preceding code, we use the program variable containing the Commander instance to invoke the version() method. The method takes a string containing the version of the CLI and Commander creates the -V option for you.

Next, we chain the description() method call with the text that describes the CLI program. Following this, you chain a call to the option() method of Commander package, which takes two arguments: an option and a description. The first argument is a string that specifies the -l option and the long name --ls. We then wrap value in [] so that the option can accept an optional argument. The second argument is the help text that will be shown when users use the -h flag.

After that, we chain another option() method call to define the -m / --mkdir option. The <> in the <value> signifies that it requires an argument. Following this, we chain another option() to define the -t option and the long name --touch, which also requires an argument.

We then chain the parse() method call, which processes the arguments in the process.argv, which is an array containing the arguments the user passed. The first argument is node, the second argument is the program filename, and the rest are additional arguments.

Finally, we set the options variable to the program.opts() call, which returns an object. The object has CLI options as properties, whose values are the arguments the user passed.

At this point, the index.ts file will look like the following:

const { Command } = require("commander");
const figlet = require("figlet");

const program = new Command();

console.log(figlet.textSync("Dir Manager"));

program
  .version("1.0.0")
  .description("An example CLI for managing a directory")
  .option("-l, --ls  [value]", "List directory contents")
  .option("-m, --mkdir <value>", "Create a directory")
  .option("-t, --touch <value>", "Create a file")
  .parse(process.argv);

const options = program.opts();
Enter fullscreen mode Exit fullscreen mode

When you are finished making changes, save the file, then compile TypeScript:

npm run build
Enter fullscreen mode Exit fullscreen mode

Run the index.js with the -h option to see the CLI help page:

node dist/index.js -h
Enter fullscreen mode Exit fullscreen mode

Upon running the command, the page will look like this: Screenshot of the CLI help page

Let's also try the -V option:

node dist/index.js -V
// 1.0.0
Enter fullscreen mode Exit fullscreen mode

So far, the -h and the -V option work without any issues. If you try the other options we defined, you will only see the CLI name.

node dist/index.js -l
Enter fullscreen mode Exit fullscreen mode

Screenshot of CLI name in ASCII art after using the <code>-l</code> option

This is happening because we have not defined the actions for the other options.

Creating actions for the CLI

So far, we have defined options for the CLI but they have no actions associated with them. In this section, we will create actions for the options so that when a user uses the options, the CLI will perform the relevant task.

We will begin with the -l option. We want the CLI to show directory contents in a table with the following fields:

  • Filename
  • Size(KB)
  • created_at

A user can also provide an optional directory path:

node dist/index.js -l /home/username/Documents
Enter fullscreen mode Exit fullscreen mode

If the user doesn't pass any option-argument, the CLI will only show contents in the location of the index.js file we are executing:

node dist/index.js -l
Enter fullscreen mode Exit fullscreen mode

In your index.ts file, import the fs and path modules:

const { Command } = require("commander");
// import fs and path modules
const fs = require("fs");
const path = require("path");
const figlet = require("figlet");
Enter fullscreen mode Exit fullscreen mode

Define a listDirContents() function with an exception handler at the end of the file:

const { Command } = require("commander");
...
const options = program.opts();

//define the following function
async function listDirContents(filepath: string) {
  try {

  } catch (error) {
    console.error("Error occurred while reading the directory!", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

The listDirContents() asynchronous function takes a filepath parameter, which has a TypeScript type declaration of string. The type ensures that the function only accepts strings as arguments, and the async keyword you prefix makes the function asynchronous. This will allow us to use the await keyword inside the function, which we’ll do soon.

Within the function, we define the try block, which is empty for now. It will contain the functionality that lists the directory contents and format the result into a table. After that, we define the catch block that will log a message in the console if the code contained in the try block has an exception.

Let's add the code that lists the directory contents in the listDirContents() function:

async function listDirContents(filepath: string) {
  try {
    // add the following
    const files = await fs.promises.readdir(filepath);
    const detailedFilesPromises = files.map(async (file: string) => {
      let fileDetails = await fs.promises.lstat(path.resolve(filepath, file));
      const { size, birthtime } = fileDetails;
      return { filename: file, "size(KB)": size, created_at: birthtime };
    });
  } catch (error) {
    console.error("Error occurred while reading the directory!", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

First, we call fs.promises.readdir() with the value in the filepath parameter to read the directory contents. The function returns a promise, so we prefix it with the await keyword to wait for it to resolve. Once resolved, files is set to an array.

Second, we iterate over each element in the files array and return a new array using the map() method, which takes an asynchronous callback. The callback accepts the file parameter. In the callback, we invoke fs.promises.lstat() with the full path of the file to get more details about the file, such as size, birthtime, and info. We then extract the size and birthtime properties and the return an object with the filename, size(KB), and created_at properties into the array that the map() method returns into the detailedFilesPromise variable.

Now, add the following code towards the end of the try block to create a table that displays the directory contents:

async function listDirContents(filepath: string) {
  try {
    const files = await fs.promises.readdir(filepath);
    const detailedFilesPromises = files.map(async (file: string) => {
      let fileDetails = await fs.promises.lstat(path.resolve(filepath, file));
      const { size, birthtime } = fileDetails;
      return { filename: file, "size(KB)": size, created_at: birthtime };
    });
    // add the following
    const detailedFiles = await Promise.all(detailedFilesPromises);
    console.table(detailedFiles);
  } catch (error) {
    console.error("Error occurred while reading the directory!", error);
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, each element in the the detailedFilesPromise will return a promise and evaluate to an object once resolved. To wait for all of them to resolve, we call the Promise.all() method.

Finally, we invoke console.table() with the detailedFiles array to log the data in the console.

Let's now define an action for the -m option. To do that, define the createDir() function below the listDirContents() function:

async function listDirContents(filepath: string) {
  ...
}

// create the following function
function createDir(filepath: string) {
  if (!fs.existsSync(filepath)) {
    fs.mkdirSync(filepath);
    console.log("The directory has been created successfully");
  }
}
Enter fullscreen mode Exit fullscreen mode

In the CreateDir() function, we check if the given directory path exists. If it doesn't exist, we call fs.mkdirSync() to create a directory, then log a success message.

Before we invoke the function, define a createFile() function for the -t flag:

async function listDirContents(filepath: string) {
  ...
}

function createDir(filepath: string) {
  ...
}
// create the following function
function createFile(filepath: string) {
  fs.openSync(filepath, "w");
  console.log("An empty file has been created");
}
Enter fullscreen mode Exit fullscreen mode

In the createFile() function, we invoke fs.openSync() to create an empty file in the given path. We then log a confirmation message to the terminal.

So far, we have created three functions but we haven't called them. To do that, we need to check if the user has used the option, so that we can invoke the suitable function.

To check if the user has used the -l or --ls option, add the following in index.ts:

...
function createFile(filepath: string) {
  ...
}
// check if the option has been used the user
if (options.ls) {
  const filepath = typeof options.ls === "string" ? options.ls : __dirname;
  listDirContents(filepath);
}
Enter fullscreen mode Exit fullscreen mode

If options.ls is set to a value, we set the filepath variable to the path the user provided, if option.ls is a string; otherwise, it's set to the file path of the index.js file in the dist directory. After that, we call the listDirContents() with the filepath variable.

Let’s now invoke the createDir() and createFile() function when the user uses the appropriate option:

if (options.ls) {
  ...
}

// add the following code
if (options.mkdir) {
  createDir(path.resolve(__dirname, options.mkdir));
}
if (options.touch) {
  createFile(path.resolve(__dirname, options.touch));
}
Enter fullscreen mode Exit fullscreen mode

If the user uses the -m flag and passes an argument, we invoke createDir() with the full path to the index.js file to create the directory.

If the user uses the -t flag and passes an argument, we invoke the createFile() function with the full path to the index.js location.

At this point, the complete index.ts file will look like this:

const { Command } = require("commander");
const fs = require("fs");
const path = require("path");
const figlet = require("figlet");

const program = new Command();

console.log(figlet.textSync("Dir Manager"));

program
  .version("1.0.0")
  .description("An example CLI for managing a directory")
  .option("-l, --ls  [value]", "List directory contents")
  .option("-m, --mkdir <value>", "Create a directory")
  .option("-t, --touch <value>", "Create a file")
  .parse(process.argv);

const options = program.opts();

async function listDirContents(filepath: string) {
  try {
    const files = await fs.promises.readdir(filepath);
    const detailedFilesPromises = files.map(async (file: string) => {
      let fileDetails = await fs.promises.lstat(path.resolve(filepath, file));
      const { size, birthtime } = fileDetails;
      return { filename: file, "size(KB)": size, created_at: birthtime };
    });
    const detailedFiles = await Promise.all(detailedFilesPromises);
    console.table(detailedFiles);
  } catch (error) {
    console.error("Error occurred while reading the directory!", error);
  }
}
function createDir(filepath: string) {
  if (!fs.existsSync(filepath)) {
    fs.mkdirSync(filepath);
    console.log("The directory has been created successfully");
  }
}

function createFile(filepath: string) {
  fs.openSync(filepath, "w");
  console.log("An empty file has been created");
}

if (options.ls) {
  const filepath = typeof options.ls === "string" ? options.ls : __dirname;
  listDirContents(filepath);
}
if (options.mkdir) {
  createDir(path.resolve(__dirname, options.mkdir));
}
if (options.touch) {
  createFile(path.resolve(__dirname, options.touch));
}
Enter fullscreen mode Exit fullscreen mode

Save your file and compile TypeScript:

npm run build
Enter fullscreen mode Exit fullscreen mode

Let's verify that the options work. In your terminal, enter the following to try the -l option:

node dist/index.js -l
Enter fullscreen mode Exit fullscreen mode

You will see the directory contents in a table that looks similar to this: Screenshot of directory contents in a table

Next, pass the directory path of your choosing as an argument:

node dist/index.js -l /home/node-user/
Enter fullscreen mode Exit fullscreen mode

In the output, you will see the directory contents of your chosen path: Screenshot of directory contents in the home directory

Using the -m option, create a new directory with any name you prefer:

node dist/index.js -m new_directory
// The directory has been created successfully
Enter fullscreen mode Exit fullscreen mode

Let's also create an empty file using the -t option:

node dist/index.js -t empty_file.txt
// An empty file has been created
Enter fullscreen mode Exit fullscreen mode

Following this, let's check if the directory and the empty file have been created with the following:

node dist/index.js -l
Enter fullscreen mode Exit fullscreen mode

Screenshot of directory contents showing a new directory and an empty file that was created

The output shows the new_directory and the empty_file.txt file, confirming that they were created.

Now, if you use the node dist/index.js command without any option, it will show the CLI name:

node dist/index.js
Enter fullscreen mode Exit fullscreen mode

Screenshot of CLI name in ASCII Art

Showing the help page

It would be a good idea to show the help page when no options have been passed. In the index.ts file, add the following at the end of the file:

...
if (!process.argv.slice(2).length) {
  program.outputHelp();
}
Enter fullscreen mode Exit fullscreen mode

If the number of arguments passed is equal to two — that is, process.argv has only node and the filename as the argument — you can invoke outputHelp() to show the output.

As with any changes, compile TypeScript to JavaScript:

npm run build
Enter fullscreen mode Exit fullscreen mode

Run the following command:

node dist/index.js
Enter fullscreen mode Exit fullscreen mode

Screenshot of the CLI help page

Making the CLI globally accessible

At this point, our CLI is now complete. You might notice that using the CLI is tedious. On a daily basis, we would have to change the directory into the CLI project directory, then invoke index.js to use it. It would be easier if we could give it a name like dirmanager that works anywhere in our system, like so:

dirmanager -l
Enter fullscreen mode Exit fullscreen mode

To do this, open the package.json file and add the following:

{
  ...
  "main": "dist/index.js",
  "bin": {
    "dirmanager": "./dist/index.js"
  },
  ...
}
Enter fullscreen mode Exit fullscreen mode

In the preceding code, we update main to the compiled index.js file. You then add bin with an object as its value. In the object, we set dirmanager to ./dist/index.js, which is the location of the compiled script. We will be using dirmanager to access the CLI, but you can use any name you like.

Next, open the index.ts file and add the following line at the top of the file:

#! /usr/bin/env node

const { Command } = require("commander");
const fs = require("fs");
Enter fullscreen mode Exit fullscreen mode

The line is called a shebang line, which tells the OS to run the file with the node interpreter.

Save your file and compile TypeScript once more:

npm run build
Enter fullscreen mode Exit fullscreen mode

Run the following command:

npm install -g .
Enter fullscreen mode Exit fullscreen mode

The -g option tells npm to install the package globally.

At this point, you can open a new terminal or use the current terminal, then enter the following command:

dirmanager
Enter fullscreen mode Exit fullscreen mode

Screenshot of CLI after typing the CLI name in the terminal

You can also try the other options and they will work fine:

dirmanager -l
Enter fullscreen mode Exit fullscreen mode

We have now successfully created a TypeScript CLI that works anywhere in the system.

Conclusion

In this article, we looked at what a CLI is and then used Commander.js and TypeScript to build a CLI. We then made the CLI globally accessible anywhere in the system. Now you are equipped with knowledge on how to create CLIs with TypeScript.

As a next step, you can visit the Commander documentation to learn more about it. To continue your TypeScript journey, visit the TypeScript documentation. You can also check out TypeScript tutorials on this blog.


200’s only Monitor failed and slow network requests in production

Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket.

LogRocket signup

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. Start monitoring for free.

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on November 3, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related