Ein CLI-Tool mit Node.js von Grund auf bauen
Schritt für Schritt zum professionellen Node.js-CLI-Tool: Argument-Parsing, Prompts, farbige Ausgabe, Fortschritt, Konfiguration und npm-Publishing.

CLI-Tools sind das Rückgrat von Entwickler-Workflows – von git über npm bis hin zu Projekt-Scaffolder. Eines zu bauen lehrt dich Argument-Parsing, Terminal-I/O, Prozess-Signale und Software-Distribution auf eine sofort nützliche Weise. Das Ergebnis ist ein Tool, das du und dein Team tatsächlich nutzt, kein Tutorial-Artefakt, das in einem Repo verstaubt.
Dieser Walkthrough baut von Grund auf ein Projekt-Scaffolding-CLI und deckt den gesamten Weg vom Argument-Parsing bis zur Veröffentlichung auf npm ab.
Projekt-Setup und Einstiegspunkt
Ein CLI-Tool braucht ein bin-Feld in der package.json und eine Shebang-Zeile. Die TypeScript-Kompilierung zielt auf CommonJS für maximale Node.js-Kompatibilität ab.
{
"name": "create-project-scaffold",
"version": "1.0.0",
"bin": {
"scaffold": "./dist/cli.js"
},
"type": "commonjs",
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^4.1.2",
"ora": "^5.4.1",
"inquirer": "^8.2.6"
},
"devDependencies": {
"typescript": "^5.3.0",
"tsx": "^4.0.0",
"@types/inquirer": "^8.2.10",
"@types/node": "^20.0.0"
}
}#!/usr/bin/env node
// src/cli.ts — the entry point
import { Command } from "commander";
import chalk from "chalk";
import { createCommand } from "./commands/create";
import { configCommand } from "./commands/config";
const program = new Command();
program
.name("scaffold")
.description("Project scaffolding CLI")
.version("1.0.0");
// Register subcommands
program.addCommand(createCommand);
program.addCommand(configCommand);
// Global error handling
program.exitOverride();
try {
program.parse();
} catch (err: unknown) {
if (
err instanceof Error &&
"exitCode" in err &&
(err as any).exitCode !== 0
) {
console.error(
chalk.red(`Error: ${err.message}`)
);
process.exit(1);
}
}Das Shebang #!/usr/bin/env node sagt dem Betriebssystem, dass es die Datei mit Node.js ausführen soll. Commander übernimmt Argument-Parsing, Subcommands und automatisch generierten Hilfetext.
Interaktive Prompts und Validierung
Wenn erforderliche Argumente fehlen, greife auf interaktive Prompts zurück. So bekommen Power-User schnelle CLI-Flags, während das Tool für alle zugänglich bleibt.
// ❌ Fail if arguments are missing
function createProject(name: string, template: string) {
if (!name) {
console.error("Error: project name is required");
process.exit(1);
}
// Users must read --help to discover all flags
}// ✅ Fall back to interactive prompts for missing args
import inquirer from "inquirer";
import { Command } from "commander";
import chalk from "chalk";
interface CreateOptions {
name: string;
template: string;
typescript: boolean;
git: boolean;
install: boolean;
}
export const createCommand = new Command("create")
.description("Create a new project from a template")
.argument("[name]", "Project name")
.option("-t, --template <template>", "Template to use")
.option("--typescript", "Use TypeScript", true)
.option("--no-git", "Skip git initialization")
.option("--no-install", "Skip dependency installation")
.action(async (name, opts) => {
const options = await resolveOptions(name, opts);
await executeCreate(options);
});
async function resolveOptions(
name: string | undefined,
opts: Record<string, unknown>
): Promise<CreateOptions> {
const questions: any[] = [];
if (!name) {
questions.push({
type: "input",
name: "name",
message: "Project name:",
validate: (input: string) => {
if (!input.trim()) return "Name is required";
if (!/^[a-z0-9-]+$/.test(input)) {
return "Use lowercase letters, numbers, hyphens only";
}
return true;
},
});
}
if (!opts.template) {
questions.push({
type: "list",
name: "template",
message: "Select a template:",
choices: [
{ name: "React + Vite", value: "react-vite" },
{ name: "Next.js App Router", value: "nextjs" },
{ name: "Express API", value: "express" },
{ name: "CLI Tool", value: "cli" },
],
});
}
const answers =
questions.length > 0
? await inquirer.prompt(questions)
: {};
return {
name: name ?? answers.name,
template: (opts.template as string) ?? answers.template,
typescript: opts.typescript !== false,
git: opts.git !== false,
install: opts.install !== false,
};
}Fortschrittsanzeigen und farbige Ausgabe
Lang laufende Operationen brauchen visuelles Feedback. Ein Spinner während des Dateikopierens und der Abhängigkeitsinstallation hält die Nutzer auf dem Laufenden.
import ora from "ora";
import chalk from "chalk";
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
async function executeCreate(
options: CreateOptions
): Promise<void> {
const projectPath = path.resolve(
process.cwd(),
options.name
);
// Check if directory exists
if (fs.existsSync(projectPath)) {
console.error(
chalk.red(
`Directory "${options.name}" already exists`
)
);
process.exit(1);
}
console.log();
console.log(
chalk.bold(`Creating ${chalk.cyan(options.name)}...`)
);
console.log();
// Step 1: Copy template
const spinner = ora("Copying template files").start();
try {
await copyTemplate(options.template, projectPath);
spinner.succeed("Template files copied");
} catch (err) {
spinner.fail("Failed to copy template");
throw err;
}
// Step 2: Customize project
spinner.start("Customizing project configuration");
await customizePackageJson(projectPath, options);
spinner.succeed("Project configured");
// Step 3: Initialize git
if (options.git) {
spinner.start("Initializing git repository");
execSync("git init", {
cwd: projectPath,
stdio: "ignore",
});
spinner.succeed("Git repository initialized");
}
// Step 4: Install dependencies
if (options.install) {
spinner.start("Installing dependencies");
execSync("npm install", {
cwd: projectPath,
stdio: "ignore",
});
spinner.succeed("Dependencies installed");
}
// Summary
console.log();
console.log(chalk.green("✓ Project created successfully!"));
console.log();
console.log(" Next steps:");
console.log(
chalk.cyan(` cd ${options.name}`)
);
console.log(chalk.cyan(" npm run dev"));
console.log();
}
async function copyTemplate(
template: string,
dest: string
): Promise<void> {
const templateDir = path.join(
__dirname,
"..",
"templates",
template
);
if (!fs.existsSync(templateDir)) {
throw new Error(`Template "${template}" not found`);
}
fs.cpSync(templateDir, dest, { recursive: true });
}
async function customizePackageJson(
projectPath: string,
options: CreateOptions
): Promise<void> {
const pkgPath = path.join(projectPath, "package.json");
const pkg = JSON.parse(
fs.readFileSync(pkgPath, "utf-8")
);
pkg.name = options.name;
pkg.version = "0.1.0";
fs.writeFileSync(
pkgPath,
JSON.stringify(pkg, null, 2) + "\n"
);
}Unterstützung für Konfigurationsdateien
CLI-Tools, die sich Benutzereinstellungen über eine Konfigurationsdatei merken, reduzieren das wiederholte Übergeben von Flags.
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
interface ScaffoldConfig {
defaultTemplate: string;
typescript: boolean;
git: boolean;
author: string;
license: string;
}
const CONFIG_DIR = path.join(os.homedir(), ".scaffold");
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
const DEFAULT_CONFIG: ScaffoldConfig = {
defaultTemplate: "react-vite",
typescript: true,
git: true,
author: "",
license: "MIT",
};
function loadConfig(): ScaffoldConfig {
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
}
} catch {
// Corrupted config — use defaults
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(
updates: Partial<ScaffoldConfig>
): void {
const current = loadConfig();
const merged = { ...current, ...updates };
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
}
fs.writeFileSync(
CONFIG_FILE,
JSON.stringify(merged, null, 2) + "\n"
);
}
// Config subcommand
export const configCommand = new Command("config")
.description("Manage CLI configuration")
.addCommand(
new Command("set")
.argument("<key>", "Configuration key")
.argument("<value>", "Configuration value")
.action((key: string, value: string) => {
const config = loadConfig();
if (!(key in config)) {
console.error(
chalk.red(`Unknown config key: ${key}`)
);
process.exit(1);
}
let parsed: unknown = value;
if (value === "true") parsed = true;
if (value === "false") parsed = false;
saveConfig({ [key]: parsed });
console.log(
chalk.green(`Set ${key} = ${value}`)
);
})
)
.addCommand(
new Command("list").action(() => {
const config = loadConfig();
console.log(chalk.bold("\nCurrent configuration:\n"));
for (const [key, value] of Object.entries(config)) {
console.log(` ${chalk.cyan(key)}: ${value}`);
}
console.log();
})
);Sauberes Signal-Handling und Aufräumen
CLI-Tools müssen SIGINT (Ctrl+C) sauber behandeln und dabei temporäre Dateien und teilweise erstellte Verzeichnisse aufräumen.
// Register cleanup handlers
let cleanupPath: string | null = null;
function registerCleanup(projectPath: string): void {
cleanupPath = projectPath;
}
function cleanup(): void {
if (cleanupPath && fs.existsSync(cleanupPath)) {
console.log(
chalk.yellow("\nCleaning up partial project...")
);
fs.rmSync(cleanupPath, {
recursive: true,
force: true,
});
console.log(chalk.yellow("Cleanup complete."));
}
}
process.on("SIGINT", () => {
cleanup();
process.exit(130);
});
process.on("SIGTERM", () => {
cleanup();
process.exit(143);
});
// In executeCreate, register before starting work:
async function executeCreate(
options: CreateOptions
): Promise<void> {
const projectPath = path.resolve(
process.cwd(),
options.name
);
registerCleanup(projectPath);
// ... creation steps ...
// Clear cleanup after successful completion
cleanupPath = null;
}Die wichtigsten Erkenntnisse
Ein professionelles CLI-Tool kombiniert Commander für das Argument-Parsing mit Inquirer für interaktive Prompts und greift auf Prompts zurück, wenn CLI-Flags fehlen – so bekommen Power-User Geschwindigkeit und Neulinge eine Anleitung. Ora-Spinner und farbige Ausgabe mit Chalk liefern essenzielles visuelles Feedback: Nutzer müssen wissen, dass ein 30-sekündiges npm install noch läuft und nicht hängt. Konfigurationsdateien in ~/.toolname/config.json reduzieren Flag-Wiederholungen, indem sie Benutzer-Defaults speichern, mit einem config set-Subcommand zur einfachen Verwaltung. Signal-Handling über process.on('SIGINT') sorgt für sauberes Aufräumen temporärer Dateien und partieller Ausgaben, wenn Nutzer mitten in einer Operation Ctrl+C drücken. Eingabevalidierung in interaktiven Prompts fängt Fehler früh mit hilfreichen Meldungen ab und verhindert nachgelagerte Fehler durch fehlerhafte Projektnamen oder fehlende Templates. Das bin-Feld in der package.json und das Shebang #!/usr/bin/env node sind die beiden Teile, die npm install -g zu einem systemweiten Befehl machen.


