See all blogs

February 22, 2026

Building and Publishing a CLI as an npm Package

Building and Publishing a CLI as an npm PackageBuilding and Publishing a CLI as an npm Package

Source code: github.com/Watuulo-Richard/Better-Auth-Modern-Authentication-Lesson-Next.js-TypeScript-

Don't forget to star the repo if you like it.

backstory

If you are only interested in the implementation, you can skip to the technical part

I've always found CLI tools fascinating. The idea that you can type a single command and have a fully scaffolded, ready-to-run project appear right in your terminal is just magical. So after going through the Desishub tutorial on building a CLI as an npm package, I decided to take it further — build my own authentication starter scaffolding tool and publish it for others to use.

The goal was clear: when a developer runs npx better-auth my-app, they should get a Next.js + Better Auth project cloned, dependencies installed, and ready to go. Simple in theory. But as I discovered, there's a lot of nuance hiding behind that one command.

...and that's where the journey began.

emotional rollercoaster

rabbit hole: part 1 — project setup

Day 1: Setting up the project

The first thing I did was create a fresh directory and initialize an npm project:

mkdir better-auth
cd better-auth
npm init -y

Running npm init -y scaffolds a default package.json. This is the identity card of our package — it tells npm everything: the name, version, dependencies, and crucially, the CLI command to expose.

Next, I installed all the dependencies:

npm install commander chalk fs-extra inquirer simple-git ora

Here's what each one does and why I needed it:

PackagePurpose
commanderParses command-line arguments and defines CLI commands
chalkAdds colors to terminal output — red for errors, green for success
fs-extraEnhanced file system operations like mkdirSync and existsSync
inquirerCreates interactive terminal prompts for user input
simple-gitRuns Git operations from Node.js — used to clone the template repo
oraShows animated spinners while async tasks run

rabbit hole: part 2 — the shebang line

Day 1: Making the file executable

I created index.js and immediately hit my first "why does this work?" moment. The very first line of the file must be:

#!/usr/bin/env node

This is called a shebang (or hashbang). On Unix-based systems (Linux, macOS), it tells the OS to use the node binary to execute this file when run directly as a command. Without it, your CLI simply won't work when installed globally or via npx.

After adding the shebang, mark the file executable:

chmod +x index.js

On Windows, npm handles this automatically — but it's good practice on any system.


rabbit hole: part 3 — adding the ASCII art banner

Day 2: Making it look cool

Every great CLI has a splash screen. I added ASCII art at the very top of index.js using chalk:

██████╗ ███████╗████████╗████████╗███████╗██████╗      █████╗ ██╗   ██╗████████╗██╗  ██╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗    ██╔══██╗██║   ██║╚══██╔══╝██║  ██║
██████╔╝█████╗     ██║      ██║   █████╗  ██████╔╝    ███████║██║   ██║   ██║   ███████║
██╔══██╗██╔══╝     ██║      ██║   ██╔══╝  ██╔══██╗    ██╔══██║██║   ██║   ██║   ██╔══██║
██████╔╝███████╗   ██║      ██║   ███████╗██║  ██║    ██║  ██║╚██████╔╝   ██║   ██║  ██║
╚═════╝ ╚══════╝   ╚═╝      ╚═╝   ╚══════╝╚═╝  ╚═╝    ╚═╝  ╚═╝ ╚═════╝    ╚═╝   ╚═╝  ╚═╝
 
console.log(chalk.cyan("  PACKAGE\n"));
console.log(chalk.gray("  Modern Authentication Starter for Next.js\n"));

chalk.cyan() wraps the string in ANSI color codes so color-supporting terminals render it in bright cyan. This runs immediately when the command is invoked, before any logic.


rabbit hole: part 4 — building the commander program

Day 2: Setting up Commander

Commander is the backbone of the CLI. You initialize it and chain methods to describe what the CLI does:

const program = new Command();
 
program
  .version("1.0.0")
  .argument("<project-name>", "name of the project")
  .action(async (projectName) => {
    // All the logic lives here
  });

Breaking this down:

  • .version("1.0.0") — Adds a --version flag automatically. Users can run better-auth --version to check it.
  • .argument("<project-name>", "...") — Defines a required positional argument. Angle brackets < > mean required; square brackets [ ] would mean optional.
  • .action(async (projectName) => { }) — The async callback that runs when the command executes. projectName is populated from what the user types.

rabbit hole: part 5 — checking for existing directories

Day 3: Defensive coding

One of the first things the action does is check whether a folder with that name already exists:

const projectPath = path.join(process.cwd(), projectName);
 
if (fs.existsSync(projectPath)) {
  console.error(
    chalk.red(`Error: Directory ${projectName} already exists.`)
  );
  process.exit(1);
}

process.cwd() returns the current working directory — wherever the user is in their terminal. path.join() constructs the full path to where the new project folder would live.

If it already exists, we log a red error and call process.exit(1). Exit code 1 signals to the shell that the process ended in error (as opposed to 0 which means success).


rabbit hole: part 6 — asking the user a question with Inquirer

Day 3: Interactive prompts

Instead of a dropdown list, I chose a confirm type prompt — a simple yes/no about TypeScript:

const answers = await inquirer.prompt([
  {
    type: "confirm",
    name: "useTypeScript",
    message: "Use TypeScript? (recommended)",
    default: true,
  },
]);

The await here is critical — Inquirer is asynchronous because it waits for keyboard input. The result is stored in answers.useTypeScript as a boolean. The original Desishub tutorial used a list prompt with two choices; my version simplifies it to yes/no since I maintain a single template.


rabbit hole: part 7 — cloning the template with simple-git

Day 4: Git operations in Node.js

With the user's choice captured, we create the directory, start the spinner, and clone:

const repoUrl =
  "https://github.com/Watuulo-Richard/Better-Auth-Modern-Authentication-Lesson-Next.js-TypeScript-.git";
 
fs.mkdirSync(projectPath);
 
const spinner = ora({
  text: "Downloading TypeScript template by Watuulo-Richard...",
  spinner: {
    interval: 80,
    frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
  },
  color: "yellow",
}).start();
 
const git = simpleGit();
await git.clone(repoUrl, projectPath);
spinner.succeed("TypeScript template downloaded successfully!");

Key points:

  1. fs.mkdirSync(projectPath) — Creates the empty folder first. simple-git needs it to already exist before cloning into it.
  2. The custom frames array defines the spinner animation — those Unicode Braille characters create the spinning dot effect.
  3. simpleGit() returns a Git instance. Calling .clone(url, destination) runs git clone under the hood.
  4. spinner.succeed() replaces the animation with a green checkmark and success message.

rabbit hole: part 8 — installing dependencies programmatically

Day 4: Running shell commands from Node

After cloning, we navigate into the new project and install dependencies:

process.chdir(projectPath);
 
spinner.start("Installing dependencies...");
execSync("npm install", { stdio: "inherit" });
spinner.succeed("Dependencies installed successfully!");

process.chdir() changes the working directory of the Node.js process — like running cd in the terminal.

execSync("npm install", { stdio: "inherit" }) runs npm install synchronously. The { stdio: "inherit" } option passes stdout and stderr directly to the terminal, so the user sees npm's real-time output instead of a blank screen.


rabbit hole: part 9 — error handling

Day 5: Making it resilient

All async operations are wrapped in a try/catch:

try {
  await git.clone(repoUrl, projectPath);
  // ... rest of happy path
} catch (error) {
  spinner.fail("Failed to set up the project.");
  console.error(chalk.red(error.message));
  process.exit(1);
}

spinner.fail() replaces the animation with a red ✖ and the failure message. Then we log the actual error in red and exit with code 1. This ensures that if the clone fails (no internet, bad URL, etc.), the user gets a clear colored error rather than a cryptic stack trace.


moment of truth

Day 5: Running it locally

Before publishing, test the CLI locally by linking it:

npm link

This makes the better-auth command available globally on your machine by symlinking it. Then run:

better-auth my-test-app

The ASCII banner appears, the confirm prompt shows up, the spinner animates, the repo clones, dependencies install, and a success message prints. Everything worked! 🎉


publishing to npm

Day 6: Sharing with the world

With everything working locally, make sure your package.json is properly configured:

{
  "name": "better-auth-starter",
  "version": "1.0.0",
  "description": "A CLI tool to scaffold Next.js + Better Auth projects",
  "type": "module",
  "main": "index.js",
  "bin": {
    "better-auth": "./index.js"
  },
  "keywords": ["cli", "nextjs", "better-auth", "scaffold", "template"],
  "author": "Watuulo-Richard",
  "license": "MIT"
}

The most important field is "bin" — it maps the CLI command name (better-auth) to the entry file (./index.js). This is how npm knows what to expose when someone installs or runs your package via npx.

Then publish:

npm login       # Log in to your npm account
npm publish     # Publish the package

If your package name is taken, scope it: @yourusername/better-auth-starter.

technical details

how it works

The CLI has a simple, linear flow:

  1. Banner — ASCII art and tagline print immediately on startup.
  2. Validation — Check if the target directory already exists.
  3. Prompt — Ask the user whether they want TypeScript (default: yes).
  4. Clone — Pull the template repo from GitHub using simple-git.
  5. Install — Run npm install inside the new project directory.
  6. Done — Print success instructions.

Complete Flow Diagram

User runs: npx better-auth my-app
  │
  ▼
ASCII banner + tagline printed
  │
  ▼
Check if ./my-app already exists
  ├── exists ──────────► print red error, exit(1)
  └── doesn't exist ──► continue
  │
  ▼
Inquirer prompt: "Use TypeScript?"
  │
  ▼
fs.mkdirSync(./my-app)
  │
  ▼
ora spinner starts ("Downloading template...")
  │
  ▼
git.clone(repoUrl, ./my-app)
  ├── fails ───────────► spinner.fail(), print error, exit(1)
  └── succeeds ────────► spinner.succeed()
  │
  ▼
process.chdir(./my-app)
  │
  ▼
ora spinner starts ("Installing dependencies...")
  │
  ▼
execSync("npm install")
  │
  ▼
spinner.succeed()
  │
  ▼
chalk.green success messages printed
  │
  ▼
Done! cd my-app && npm run dev

Final index.js

#!/usr/bin/env node
 
import { Command } from "commander";
import chalk from "chalk";
import fs from "fs-extra";
import path from "path";
import { simpleGit } from "simple-git";
import ora from "ora";
import inquirer from "inquirer";
import { execSync } from "child_process";
 
console.log(chalk.cyan(`
██████╗ ███████╗████████╗████████╗███████╗██████╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗
██████╔╝█████╗     ██║      ██║   █████╗  ██████╔╝
██╔══██╗██╔══╝     ██║      ██║   ██╔══╝  ██╔══██╗
██████╔╝███████╗   ██║      ██║   ███████╗██║  ██║
╚═════╝ ╚══════╝   ╚═╝      ╚═╝   ╚══════╝╚═╝  ╚═╝
`));
 
console.log(chalk.cyan("  "), chalk.bold.white("BETTER AUTH"), chalk.gray("— Modern Authentication Starter for Next.js\n"));
 
console.log(chalk.cyan("  PACKAGE\n"));
console.log(chalk.gray("  Modern Authentication Starter for Next.js\n"));
 
const program = new Command();
 
program
  .version("1.0.0")
  .argument("<project-name>", "name of the project")
  .action(async (projectName) => {
    const projectPath = path.join(process.cwd(), projectName);
 
    if (fs.existsSync(projectPath)) {
      console.error(
        chalk.red(`Error: Directory ${projectName} already exists.`)
      );
      process.exit(1);
    }
 
    const answers = await inquirer.prompt([
      {
        type: "confirm",
        name: "useTypeScript",
        message: "Use TypeScript? (recommended)",
        default: true,
      },
    ]);
 
    const repoUrl =
      "https://github.com/Watuulo-Richard/Better-Auth-Modern-Authentication-Lesson-Next.js-TypeScript-.git";
 
    fs.mkdirSync(projectPath);
 
    const spinner = ora({
      text: "Downloading TypeScript template by Watuulo-Richard...",
      spinner: {
        interval: 80,
        frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
      },
      color: "yellow",
    }).start();
 
    const git = simpleGit();
 
    try {
      await git.clone(repoUrl, projectPath);
      spinner.succeed("TypeScript template downloaded successfully!");
 
      process.chdir(projectPath);
 
      spinner.start("Installing dependencies...");
      execSync("npm install", { stdio: "inherit" });
      spinner.succeed("Dependencies installed successfully!");
 
      console.log(chalk.green(`Project ${projectName} is ready!`));
      console.log(chalk.green(`cd ${projectName} and run npm run dev`));
    } catch (error) {
      spinner.fail("Failed to set up the project.");
      console.error(chalk.red(error.message));
      process.exit(1);
    }
  });
 
program.parse(process.argv);

setup

Prerequisites

Before getting started, make sure you have:

  • Node.js (version 12 or higher) — nodejs.org
  • npm (comes bundled with Node.js)
  • A code editor (VS Code recommended)
  • A GitHub account (for hosting your template repo)
  • An npm account — create one at npmjs.com

Step-by-Step

1. Create and initialize the project:

mkdir better-auth
cd better-auth
npm init -y

2. Install dependencies:

npm install commander chalk fs-extra inquirer simple-git ora

3. Create index.js with the shebang at the top and the full implementation above.

4. Update package.json:

{
  "name": "better-auth-starter",
  "version": "1.0.0",
  "description": "A CLI tool to scaffold Next.js + Better Auth projects",
  "type": "module",
  "main": "index.js",
  "bin": {
    "better-auth": "./index.js"
  },
  "keywords": ["cli", "nextjs", "better-auth"],
  "author": "Watuulo-Richard",
  "license": "MIT"
}

5. Test locally:

npm link
better-auth my-test-app

6. Publish:

npm login
npm publish

Using the Published Package

Once published, anyone can scaffold your template in two ways:

Global installation (install once, use always):

npm install -g better-auth-starter
better-auth my-app

Via npx (no installation required, always latest version):

npx better-auth-starter my-app

npx is the better recommendation for scaffolding CLIs — it always fetches the latest version and doesn't pollute the user's global npm space.

conclusion

Building a CLI tool and publishing it as an npm package is one of the most rewarding developer experiences. With just a handful of well-chosen packages — Commander, Chalk, Inquirer, simple-git, and Ora — you can create a polished, production-quality tool that other developers will actually enjoy using.

Key takeaways from this build:

  • ✅ The shebang line (#!/usr/bin/env node) is non-negotiable — without it your CLI won't run
  • ✅ Commander makes argument parsing and command definition clean and declarative
  • ✅ Chalk + Ora together create a professional terminal UX with colors and spinners
  • ✅ The bin field in package.json is what makes npx your-package work
  • ✅ Always test with npm link before publishing to npm
  • ✅ process.exit(1) signals an error to the shell — always use it on failure

What I would do differently:

  • Add support for multiple templates from the start (JS + TS)
  • Write a more comprehensive README.md before publishing
  • Review --version flag and --help output before releasing
  • Consider using execa instead of execSync for non-blocking installs

I hope this breakdown helps you build your own CLI tool. The full source is on GitHub — star it if it helped you!

If you have any questions, feel free to reach out on Twitter/X.

Until next time, happy building! 🚀

additional resources

  • Commander.js Documentation
  • Chalk Documentation
  • Inquirer.js Documentation
  • simple-git Documentation
  • Ora Documentation
  • npm Publishing Guide
  • Desishub CLI Tutorial

See all blogs