Building Your Own NPX CLI Tool

Recently, I've been working on building a number of demos and PoCs covering a number of topics related to Headless Content Platforms, AI and nextjs. In so doing, I'm often working from a common set of base projects that implement the necessary basics like routing, components, and more. After the first few times of copying a base project, I decided it would be a good time to learn how to make a command line tool to bootstrap new projects. This post describes how to create such a tool.

This interface runs via npx and uses the chalk package for styling console output, and inquirer for prompting the user for input. The language of choice is typescript.

"dependencies": {
  "@types/node": "^22.13.9",
  "chalk": "^5.4.1",
  "inquirer": "^12.4.2",
  "ts-node": "^10.9.2"
},
"devDependencies": {
  "typescript": "^5.8.2"
}

To allow the tool to be executed by npx, we'll need a few more package properties. First we'll configure the main method for execution to point to an index file that we'll build. Then we'll map a bin section with the command we want to bind to, create-myproject. Finally, we'll update the build script to compile the typescript to javascript and pack the source as an installable tgz file.

{
  // ...
  "main": "dist/index.js",
  "type": "module",
  "bin": {
    "create-myproject": "./dist/index.js"
  },
  "scripts": {
    "build": "npx tsc && npm pack
  }
}

Next we'll make a src/index.ts file that starts with a shebang to allow the file to be executed.

#!/usr/bin/env node

At this point, you can go off and build the file as you need it. For instance, you can use inquirer to ask the user to select a repository to clone.

First, we'll divert to a Git.ts to declare a helper method

import fs from "fs";
import { execSync } from "child_process";
import path from "path";
import chalk from "chalk";

export function cloneRepoToFolder(repo, projectName) {
  const fullPath = path.join(process.cwd(), projectName);
  const gitFolder = path.join(process.cwd(), projectName, ".git");

  console.log(chalk.blue("[INFO] ") + `Cloning ${repo} to ${fullPath}`);
  execSync(`git clone ${repo} ${fullPath}`);

  console.log(chalk.blue("[INFO] ") + "Cleaning git artifacts");
  fs.rmSync(gitFolder, { recursive: true, force: true });
}

We'll use this back in index.ts to help us clone the repository. We'll collect some info via inquirer and use it to make folders and clone repos.

import { select } from "@inquirer/prompts";

const options = [
  { name: "Repository 1", value: "https://github.com/jsedlak/reach" },
  { name: "Quit", value: "Quit" },
];

const projectName = await input({
  message: "What is the name of your project (e.g. site-name)?",
  required: true,
});

select({
  message: "What repository are you bootstrapping?",
  choices: options,
})
  .then((selectedOption) => {
    cloneRepoToFolder(selectedOption, projectName);
  })
  .catch((reason) => {
    /* no op */
  });

Finally, we can pack this up, install it and use it!

npm run build
npm install -g package-name-1.0.0.tgz
npx create-myproject