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();
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));
- 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:
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 };
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
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);
…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));
If you run the following command:
cfft -n MyFile --template MyTemplate
…the result will be:
{ _: [], '--fileName': 'MyFile', '--template': 'MyTemplate' }
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);
})();
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);
})();
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:
- How to know where is the configuration located – where is the root of the project?
- How to know does it exist or not?
When I have a problem as described above, I usually split it into multiple steps:
- How to know the current terminal path?
- How to know if the configuration file is in a parent directory, or in a grandparent directory, etc?
- 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()
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);
}
};
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
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());
When we execute the help command (cfft --help
), we should see the following result (resize the terminal if it doesn’t look the same):
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"
}
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
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
Voila! You can test your CLI locally now:
cfft-dev --version
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"
}
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)
I am currently in the progress of building a CLI for next js. This is exactly what I need. Thank you