Building a CLI Tool

May 9, 2024

CLI Concept

A Command Line Interface (CLI) is a tool or application that allows users to interact with a program via the command line to perform certain tasks. In frontend development, examples include vue-cli, vite, create-react-app, etc. These tools help reduce repetitive tasks, standardize workflows, and improve development efficiency.

Functional Goals

  • Create a template to initialize a Koa project
  • Allow users to specify middleware and configuration via commands
  • Specify the package manager

Implementation

To implement the above features, we generally need the following libraries:

  • inquirer – prompts users for configuration via interactive questions
  • commander – provides a CLI interface with command and argument support
  • ejs – templating engine for creating project files
  • execa – run child processes in a more user-friendly way
  • chalk – add colors and styles to console output

Entry File

First, create an index.js file inside the bin directory:

import { Command } from "commander";
import { create } from "./command/create.js";

const program = new Command();

program.name("koa-inventor").description("A CLI tool for Koa").version("1.0.4");

program
  .command("create")
  .description("Create a Koa project")
  .action(() => {
    create();
  });

program.parse();

By creating a Command instance, we can provide CLI metadata and prompts for users.

Next, we implement the create() function.

The create() function roughly performs:

  1. Create a project folder
  2. Create an entry file
  3. Create package.json
  4. Install project dependencies

Example implementation:

export async function create() {
  const config = await createConfig();
  const rootPath = `./${config.projectName}`;
  config.rootPath = rootPath;

  // 1. Create project folder
  fs.mkdirSync(rootPath);
  createEditorConfig(config);
  createMiddleWareFile(config);

  console.log(chalk.blue("Project folder created successfully"));

  // 2. Create entry file
  fs.writeFileSync(`./${rootPath}/index.js`, createBootstrapTemplate(config));
  console.log(chalk.blue("index.js created successfully"));

  // 3. Create package.json
  fs.writeFileSync(
    `./${rootPath}/package.json`,
    createPackageJsonTemplate(config)
  );
  console.log(chalk.blue("package.json created successfully"));

  // 4. Install dependencies
  console.log(chalk.blue("Installing dependencies..."));
  await installDependencies(config);
  console.log(chalk.blue("Dependencies installed successfully"));
  console.log(chalk.blue("Happy coding~~"));
}

Configuration

When running node index.js create, we need to create files based on user input. This is where inquirer comes in.

createConfig() function:

async function createConfig() {
  return await inquirer.prompt([
    projectNameConfig(),
    middlewareConfig(),
    portConfig(),
    packageManagerConfig(),
  ]);
}

projectNameConfig() function:

function projectNameConfig() {
  return {
    type: "input",
    name: "projectName",
    validate(projectName) {
      if (projectName) return true;
      return "Please enter your project name!";
    },
  };
}

By running this, users can create projects interactively.

Templates & Files

We can use ejs to generate templates. For example, creating the entry file:

export function createBootstrapTemplate(config) {
  const __dirname = fileURLToPath(import.meta.url);
  const template = fs.readFileSync(
    path.resolve(__dirname, "../../../templates/index.ejs")
  );
  const code = ejs.render(template.toString(), config);
  return code;
}

Using fileURLToPath to get __dirname is necessary for ES modules in Node. Make sure package.json includes "type": "module".

Example index.ejs:

import Koa from 'koa'

<% if (middleware.includes('koa-static')) { %>
import serve from 'koa-static'
<% } %>
<% if (middleware.includes('koa-bodyparser')) { %>
import bodyParser from 'koa-bodyparser'
<% } %>
<% if (middleware.includes('koa-router')) { %>
import Router from 'koa-router'
import useRoutes from './router/index.js'
<% } %>

const app = new Koa()

<% if (middleware.includes('koa-static')) { %>
app.use(serve('./static'));
<% } %>
<% if (middleware.includes('koa-bodyparser')) { %>
app.use(bodyParser())
<% } %>
<% if (middleware.includes('koa-router')) { %>
app.useRoutes = useRoutes
app.useRoutes()
<% } %>

app.listen(<%= port %>, () => {
  console.log('Server is running on <%= port %>')
})

You can similarly create an EJS template for package.json.

Installing Dependencies

After creating all configuration files, install dependencies according to package.json. Using execa:

import { execa } from "execa";

export async function installDependencies(config) {
  const { packageManager, rootPath } = config;
  const installCommand = packageManager.includes("npm")
    ? `${packageManager} install`
    : "yarn";

  await execa(installCommand, { cwd: rootPath });
}

Testing

In package.json, specify the bin field as the entry point, then run npm link to test the CLI locally.

Publishing

To share the CLI publicly:

  • npm login – log in
  • npm publish – publish to NPM

Conclusion

This project was born from my own frustration. When developing with Koa, I repeatedly installed middleware and configured projects. This CLI allows rapid project creation, saving setup time.