Building a TypeScript CLI with Node.js and Commander
Matt Angelosanto
Posted on November 3, 2022
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
- Why Commander.js?
- Understanding the command-line interface
- Getting started and configuring TypeScript
- Creating a CLI with TypeScript
- Creating actions for the CLI
- Showing the help page
- Making the CLI globally accessible
Prerequisites
To follow this tutorial, you will need:
- Node.js v ≥ 16 installed on your system
- Familiarity with how to write asynchronous code in JavaScript
- Knowledge of how to write Node.js programs and use TypeScript
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
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
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
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
The -e
option returns an error if no argument is passed:
node -e
// node: -e requires an argument
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 arels -l
orsudo -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 isgit 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
Change into the directory:
cd directory_manager
Initialize the directory as an npm project:
npm init -y
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
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
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"
}
}
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 thesrc
directory -
outDir
: A directory that will contain TypeScript-compiled JavaScript source code. We will use thedist
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"
},
...
}
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:
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:
In your project directory, create the src
directory and navigate into it:
mkdir src && cd src
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"));
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
When TypeScript finishes compiling, you will see output like this:
// output
> typescript_app@1.0.0 build
> npx tsc
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
List the directory contents:
ls
// output
index.js index.js.map
You will see that the index.js
file has been created. You can run the file with Node.js as follows:
node index.js
Upon running the command, you will see the CLI name in ASCII art:
Now, go back to the root directory:
cd ..
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: 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"));
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();
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();
When you are finished making changes, save the file, then compile TypeScript:
npm run build
Run the index.js
with the -h
option to see the CLI help page:
node dist/index.js -h
Upon running the command, the page will look like this:
Let's also try the -V
option:
node dist/index.js -V
// 1.0.0
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
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
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
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");
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);
}
}
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);
}
}
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);
}
}
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");
}
}
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");
}
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);
}
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));
}
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));
}
Save your file and compile TypeScript:
npm run build
Let's verify that the options work. In your terminal, enter the following to try the -l
option:
node dist/index.js -l
You will see the directory contents in a table that looks similar to this:
Next, pass the directory path of your choosing as an argument:
node dist/index.js -l /home/node-user/
In the output, you will see the directory contents of your chosen path:
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
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
Following this, let's check if the directory and the empty file have been created with the following:
node dist/index.js -l
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
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();
}
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
Run the following command:
node dist/index.js
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
To do this, open the package.json
file and add the following:
{
...
"main": "dist/index.js",
"bin": {
"dirmanager": "./dist/index.js"
},
...
}
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");
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
Run the following command:
npm install -g .
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
You can also try the other options and they will work fine:
dirmanager -l
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 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.
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
August 1, 2023