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.argvarray. 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.argvarray will look like something like this:
1[2 '/usr/local/bin/node', // path to the node executable3 '/Users/USERNAME/node-ts-starter/index.js', // path to the file that is being executed4 '--version' // arguments passed to the file5]
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.argvarray 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. 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.jscould 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';23const 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.
1program2.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
parsemethod 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 --version23# output 0.0.1
Now let's define an option that lists the content of a directory. We can do that by calling the
optionmethod on the program object.
1program2.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';34// previous code here56const options = program.opts();78// if the list option is passed, we list the content of the directory9if (options.list) {10 fs.readdir(process.cwd(), { withFileTypes: true }, (err, files) => {11 if (err) {12 console.log(err);13 return;14 }1516 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 });2223 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}3233const 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}4243// if the show option is passed, we show the content of the file44if (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 --list2pnpm 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
--listoption 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';45const options = program.opts();67// Previous code here ...89// 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 }1617 const fileList = files.map((file) => {18 if (file.isDirectory()) {19 return `π ${file.name}`;20 }21 return `π ${file.name}`;22 });2324 inquirer25 .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};4344// if the list option is passed, we list the content of the directory45if (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 fromfs.readdir
and creates a new array calledfileList
. 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) => {...})
: Theinquirer.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:
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.