DEV Community

Cover image for Interactive Node CLI tool from absolute scratch 🔥
Pramit Marattha for Aviyel Inc

Posted on • Updated on

Interactive Node CLI tool from absolute scratch 🔥

In this blog tutorial, you will learn how to create your very own Command Line Interface using Node.js, where we will attempt to automatically fetch the pre-configured JavaScript and various other frameworks boilerplate initial project templates.

So, What is Node CLI?

CLI tools enable you to perform specific tasks or operations directly from your terminal or command line prompt. CLI’s can be constructed in a variety of computer languages, with Node.js being one of the most popular platforms. Node.js' command-line interfaces (CLIs) simplify and accelerate repetitive operations while making use of the vast Node.js infrastructure. These can be readily deployed and accessed across different systems due to the existence of package managers like node package manager(npm), yarn and pnpm.

Node CLI

So, without further ado, let's get started and develop our very own CLI to acquire/fetch our pre-configured simple static site template, JavaScript templates and several other frameworks boilerplate basic project templates automatically with the help of just simple CLI commands.

Project configuration

Let's begin by creating a new folder for our project named template-grabber, which will serve as a command project formatting for CLI's on NPM. Next, we'll execute npm init --y to initialize our npm project, and then open it in your preferred coding editor.

making directory

npm init

Demo

Then, inside our main project directory, make a src directory and a bin directory, and inside the src directory, make a interfaceCommand.js file, and inside the bin directory, make an empty template-grabber file without any file extension. As a result, the structure of your folders and files should resemble something like this.

File and Folder structure

Let's open our interfaceCommand.js file in the src folder and export a function called an interfaceCommand that accepts some arguments. For now, we'll just console log the arguments.

// src/interfaceCommand.js
export function interfaceCommand(args) {
    console.log(args);
}
Enter fullscreen mode Exit fullscreen mode

Next, navigate to the template-grabber file, which is located in the bin directory, and inside it, we will simply create a script inside of the node that requires the esm module. This allows us to use es modules without having to transpile the variety of node.js versions that may not have that kind of support, and once we have done that, we will call the interfaceCommand function from within the interfaceCommand.js

// bin/template-grabber
#!/usr/bin/env node

require = require('esm')(module /*, options*/);
require('../src/interfaceCommand').interfaceCommand(process.argv);
Enter fullscreen mode Exit fullscreen mode

Then we'll use npm to install the esm module, and then we'll go to our package.json file and alter it for publishing our npm package, notably name, which we'll set to @pramitmarattha/template-grabber.You should create or add your own npm name, and don't forget to update the description. In the main, point it to the index.js file in the src directory, and the bin directory. Create two entries as mentioned in the code below, and then create a publishConfig with public access and don't forget to set up a keyword for the project.

ESM module

ESM module

ESM module Installation

The "package.json" file should look like this after the dependencies have been installed.

{
   "name":"@pramitmarattha/template-grabber",
   "version":"1.0.0",
   "description":"A Command Line Interface ( to automatically setup pre-configured JavaScript and various other frameworks initial project template ) crafted using NodeJS and external third-party library",
   "main":"src/index.js",
   "bin":{
      "@pramitmarattha/template-grabber":"bin/template-grabber",
      "template-grabber":"bin/template-grabber"
   },
   "publishConfig":{
      "access":"public"
   },
   "scripts":{
      "test":"echo \"Error: no test specified\" && exit 1"
   },
   "repository":{
      "type":"git",
      "url":"git+https://github.com/pramit-marattha/Template-grabber-Node-CLI.git"
   },
   "keywords":[
      "cli",
      "command",
      "line",
      "interface",
      "javascript",
      "react",
      "generator",
      "template",
      "project",
      "vite",
      "vue",
      "auto-generator",
      "template-grabber"
   ],
   "author":"pramitmarattha",
   "license":"MIT",
   "bugs":{
      "url":"https://github.com/pramit-marattha/Template-grabber-Node-CLI/issues"
   },
   "dependencies":{
      "esm":"^3.2.25"
   }
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll use npm link to establish a link to our code so that we can test it out by simply typing template-grabberinto the terminal.

npm link

Template Grabber

So, let's run template-grabber into our terminal and specify --yes, and we'll see that there are roughly three arguments passed in because we logged out using console.log previously.

Template Grabber

Let's examine what happens if we simply use template-argument. As you can see, there are only two arguments.

Template Grabber argument

Let's try again with template-grabber --yes --git, which has four arguments as you can see.

template grabber --yes --git

Arranging and processing our arguments

Let's go over each argument one by one now that we've prepared them. The arguments our CLI will accept are a template, which can be javascript or other frameworks, as well as whether you want to do a git initialization and whether you want to install node dependencies using the npm package manager.

Arguments

We'll utilize a few packages to help us out here, including inquirer, which allows us to ask questions about missing choices, and arg, which allows us to process arguments into options. So to install these packages simply type the following command into your terminal.

npm install inquirer arg
Enter fullscreen mode Exit fullscreen mode

Dependencies

After installing these packages, your "package.json" file's dependencies section should look like this.

package.json

Now that we've installed our dependencies, let's use them, so let's import arg into our interface first. After that, create a function called argumentOptionsParser that takes the command line inputs and turns them to options. So we're specifying the parameters we're hoping to see in this object, which include --git --yes and --install as well as their aliases. Finally, the second object that we pass in is the arguments that we want argv to use, which start at the third argument of the raw args, so the first one is the template-garbber and the second one is the template, so starting at the three, we are looking for these "--" arguments, and after that, we will return some options in an object, so skipPrompts will correspond to if the user specifies --yes and If the user specifies --install the runInstall option corresponds; otherwise, it will be false. template is actually the user's first argument, so it'll be args._[0], and finally, if the user specifies --git the git the option will correspond.As a result, your argumentOptionsParser function in your interfaceCommand file should look like this.

function argumentOptionsParser(rawArguments) {
  let args = arg(
    {
      "--git": Boolean,
      "--help": Boolean,
      "--yes": Boolean,
      "--install": Boolean,
      "--g": "--git",
      "--h": "--help",
      "--y": "--yes",
      "--i": "--install",
    },
    {
      argv: rawArguments.slice(2),
    }
  );
  return {
    template: args._[0],
    skipPrompts: args["--yes"] || false,
    git: args["--git"] || false,
    runInstall: args["--install"] || false,
  };
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll go to our interfaceCommand function and process the previously declared options instead of the args, and we'll console.log the options instead of the args.

export function interfaceCommand(args) {
  let opts = argumentOptionsParser(args);
  console.log(opts);
}
Enter fullscreen mode Exit fullscreen mode

Let's go over to our terminal and put it to the test.

Demo

Inquiring about the missing/undeclared items

Let’s prompt the user for any missing items they didn’t pass in on the command line. To do this, we'll create an async function called inquireUndeclaredItems that takes the choices we've gathered so far and prompts the user for any missing items they didn't define on the command line. The first thing we do inside that method is set the default template to react. Next, we want to tick the skipPrompts option since we don't want to prompt users with options if they don't want to be prompted again. So we'll verify if users have specified skip prompts, and if they have, we'll take the choices we've gathered so far and set the template to either the template the user-specified in opts.template or the default vanilla react template if they didn't specify one on the command line.The next thing we'll do is set up our lists of questions so we can assist the user in filling in the missing parameters. The first thing we'll look for is the template, and if they haven't specified one, we'll create a question to ask them which template to use. We'll start by pushing a question on, and it'll be a list type, so we'll give the user a couple of options to choose from. The message will be "What template would you like to use?" and the name will be a template name. The options will be react, javascript or viteReact templates, with react being the default option, as stated above. If they haven't specified git, we'll do something similar and simply ask the users if they want to start the git repository inside the templated projects, with the default being false. We'll set a constant of answers equals to await inquirer to prompt the questions and that'll return an answer to users specified, so we'll return our existing options as well as the template whether of the template they specified inside of the options or the answers that the user gave us, and we'll do the same thing for the git.As a result, your inquireUndeclaredItems function in your interfaceCommand file should look like this.

async function inquireUndeclaredItems(opts) {
  const defaultTemplate = "React";
  if (opts.skipPrompts) {
    return {
      ...opts,
      template: opts.template || defaultTemplate,
    };
  }
  const displayOptions = [];
  if (!opts.template) {
    displayOptions.push({
      type: "list",
      name: "template",
      message: "What template would you like to use?",
      choices: ["React", "viteReact", "JavaScript"],
      default: defaultTemplate,
    });
  }

  if (!opts.git) {
    displayOptions.push({
      type: "confirm",
      name: "git",
      message: "Would you like to use git?",
      default: false,
    });
  }

  const userInput = await inquirer.prompt(displayOptions);
  return {
    ...opts,
    template: opts.template || userInput.template,
    git: opts.git || userInput.git,
  };
}
Enter fullscreen mode Exit fullscreen mode

Let's move on to our interfaceCommand function now that you've successfully constructed this inquireUndeclaredItems function. Let's use the command to prompt for the missing options while passing the options we have so far and making the function asynchronous.Hence, your interfaceCommandfunction in your interfaceCommand file should look like this.

export async function interfaceCommand(args) {
  let opts = argumentOptionsParser(args);
  opts = await inquireUndeclaredItems(opts);
  console.log(opts);
}
Enter fullscreen mode Exit fullscreen mode

If you've followed all of the detailed instructions till now, your interfaceCommand.js file should look like this.

// src/interfaceCommand.js
import arg from "arg";
import inquirer from "inquirer";

function argumentOptionsParser(rawArguments) {
  let args = arg(
    {
      "--git": Boolean,
      "--help": Boolean,
      "--yes": Boolean,
      "--install": Boolean,
      "--g": "--git",
      "--h": "--help",
      "--y": "--yes",
      "--i": "--install",
    },
    {
      argv: rawArguments.slice(2),
    }
  );
  return {
    template: args._[0],
    skipPrompts: args["--yes"] || false,
    git: args["--git"] || false,
    runInstall: args["--install"] || false,
  };
}

async function inquireUndeclaredItems(opts) {
  const defaultTemplate = "React";
  if (opts.skipPrompts) {
    return {
      ...opts,
      template: opts.template || defaultTemplate,
    };
  }
  const displayOptions = [];
  if (!opts.template) {
    displayOptions.push({
      type: "list",
      name: "template",
      message: "What template would you like to use?",
      choices: ["React", "viteReact", "JavaScript"],
      default: defaultTemplate,
    });
  }

  if (!opts.git) {
    displayOptions.push({
      type: "confirm",
      name: "git",
      message: "Would you like to use git?",
      default: false,
    });
  }

  const userInput = await inquirer.prompt(displayOptions);
  return {
    ...opts,
    template: opts.template || userInput.template,
    git: opts.git || userInput.git,
  };
}

export async function interfaceCommand(args) {
  let opts = argumentOptionsParser(args);
  opts = await inquireUndeclaredItems(opts);
  console.log(opts);
}
Enter fullscreen mode Exit fullscreen mode

Now let's see whether this works, so open your terminal and type template-grabber.

Demo

It will also ask us whether we want a git repo set up for our project or not.

git prompt

Adding templates

Now that we have options set up for our users, it's time to generate and initialize the template. To do so, we'll use ncp to copy some of the template files and chalk to format the output using various different colors. So to install these packages simply type the following command into your terminal.

npm install ncp chalk
Enter fullscreen mode Exit fullscreen mode

Dependencies

After installing these packages, your "package.json" file's dependencies section should look like this.

package json

All of our application's core logic will be included in a file called main.js in the source folder. As an outcome, the structure of your folders and files should look like this.

Folder structure

Let's start by importing all of the necessary dependencies into our main.js file, then promisifying the fs.access and ncp functions and storing them as access. We'll use access to check for reading access to a file and copy to copy our project template files into the users’ target folder recursively.

// src/main.js
import fs from "fs";
import path from "path";
import chalk from "chalk";
import ncp from "ncp";
import { promisify } from "util";

// access
const access = promisify(fs.access);

// reccusive copy
const copy = promisify(ncp);
Enter fullscreen mode Exit fullscreen mode

Next, we'll write an asynchronous method called copyProjectTemplateFiles that takes the options and uses them to copy the template directory to the target directory without overwriting it, so for that, we'll set clobber to false.

// async function to copy template files
async function copyProjectTemplateFiles(opts) {
  return copy(opts.templateDirectory, opts.targetDirectory, {
    clobber: false,
  });
}
Enter fullscreen mode Exit fullscreen mode

Next, we'll create the templateGrabber function, which will create users bootstrap the project. The first thing we'll do inside this function is specify a target directory, so we'll take the options we've had so far and also specify the target directory. If they passed in a target directory, we'll use that; otherwise, we'll use the process on the current working directory, which will be our normal operations. Then, using path.resolve from the current pathname, we'll set the template directory. Several directories up, there's a folder called projectTemplates, and inside it, there's a folder with the templates folder name. We'll be able to resolve the template directory utilizing all of that inside our path.resolve function. We can now set the template directory within our options once we have that. Now that we have a template directory, we need to check to see if it exists, so we use "access" to look at it. If it succeeds, we're set to go; if it doesn't, we'll merely log out the error and exit the process inside our catch block. We'll simply log out the success message if everything went smoothly. If you've followed all of the detailed instructions till now, your templateGrabber function should look like this.

export async function templateGrabber(opts) {
  opts = {
    ...opts,
    targetDirectory: opts.targetDirectory || process.cwd(),
  };

  const fullPathName = new URL(import.meta.url).pathname;
  let templateDir = path.resolve(
    fullPathName.substr(fullPathName.indexOf("/")),
    "../../projectTemplates",
    opts.template.toLowerCase()
  );
  templateDir = templateDir.substring(3);
  opts.templateDirectory = templateDir;

  try {
    await access(templateDir, fs.constants.R_OK);
  } catch (err) {
    console.log(chalk.red(`Template directory ${templateDir} does not exist`));
    console.log(err);
    process.exit(1);
  }

  console.log("Copying project files....");
  await copyProjectTemplateFiles(opts);

  console.log(chalk.green(`Creating project from template ${opts.template}`));
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Now we need to correctly arrange and build our folders and files, so let's make a projectTemplates directory inside our main project folder. Create three directories inside it for now: react, viteReact, and javascript. Inside each of these, add your own project templates, or go to the following repo and grab the projectTemplates files if you just want to follow along with this guide.

Project Templates

The structure of your files and folders should resemble something like this.

Files and Folder structure

Return to your interfaceCommand.js file and import the templateGrabber function from the main logic file, then replace the console log with the templateGrabber function and supply the CLI arguments to it. After you've got that figured out, your interfaceCommand.js file should look like this.

// src/interfaceCommand.js
import arg from "arg";
import inquirer from "inquirer";
import { templateGrabber } from "./main.js";

function argumentOptionsParser(rawArguments) {
  let args = arg(
    {
      "--git": Boolean,
      "--help": Boolean,
      "--yes": Boolean,
      "--install": Boolean,
      "--g": "--git",
      "--h": "--help",
      "--y": "--yes",
      "--i": "--install",
    },
    {
      argv: rawArguments.slice(2),
    }
  );
  return {
    template: args._[0],
    skipPrompts: args["--yes"] || false,
    git: args["--git"] || false,
    runInstall: args["--install"] || false,
  };
}

async function inquireUndeclaredItems(opts) {
  const defaultTemplate = "React";
  if (opts.skipPrompts) {
    return {
      ...opts,
      template: opts.template || defaultTemplate,
    };
  }
  const displayOptions = [];
  if (!opts.template) {
    displayOptions.push({
      type: "list",
      name: "template",
      message: "What template would you like to use?",
      choices: ["React", "JavaScript", "vite"],
      default: defaultTemplate,
    });
  }

  if (!opts.git) {
    displayOptions.push({
      type: "confirm",
      name: "git",
      message: "Would you like to use git?",
      default: false,
    });
  }

  const userInput = await inquirer.prompt(displayOptions);
  return {
    ...opts,
    template: opts.template || userInput.template,
    git: opts.git || userInput.git,
  };
}

export async function interfaceCommand(args) {
  let opts = argumentOptionsParser(args);
  opts = await inquireUndeclaredItems(opts);
  // console.log(opts);
  await templateGrabber(opts);
}
Enter fullscreen mode Exit fullscreen mode

So let’s try out one demo first before we proceed any further so for that create one test sample directory and lets run our template project script inside it.

Demo

Your react template should be ready if you look in your sample-testing folder.

Copied template

Fixing git initialization and project installation

Now that we're almost done, let's fix the git initialization issues and issues for installing the packages inside our project templates. To do so, we'll use execa, pkg-install, and listr, which are all external thriparty packages. To install these packages simply type the following command inside of your terminal.

npm install listr pkg-install execa
Enter fullscreen mode Exit fullscreen mode

Dependencies

After installing these packages, your "package.json" file's dependencies section should look like this.

package json

Let's start by importing all of the necessary dependencies into our main.js file, then we'll create an asynchronous function called initializeGit that will take in our opt, and inside of that we'll run execa and specify that we want to run git with the parameter of init, and we'll use the current working directory as a opts.targetDirectory,which is the directory from which the user is currently running the project, and finally, if the result failed, we'll simply need to reject this promise and return the failed message to it .

async function initializeGit(opts) {
    const result = await execa("git", ["init"], {
        cwd: opts.targetDirectory,
    });
    if (result.failed) {
        console.error(chalk.red("Failed to initialize git repository"));
        return Promise.reject(
            new Error(`Failed to initialize git repository: ${result.stderr}`)
        );
        process.exit(1);
    }
    return;
}
Enter fullscreen mode Exit fullscreen mode

Finally, inside of our templateGrabber function, we'll replace the point where we copied our template files with a Listr to list the task, so inside this we'll simply copy the project files, initialize the git, and install the dependencies. This will take a list of objects with titles and tasks, so the first one will be copy project files, and inside the task, we'll run copyProjectTemplateFiles and pass the opts to it. The second one will be for initializing git, so name it accordingly. The task that we will run there is initilizeGit, and we will pass our opts. Finally, we will specify our third argument called enabled, which will simply check to see if git is initialized inside the project or not. Installing the project dependencies is the final and third task, so title it appropriately, and the task will be project install, taking in a current working directory of opts.targetDirectory inside this one, we'll specify another argument called skip, which will simply skip the task and let the user know that if they don't specify run "--install" as an option, they can pass --install to automatically install the dependencies. Finally, use await "runningTask.run" to begin the process of running these tasks, which will initialize git, install dependencies, and copy files if the user desires. After you have added everything, your main.js file should look like this.

// src/main.js
import chalk from "chalk";
import fs from "fs";
import path from "path";
import ncp from "ncp";
import { promisify } from "util";
import { projectInstall } from "pkg-install";
import execa from "execa";
import Listr from "listr";

// access
const access = promisify(fs.access);

// reccusive copy
const copy = promisify(ncp);

async function initializeGit(opts) {
  const result = await execa("git", ["init"], {
    cwd: opts.targetDirectory,
  });
  if (result.failed) {
    console.error(chalk.red("Failed to initialize git repository"));
    return Promise.reject(
      new Error(`Failed to initialize git repository: ${result.stderr}`)
    );
    process.exit(1);
  }
  return;
}

// async function to copy template files
async function copyProjectTemplateFiles(opts) {
  return copy(opts.templateDirectory, opts.targetDirectory, {
    clobber: false,
  });
}

export async function templateGrabber(opts) {
  opts = {
    ...opts,
    targetDirectory: opts.targetDirectory || process.cwd(),
  };

  const fullPathName = new URL(import.meta.url).pathname;
  let templateDir = path.resolve(
    fullPathName.substr(fullPathName.indexOf("/")),
    "../../projectTemplates",
    opts.template.toLowerCase()
  );
  templateDir = templateDir.substring(3);
  opts.templateDirectory = templateDir;

  try {
    await access(templateDir, fs.constants.R_OK);
  } catch (err) {
    console.log(chalk.red(`Template directory ${templateDir} does not exist`));
    console.log(err);
    process.exit(1);
  }

  // console.log("Copying project files....");
  // await copyProjectTemplateFiles(opts);

  const runningTask = new Listr([
    {
      title: "Hold up!! Copying project files...",
      task: async () => await copyProjectTemplateFiles(opts),
    },
    {
      title: "Waitt!!! Initializing git repository....",
      task: async () => await initializeGit(opts),
      enabled: () => opts.git,
    },
    {
      title: "REEEEEEE!! Installing dependencies....",
      task: async () =>
        await projectInstall({
          cwd: opts.targetDirectory,
        }),
      skip: () =>
        !opts.runInstall ? "--install to install all dependencies" : undefined,
    },
  ]);

  await runningTask.run();

  console.log(chalk.green(`Creating project from template ${opts.template}`));
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Lets test out our script so lets create one sample testing folder and lets fire the following command/script inside the terminal

template-grabber viteReact --git --install
Enter fullscreen mode Exit fullscreen mode

Demo

generated template

The project's complete source code can be found here.

https://github.com/pramit-marattha/Template-grabber-Node-CLI

Conclusion

You've just successfully learned how to build a CLI tool from the ground up using Node.js. This project's potential is limitless, and also don't forget to check out some of the libraries described above, since these packages are really powerful and may be used to develop a variety of large industry level tools, so get creative! and Happy Coding!!

Main article available here => https://aviyel.com/post/1316

Happy Coding!!

Follow @aviyelHQ or sign-up on Aviyel for early access if you are a project maintainer, contributor, or just an Open Source enthusiast.

Join Aviyel's Discord => Aviyel's world

Twitter =>[https://twitter.com/AviyelHq]

Discussion (2)

Collapse
lukeshiru profile image
LUKESHIRU

Leaving aside the fact that the title says "from scratch" when is using a lot of dependencies, I'll recommend some replacements for the packages you used:

  • For chalk, a good replacement is kleur.
  • For esm, I would recommend to instead of using it, just add "type": "module" to your package.json and use ESM directly.
  • For inquirer, a great replacement is propmts.
  • Instead of using ncp and promisifying it, you can use node:fs/promises, or if you really want to use a package, mem-fs with mem-fs-editor is fast and extremely battle tested.
  • I believe pkg-install uses only npm, so ideally you should leave the install to the user or at least make it optional. You can also use packages such as detect-package-manager or maybe which-pm-runs and run whatever the dev prefers. No everyone uses npm (I personally use pnpm and there are lots of folks that use yarn).

Cheers!

Collapse
pramit_marattha profile image
Pramit Marattha Author • Edited on

Oh wait !! There is an substitute for chalk . 👀 ... Thanks for the info !!
and also , pkg-install supports both npm and yarn btw .