DEV Community

Cover image for Let’s create a Node CLI for generating files from templates!
Dušan
Dušan

Posted on

Let’s create a Node CLI for generating files from templates!

Project: CFFT - Create Files From Template CLI

In this article, I explain why I created the CLI for generating files based on templates, what issues I had to solve, and what I’ve learned.

You’ll find much useful information about building your CLI:

  • How to setup the app,
  • How to build a colorful logger,
  • How to read command line arguments,
  • How to ask questions using CLI,
  • How to create a configuration file,
  • How to locate a configuration file based on the current terminal location,
  • How to retrieve the package version,
  • How to create tables for CLI,
  • How to publish your package to npm.

About CFFT

So, what is CFFT CLI?

It is a simple, but powerful Node CLI that can be used to generate a list of files from templates. It is a file generator. We can use it regardless of a framework – React, Angular, Vue, Express, .NET, Java, Python, … The only requirement is to have Node.js installed on your machine.

Features:
• Create a custom file structure using a terminal,
Search and replace - Replace file content with a custom text or file name,
• Create multiple templates,
• Set options directly as a CLI argument, or answering on CLI questions, or:
• Set defaults by configuring a CLI using a .config JSON file for each template - cfft.config.json.

See npm package for more info.

Why I built it?

I am a full stack developer, focused on a front-end, currently working on a React-based project. I also work with, and/or understand other frameworks and languages, such as Node.js, Nest.js, Next.js, Angular, .NET, etc. Angular, for example, has a great CLI that helps you to create components and other files using an interactive command line interface. It saves you time by a lot. I decided to build something like that to use on my React projects – but not to limit only to that – I wanted to create something independent of a framework. I built it to save development time spent on repetitive tasks since I cached myself copying/pasting and renaming the same file structure repeatedly.

Let’s build the app!

Note: This is not the whole app. I am explaining only some interesting parts of it. Check GitHub to see the code.

Setup the app

I decided to use the following languages and packages:

  • node.js – this is a node CLI application, so we need a node installed on our machine,
  • typescript – for type safety,
  • esbuild – is an easy-to-configure bundler,
  • jest and ts-jest – for testing,
  • arg – for reading command line arguments,
  • chalk – for colorful console logs,
  • cli-table – for writing tables in a console,
  • inquirer – for asking questions.

To start developing, create a new project, initialize a new node project by creating a package.json (npm init command), and install the packages you need for your CLI. If you wish to use GIT, initialize it using the git init command.

The next step is to create the start (executable) file and bundle the app.

Create a new file in your source directory:

/src/index.ts



const run = () => {
  console.log('Hello World');
};

run();


Enter fullscreen mode Exit fullscreen mode

Create esbuild.js in a root folder to configure bundling:

esbuild.js



require("esbuild")
  .build({
    entryPoints: ["src/index.ts"],
    bundle: true,
    outdir: "./dist",
    platform: "node",
    loader: { ".ts": "ts" },
    minify: process.env.NODE_ENV !== "development",
    external: ["fs", "path", "util"],
  })
  .catch(() => process.exit(1));


Enter fullscreen mode Exit fullscreen mode
  • entryPoints – path to the entry file (where to start),
  • bundle – should bundle,
  • outDir – where to store the result (the output),
  • loader – we tell the bundler that we want to use typescript,
  • minify – optimizes our code by making it smaller
  • external – what to exclude from the bundle (we don’t need fs, path, and util since these are already included in node).

There are many other bundles in the market, such as webpack, swc, turbopack and parcel, but I decided to use esbuild since it is easy to configure to work with typescript.

Logger

Logs are very important for any command line application. It is the application's way to communicate with users.

I tend to create colorful messages filled with emojis, to make the console more interesting. For example:

using chalk - example

To colorize my logs, I used a chalk package and created a logger utility:

/src/logger.ts



const log = (...message: any[]) => write(...message);

const success = (...message: any[]) => write(chalk.green(...message));

const warning = (...message: any[]) => write(chalk.yellow(...message));

const info = (...message: any[]) => write(chalk.blue(...message));

const error = (...message: any[]) => write(chalk.red(...message));

const debug = (...message: any[]) =>
  isDebug() ? write(chalk.whiteBright("🔧 [DEBUG]", ...message)) : undefined;

const write = (...message: any[]): void => {
  const shouldWrite = process.env.APP_ENV !== "test";

  if (shouldWrite) {
    console.log(...message);
  }
};

export default { success, warning, info, error, log, debug };


Enter fullscreen mode Exit fullscreen mode

Read command line arguments

Reading CLI arguments is also the essential part of every CLI. It is a way to provide additional options to the application. For example:



cfft --template css --fileName my-styles


Enter fullscreen mode Exit fullscreen mode

Here we tell our CLI to use the css template and to create the my-styles file structure.

It is possible to extract command line arguments without installing any additional package:



var arguments = process.argv;

console.log(arguments);


Enter fullscreen mode Exit fullscreen mode

…but I decided to install the arg package. It is a nice utility that stores command line arguments in an object, allows you to specify aliases, does validation for you, and more.

To indicate allowed options, types, and aliases, you need to create an object and pass it to the arg function:



import arg from "arg";

export const CLI_ARGS_TYPE = {
  "--fileName": String,
  "--dirPath": String,
  "--template": String,
  "--templatePath": String,
  "--shouldReplaceFileName": String,
  "--fileNameTextToBeReplaced": String,
  "--shouldReplaceFileContent": String,
  "--textToBeReplaced": String,
  "--replaceTextWith": String,
  "--searchAndReplaceSeparator": String,
  "--debug": String,
  "--version": Boolean,
  "--help": Boolean,

  // Aliases
  "-n": "--fileName",
  "-t": "--template",
  "-v": "--version",
};

console.log(arg(CLI_ARGS_TYPE));


Enter fullscreen mode Exit fullscreen mode

If you run the following command:



cfft -n MyFile --template MyTemplate


Enter fullscreen mode Exit fullscreen mode

…the result will be:



{ _: [], '--fileName': 'MyFile', '--template': 'MyTemplate' }


Enter fullscreen mode Exit fullscreen mode

Note: See the /src/options folder on GitHub for more info.

Fill in the missing options by asking questions

Our CLI should be user-friendly. If our user forgets to specify some options, we should ask him to do it.

We can easily achieve that with the inquirer package. This package allows you to ask different types of questions, such as input questions (open-text questions), confirmation questions (Yes/No), etc.

For example, asking a simple input question can be written like this:



import inquirer from "inquirer";

(async () => {
  const answer = await inquirer.prompt([
    {
      type: "input",
      name: "fileName",
      message: "Enter file name:",
    },
  ]);

  console.log(answer);
})();


Enter fullscreen mode Exit fullscreen mode

Result:
inquirer example result

For some questions, you can set a default value, too:



import inquirer from "inquirer";

(async () => {
  const answer = await inquirer.prompt([
    {
      type: "input",
      name: "fileName",
      message: "Enter file name:",
      default: "MyFile",
    },
  ]);

  console.log(answer);
})();


Enter fullscreen mode Exit fullscreen mode

Result:
inquirer with default value example result

Note: For more information, see /src/questions.ts and /src/options on GitHub.

Create a configuration file

Having a configuration file where the user can set some default values is very useful for CLI applications. You could see previously, that at the time of writing this article, the CFFT CLI had more than 10 different options. It could happen that our users forget to fill them, or they can simply want to use the same value for their template every time. To allow them to specify those values we need a configuration file.

I faced two issues while implementing this feature:

  1. How to know where is the configuration located – where is the root of the project?
  2. How to know does it exist or not?

When I have a problem as described above, I usually split it into multiple steps:

  1. How to know the current terminal path?
  2. How to know if the configuration file is in a parent directory, or in a grandparent directory, etc?
  3. How to know when to stop searching?

CFFT, logically, creates files from a template on a current terminal path. To know that path, you can use the following Node.js function:



process.cwd()


Enter fullscreen mode Exit fullscreen mode

To check does our file exist on the current path, we should try to read it. If it exists, that is a success, if not, the function will throw an exception, so in that case, we check in the parent folder.

To search parent directories, clearly, we need a recursion.

And lastly, if the current path is the same as the previous path, that means that we’ve reached to last parent directory and that our file doesn’t exist.

This is the function that combines everything that is explained:



export const findConfig = async (
  pathArg = ".",
  previousPath?: string
): Promise<Config> => {
  const searchPath = path.join(process.cwd(), pathArg, CONFIG_FILE_NAME);
  const folderPath = resolve(path.join(process.cwd(), pathArg));
  const currentPath = resolve(searchPath);

  if (currentPath === previousPath) return null as any;

  Logger.debug(`Searching for config. Path: ${currentPath}`);

  try {
    const file = await promisify(fs.readFile)(searchPath);

    Logger.debug(`Config file found: ${currentPath}`);

    const config = JSON.parse(file.toString()) as Config;
    config.folder = folderPath;
    config.path = currentPath;

    return config;
  } catch (error) {
    return await findConfig(path.join(pathArg, ".."), currentPath);
  }
};


Enter fullscreen mode Exit fullscreen mode

Note: For more information about the configuration, see the /src/config folder on GitHub.

Retrieve the package version

It can be very useful to show the current package version to our users. The goal is to show it when the --version or -v argument is provided. This is easily implementable:



require("./package.json").version


Enter fullscreen mode Exit fullscreen mode

The --help command

Another very useful and user-friendly command for CLI is the --help command. This command should help our users to use our CLI. This is the perfect place for tables. I installed the cli-table package.

I decided to show three columns: Command, Alias, and Description.



const table = new Table({
    head: ["Command", "Alias", "Description"],
    style: {
      head: new Array(3).fill("cyan"),
    },
  });

  let rows = [
    ["--fileName", "-n", "File name to be used"],
    ["--dirPath", "", "Path to the location where to generate files"],
    ["--template", "-t", "Name of the template to use"],
    ["--templatePath", "", "Path to the specific template folder"],
    ["—-shouldReplaceFileName", "", "Should or not CLI replace a file name",],
    ["--fileNameTextToBeReplaced", "", "Wich part of the file name should be replaced",],
    ["--shouldReplaceFileContent", "", "Should or not CLI replace a file content",],
    ["--textToBeReplaced", "", "Text to be replaced separated by a search and replace separator",],
    ["--replaceTextWith", "", "Text to be used for search and replace separated by a separator",],
    ["--searchAndReplaceSeparator", "", "Custom separator for search and replace"],
    ["--version", "-v", "Show the current version of the package"],
    ["--debug", "", "Show additional logs"],
  ];

  table.push(...rows);

  console.log(table.toString());


Enter fullscreen mode Exit fullscreen mode

When we execute the help command (cfft --help), we should see the following result (resize the terminal if it doesn’t look the same):

The help command result

Note: Check /src/help.ts for more info on GitHub.

Test your code locally

With all these features, our CLI is almost ready. But during our development, before publishing it globally, we would like to test it.

This is possible by adding the following lines to the package.json file (See package.json):



"bin": {
    "cfft-dev": "dist/index.js"
 }


Enter fullscreen mode Exit fullscreen mode

bin – is where you add your global commands. We basically tell Node that when we execute the cfft-dev command, it should run the dist/index.js file (which is a file we create on the application build).

The very important step is to add the following line of code to your executable file (index.ts) as the first line:



#!/usr/bin/env node


Enter fullscreen mode Exit fullscreen mode

This line tells Node that this is a node executable file.

The next step is to install the command locally. Navigate the terminal to the location of your package.json, and execute the following command:



npm link


Enter fullscreen mode Exit fullscreen mode

Voila! You can test your CLI locally now:



cfft-dev --version


Enter fullscreen mode Exit fullscreen mode

Publish to NPM

Last, but not Least! Our CLI is finished, and we want to allow other people to use it. Let’s publish it to NPM.

In the /dist folder create another package.json. Fill in all necessary fields - author, repository, add keywords, version, description, etc. See package.json

But also, it is very important to add the bin command that will be registered when people install your CLI globally (or using npx).



"bin": {
    "cfft": "index.js"
 }


Enter fullscreen mode Exit fullscreen mode

By doing so, our CLI is ready to be published. Navigate to your /dist folder, execute the npm publish and feel proud of yourself! Congrats! 💪 🎉 (Note: follow the documentation on npm on how to do it).


Thank you! 🙂

Top comments (1)

Collapse
 
thethmuu profile image
Thet Hmuu Eain Soe

I am currently in the progress of building a CLI for next js. This is exactly what I need. Thank you