Create Custom CLI Tool with Node JS

Sep 1, 2024

Creating Command Line Tools (CLI) can be a great way to automate repetitive tasks, enhance productivity and minimize time spent on time consuming tasks. In this post we'll see how we can build a custom CLI Tool in Node js

  1. Introduction

    A CLI program basically is a program that runs in the terminal, allowing users to interact with it via commands and arguments.

  2. Prerequisites

    Before we start you need to have Node JS Installed (In your machine or containerized) check the official website.

    I run the version v22.2.0 you can check your version via the command:

    bash
    node --version

    Make sure it is equivalent or higher than mine (recommended to avoid unexpected issues).

    I will be using TypeScript in the following code. You can ignore the types if you want to stick with JavaScript.

    You can install TypeScript globally on you system using the following command:

    bash
    npm install -g typescript
  3. Setting Up The Project

    In this project I want to create a code comments and trailing spaces remover. That means it will take a file as input and removes comments or trailing spaces on that file.

    First I will create a directory for this project:

    bash
    mkdir code-cleaner && cd code-cleaner

    Note: I will use Built in modules. If you are lazy like me skip this section to the coming one where i use some npm modules.

    Now Create a new project using NPM:

    bash
    npm init -y

    Install Dependencies

    These dependencies are not required if you are using JavaScript:

    bash
    npm i -D typescript ts-node @types/node

    Configure TypeScript

    Create a tsconfig.json file to configure TypeScript:

    bash
    npx tsc --init

    If you run this command a tsconfig.json file will appear Add the following code it is missing (or uncomment it if it is commented):

    json
    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "CommonJS",
        "outDir": "./dist",
        "strict": true,
        "esModuleInterop": true
      },
      "include": ["src/**/*.ts"],
      "exclude": ["node_modules"]
    }

    Add NPM Scripts

    Add the following scripts for building the project and for development too:

    json
    "scripts": {
     "build": "tsc",
     "start": "node dist/index.js",
     "dev": "ts-node src/index.ts"
    }

    Create File Structure

    I'll create an index.ts file (or index.js) file inside the src directory as the entry point to the project. Run the following command:

    bash
    mkdir src
    touch src/index.ts

    Test if Everything works

    Let's add some code to the index.ts file:

    typescript
    console.log("Hello, SyntaxBox!"); // this may get somebody mad :)

    Now let's run the build, start, dev commands.

    Let's try the first command:

    bash
    npm run build

    If it builds with no errors you will see that there is a new dist directory. It has the compiled JavaScript code.

    I'll keep the rest for you to test them.

    Now The File structure Will be something similar to this:

    bash
    code-cleaner/
     ├── src/
       └── index.ts
     ├── dist/
     ├── package.json
     ├── tsconfig.json
     └── README.md
  4. Create The CLI Logic

    1. Add Shebang and Imports

      First let's import the required packages in the index.ts file:

      typescript
      #!/usr/bin/env node
       
      import * as path from "path";
      import * as fs from "fs";

      The first line basically allows the script to be executed from the command line directly.

      Here is an AI generated explanation to it if you are a Nerd:

      typescript
      #!/usr/bin/env node
       
      `This line is called a shebang or hashbang. Let's break it down:
       
        1. "#!" - This is the shebang notation. It tells the system that this file should be executed by an interpreter.
       
        2. "/usr/bin/env" - This is a command that locates and executes programs in the user's PATH. It's used here to find the "node" executable.
       
        3. "node" - This specifies that the Node.js interpreter should be used to execute this script.
       
        By using this shebang line, you're indicating that this file is a Node.js script that can be executed directly from the command line on Unix-like systems (Linux, macOS, etc.). When you make the file executable (using "chmod +x filename.js") and run it ("./filename.js"), the system will use Node.js to interpret and run the script.
       
        This approach is more portable than specifying a direct path to the Node.js executable (like "#!/usr/bin/node") because it will work even if Node.js is installed in different locations on different systems, as long as it's in the user's PATH.`;

      Now if we run build command and then make compiled file executable via this command (Read the explanation):

      bash
      chmod +x ./dist/index.js

      And then try to run the command via

      bash
      ./dist/index.js

      It works (will not output anything because it is empty).

      Congrats you created the first CLI Tool.

    2. Basic CLI Structure

      If we try to log the process.argv object inside the project like this:

      typescript
      console.log(process.argv);

      Then build and run the file:

      bash
      npm run build
      ./dist/index.js

      We get nothing interesting:

      bash
      ./dist/index.js
      [
      '/home/hamid/.nvm/versions/node/v22.2.0/bin/node',
      '/home/hamid/dev/scripts/nodejs/code-cleaner/dist/index.js',
      ]

      These two paths mean the binary path for Node JS and the path for our JS file.

      But if we add some text after the file path like this:

      bash
      ./dist/index.js syntaxbox is cool

      We get this output

      bash
      [
      '/home/hamid/.nvm/versions/node/v22.2.0/bin/node',
      '/home/hamid/dev/scripts/nodejs/code-cleaner/dist/index.js',
      'syntaxbox',
      'is',
      'cool'
      ]

      Which means we can send arguments via writing them after the file name. Here is an AI generated text explaining the standard way to use arguments:

      typescript
      `Standard Usage of Command-Line Arguments
      Command-line arguments are a powerful way to interact with your CLI tool. Here's a standard approach to using them:
       
      1. Positional Arguments: These are arguments that are expected to be in a specific position. For example, in the command "./dist/index.js greet John", "greet" could be considered the command, and "John" the argument.
       
      2. Flags: These are typically used to modify the behavior of a command. They are usually prefixed with one or two dashes, such as "-v" for a version flag or "--name" for a named option.
       
      3. Options: Often used in conjunction with flags, options provide additional parameters. For example, "./dist/index.js greet --name John" uses "--name" as a flag with "John" as its value.`;

      To make Things easier to work with we can slice the arguments from the two first paths like this:

      typescript
      const args = process.argv.slice(2); // Slice out the first two elements
    3. Create Arguments Parser Function

      First let's define what we need as an interface.

      We need the following flags:

      • --file or --f flag for file name.
      • --spaces to specify the removal or trailing spaces.
      • --comments for the comments removal and also for the comments syntax argument after it.
      • --multiStart for the multi line comments removal and also. For the multi line comments starting syntax argument after it.
      • --multiEnd for the multi line comments removal and also for the multi line comments ending syntax argument after it.
      typescript
      interface CleanOptions {
        file: string;
        spaces: boolean;
        comments?: string;
        multiStart?: string;
        multiEnd?: string;
      }

      Now let's Create the parser function which will return CleanOptions type:

      typescript
      function parseArguments(args: string[]): CleanOptions {
        const options: CleanOptions = {
          file: "",
          spaces: false,
        };
       
        for (let i = 0; i < args.length; i++) {
          switch (args[i]) {
            case "-f":
            case "--file":
              options.file = args[++i];
              break;
            case "--spaces":
              options.spaces = true;
              break;
            case "--comments":
              options.comments = args[++i];
              break;
            case "--multi-start":
              options.multiStart = args[++i];
              break;
            case "--multi-end":
              options.multiEnd = args[++i];
              break;
          }
        }
       
        if (!options.file) {
          console.error(
            "Error: File path is required. Use -f or --file to specify the file.",
          );
          process.exit(1);
        }
       
        return options;
      }
    4. Create The Business Logic

      Let's create the functions that will handle the trailing spaces and comments removal:

      typescript
      function removeTrailingSpaces(line: string): string {
        return line.trimRight();
      }
      typescript
      function removeSingleLineComment(
        line: string,
        commentSyntax: string,
      ): string {
        return line.split(commentSyntax)[0].trimRight();
      }
      typescript
      function handleMultiLineComments(
        lines: string[],
        multiStart: string,
        multiEnd: string,
      ): string[] {
        let inMultiLineComment = false;
        return lines.map((line) => {
          if (inMultiLineComment) {
            if (line.includes(multiEnd)) {
              inMultiLineComment = false;
              return line.split(multiEnd)[1] || "";
            }
            return "";
          } else if (line.includes(multiStart)) {
            inMultiLineComment = true;
            const parts = line.split(multiStart);
            return parts[0].trim();
          }
          return line;
        });
      }
      typescript
      interface LineProcessResult {
        line: string;
        inMultiLineComment: boolean;
      }
       
      function processLine(
        line: string,
        options: CleanOptions,
        inMultiLineComment: boolean,
      ): LineProcessResult {
        if (inMultiLineComment) {
          return { line: "", inMultiLineComment };
        }
       
        if (options.spaces) {
          line = removeTrailingSpaces(line);
        }
       
        if (options.comments) {
          line = removeSingleLineComment(line, options.comments);
        }
       
        return { line, inMultiLineComment };
      }

      Also let's create the functions responsible for files read/write:

      typescript
      function readFile(filePath: string): Promise<string> {
        return new Promise((resolve, reject) => {
          fs.readFile(filePath, "utf8", (err, data) => {
            if (err) reject(err);
            else resolve(data);
          });
        });
      }
      typescript
      function writeFile(filePath: string, content: string): Promise<void> {
        return new Promise((resolve, reject) => {
          const { name, ext } = path.parse(filePath);
          const outputPath = `${name}_cleaned${ext}`;
          fs.writeFile(outputPath, content, "utf8", (err) => {
            if (err) reject(err);
            else {
              console.log(`Cleaned file saved as: ${outputPath}`);
              resolve();
            }
          });
        });
      }

      Now let's create the function that puts all the pieces of the puzzle together:

      typescript
      async function cleanCode(options: CleanOptions): Promise<void> {
        try {
          let content = await readFile(options.file);
          let lines = content.split("\n");
       
          if (options.multiStart && options.multiEnd) {
            lines = handleMultiLineComments(
              lines,
              options.multiStart,
              options.multiEnd,
            );
          }
       
          let inMultiLineComment = false;
          lines = lines.map((line) => {
            const result = processLine(line, options, inMultiLineComment);
            inMultiLineComment = result.inMultiLineComment;
            return result.line;
          });
       
          const cleanedContent = lines.join("\n");
          await writeFile(options.file, cleanedContent);
        } catch (error) {
          console.error(
            "Error:",
            error instanceof Error ? error.message : String(error),
          );
          process.exit(1);
        }
      }

      Now just call the functions or wrap it in a main function for easier readability:

      typescript
      async function main() {
        const options = parseArguments(args); // you can also past process.argv but you need to change the loop to start from 2
        await cleanCode(options);
      }
       
      main();
  5. Create CLI Tools Using Commander

    Commander is a JS library for argument parsing which means it can replace parseArguments function

    First let's install the package:

    bash
    npm i commander

    Then let's use it to parse the arguments like this:

    typescript
    import { Command } from "commander";
     
    const program = new Command();
     
    program
      .requiredOption("-f, --file <file>", "File path to clean")
      .option("--spaces", "Remove trailing spaces", false)
      .option("--comments <comments>", "Single line comment syntax")
      .option("--multi-start <multiStart>", "Start of multi-line comment")
      .option("--multi-end <multiEnd>", "End of multi-line comment");
     
    program.parse(process.argv);

    Also, We need to change the main function:

    typescript
    async function main() {
      const options: CleanOptions = program.opts();
      await cleanCode(options);
    }
     
    main();

    Note:
    You can do a lot more with this library check this GitHub repo.

Conclusion

Congratulations! You've built a fully functioning CLI tool with Node.js and TypeScript, complete with commands, options, error handling, and a polished user experience. This tool can now be used locally, shared with your team, or published for the world to use.

By following this guide, you've learned not just how to build a CLI tool, but also how to structure a Node.js project, handle command-line arguments, validate input, and enhance user experience. This foundation can be the starting point for more complex and powerful CLI tools.

Happy Hacking!