February 22, 2026


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.
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.
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 -yRunning 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 oraHere's what each one does and why I needed it:
| Package | Purpose |
|---|---|
commander | Parses command-line arguments and defines CLI commands |
chalk | Adds colors to terminal output — red for errors, green for success |
fs-extra | Enhanced file system operations like mkdirSync and existsSync |
inquirer | Creates interactive terminal prompts for user input |
simple-git | Runs Git operations from Node.js — used to clone the template repo |
ora | Shows 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 nodeThis 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.jsOn 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:
fs.mkdirSync(projectPath) — Creates the empty folder first. simple-git needs it to already exist before cloning into it.frames array defines the spinner animation — those Unicode Braille characters create the spinning dot effect.simpleGit() returns a Git instance. Calling .clone(url, destination) runs git clone under the hood.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 linkThis makes the better-auth command available globally on your machine by symlinking it. Then run:
better-auth my-test-appThe 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 packageIf your package name is taken, scope it: @yourusername/better-auth-starter.
The CLI has a simple, linear flow:
simple-git.npm install inside the new project directory.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#!/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);Before getting started, make sure you have:
1. Create and initialize the project:
mkdir better-auth
cd better-auth
npm init -y2. Install dependencies:
npm install commander chalk fs-extra inquirer simple-git ora3. 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-app6. Publish:
npm login
npm publishOnce published, anyone can scaffold your template in two ways:
Global installation (install once, use always):
npm install -g better-auth-starter
better-auth my-appVia npx (no installation required, always latest version):
npx better-auth-starter my-appnpx is the better recommendation for scaffolding CLIs — it always fetches the latest version and doesn't pollute the user's global npm space.
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:
#!/usr/bin/env node) is non-negotiable — without it your CLI won't runbin field in package.json is what makes npx your-package worknpm link before publishing to npmprocess.exit(1) signals an error to the shell — always use it on failureWhat I would do differently:
README.md before publishing--version flag and --help output before releasingexeca instead of execSync for non-blocking installsI 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! 🚀