DEV Community

Cover image for Create a CLI to scaffold extensions
Ivan N
Ivan N

Posted on

Create a CLI to scaffold extensions

In our previous exercise, we built a browser extension using TypeScript. This involved a series of steps, including creating a Vite project and customizing it to meet the specific requirements of browser extensions. While the process wasn’t particularly lengthy or complex, we can simplify it further by automating it with a Node CLI (Command Line Interface). If you're new to CLIs, let me walk you through the one I've created!

Create a Node project

Naturally, the first step is to initialize and set up our Node project. Use the following commands to create a folder for our code and generate a basic package.json file:

mkdir create-browser-extension-vite && cd create-browser-extension-vite
npm init --yes
Enter fullscreen mode Exit fullscreen mode

Next, I decided to modify the generated package.json to include "type": "module". With this we’ll inform Node to interpret .js files in the project as ES modules rather than CommonJS modules. Here's the updated package.json after making some adjustments.

{
  "name": "create-browser-extension-vite",
  "version": "1.0.0",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "publishConfig": {
    "access": "public"
  },
  "keywords": [
      "cli",
    "create-project"
  ],
  "author": "",
  "license": "ISC",
  "description": "A CLI tool to create browser extensions with Vite",
  "type": "module"
}
Enter fullscreen mode Exit fullscreen mode

First steps

Let’s begin by creating a file called create-project in a new folder named bin:

#!/usr/bin/env node

console.log("hello world");
Enter fullscreen mode Exit fullscreen mode

This file will act as the entry point for your command and to ensure it can be ran directly on your computer once the package is installed globally, add the following field to the package.json:

"bin": "bin/create-project"
Enter fullscreen mode Exit fullscreen mode

Now it's time to test what we've built so far. First, we install the package locally by running:

npm link
create-browser-extension-vite // execute the CLI 
Enter fullscreen mode Exit fullscreen mode

Once linked, you’ll have a new CLI command called create-browser-extension-vite, which currently just prints “hello world” to the console.

And that’s all it takes to create a basic CLI! From here, you can leverage the full power of the Node ecosystem to build anything you can imagine.

Handling user input

Let’s take another step toward our goal! The aim of this CLI is to generate a fully functional TypeScript browser extension with a single command. To accomplish this, the CLI will accept several optional parameters.

Image description

  • name: If provided, a folder with the specified name will be created. Otherwise, the current folder will contain the project.
  • git: If specified, a Git repository will be initialized for the project.
  • install: If specified, the project dependencies will be installed automatically.
  • yes: Skips all prompts and generates the project with default settings.

The first step is to create a new file, src/cli.js, which will handle all the logic for collecting user preferences. This new module will be invoked from the current create-project file:

#!/usr/bin/env node

import { cli } from "../src/cli.js";

cli(process.argv);
Enter fullscreen mode Exit fullscreen mode

To streamline the process of gathering user preferences, we’ll use two useful libraries:

npm install @inquirer/prompts arg
Enter fullscreen mode Exit fullscreen mode
  • arg: A powerful argument parser for handling command-line inputs.
  • @inquirer/prompts: A library for creating elegant and interactive command-line interfaces.
import arg from "arg";
import { confirm } from "@inquirer/prompts";

async function promptForMissingOptions(options) {
  if (options.skipPrompts) {
    return options;
  }

  return {
    ...options,
    git:
      options.git ||
      (await confirm({ message: "Initialize a git repository?" })),
  };
}

function parseArgumentsIntoOptions(rawArgs) {
  const args = arg(
    {
      "--git": Boolean,
      "--help": Boolean,
      "--yes": Boolean,
      "--install": Boolean,
      "-g": "--git",
      "-y": "--yes",
      "-i": "--install",
    },
    {
      argv: rawArgs.slice(2),
    }
  );

  return {
    skipPrompts: args["--yes"] || false,
    git: args["--git"] || false,
    runInstall: args["--install"] || false,
    projectName: args._[0],
  };
}

export async function cli(args) {
  let options = parseArgumentsIntoOptions(args);
  options = await promptForMissingOptions(options);
  console.log(options);
}

Enter fullscreen mode Exit fullscreen mode

I’ll leave it to you to add an extra option for displaying a basic help message. This will involve introducing a new user preference controlled by the --help or -h parameter. If this parameter is provided, the CLI should display a simple manual explaining the command’s usage. You can refer to my solution in the repository linked below.

Creating the project

In this step, the project will be created based on the preferences selected in the previous stage. We'll begin by creating a folder named template and copying into it the files that will make up the generated project.

The folder structure should look like this, and you can find the content of these files in my GitHub repository. If you’re curious about how they were created, check out my previous post, where I discuss building a browser extension with TypeScript.

Image description

Our code will utilize the files in the template folder to generate the user's new browser extension and the following packages will be particularly useful in accomplishing this:

npm install ncp chalk execa pkg-install listr
Enter fullscreen mode Exit fullscreen mode
  • ncp: Facilitates recursive copying of files.
  • chalk: Adds terminal string styling.
  • execa: Simplifies running external commands like git.
  • pkg-install: Automatically triggers either yarn install or npm install based on the user's preference.
  • listr: Allows defining a list of tasks while providing a clean progress overview for the user.

We'll begin by creating a new file, src/main.js, to contain the code that generates the project by copying the files from the template folder.

import { createProject } from "./main.js";

...

export async function cli(args) {
  let options = parseArgumentsIntoOptions(args);
  options = await promptForMissingOptions(options);
  await createProject(options);
}
Enter fullscreen mode Exit fullscreen mode
import chalk from "chalk";
import ncp from "ncp";
import path from "path";
import { promisify } from "util";
import { execa } from "execa";
import Listr from "listr";
import { projectInstall } from "pkg-install";

const copy = promisify(ncp);

async function copyTemplateFiles(options) {
  return copy(options.templateDirectory, options.targetDirectory, {
    clobber: false,
  });
}

async function initGit(options) {
  const result = await execa("git", ["init"], {
    cwd: options.targetDirectory,
  });
  if (result.failed) {
    return Promise.reject(new Error("Failed to initialize git"));
  }
  return;
}

export async function createProject(options) {
  options = {
    ...options,
    targetDirectory: options.projectName || process.cwd(),
  };

  const currentFileUrl = import.meta.url;
  const templateDir = path.resolve(
    new URL(currentFileUrl).pathname,
    "../../template"
  );
  options.templateDirectory = templateDir;

  const tasks = new Listr([
    {
      title: "Copy project files",
      task: () => copyTemplateFiles(options),
    },
    {
      title: "Initialize git",
      task: () => initGit(options),
      enabled: () => options.git,
    },
    {
      title: "Install dependencies",
      task: () =>
        projectInstall({
          cwd: options.targetDirectory,
        }),
      skip: () =>
        !options.runInstall
          ? "Pass --install to automatically install dependencies"
          : undefined,
    },
  ]);

  await tasks.run();
  console.log("%s Project ready", chalk.green.bold("DONE"));
  return true;
}
Enter fullscreen mode Exit fullscreen mode

The code above utilizes Listr to execute the series of actions needed to generate the new project, from copying files with ncp to setting up the Git repository. Also note how we used promisify to convert the callback-based copy method of ncp into a promise-based function, making the code more readable and maintainable.

And that’s it! These are the steps I followed to create my new CLI tool, the one I’ll be using to streamline the creation of my new browser extensions. You can use it too! Because I’ve published it on npm for anyone to generate their own extensions.

https://github.com/ivaneffable/create-browser-extension-vite

References

How to build a CLI with Node.js

Top comments (0)