Ein CLI-Tool mit Node.js bauen: Grundlagen bis Veröffentlichung
Vollständiger Leitfaden zum Erstellen, Testen und Veröffentlichen eines Node.js-CLI-Tools: Argument-Parsing, Prompts, Ausgabeformat und npm-Publishing.

Die besten Entwickler-Tools sind CLI-Tools. Sie lassen sich mit Pipes kombinieren, mit Skripten automatisieren und laufen in CI ohne Browser. Ein eigenes CLI in Node.js zu bauen ist einfach, sobald man die Muster kennt — Argument-Parsing, interaktive Prompts, farbige Ausgabe und Fehlerbehandlung haben allesamt ausgereifte Bibliotheken.
Dieser Leitfaden baut ein echtes CLI von Grund auf: ein Projekt-Scaffolding-Tool, das Verzeichnisse anlegt, Konfigurationsdateien schreibt und Abhängigkeiten installiert.
Projekt-Setup
Ein CLI-Tool ist nur ein Node.js-Skript mit einem speziellen Header (Shebang) und einem bin-Feld in der package.json.
{
"name": "create-project",
"version": "1.0.0",
"bin": {
"create-project": "./dist/index.js"
},
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts"
},
"dependencies": {
"commander": "^11.0.0",
"chalk": "^5.3.0",
"inquirer": "^9.2.0",
"ora": "^7.0.0"
},
"devDependencies": {
"typescript": "^5.3.0",
"tsx": "^4.0.0",
"@types/inquirer": "^9.0.0"
}
}#!/usr/bin/env node
// src/index.ts — the entry point
// The shebang line tells the OS to run this with Node.js
import { program } from 'commander';
import chalk from 'chalk';
program
.name('create-project')
.description('Scaffold a new project with sensible defaults')
.version('1.0.0');
program
.argument('<name>', 'project name')
.option('-t, --template <type>', 'project template', 'node')
.option('--no-git', 'skip git initialization')
.option('--no-install', 'skip dependency installation')
.action(async (name, options) => {
console.log(chalk.blue(`Creating project: ${name}`));
await createProject(name, options);
});
program.parse();npm link während der Entwicklung macht das CLI global verfügbar. npm publish macht es für alle verfügbar.
Argument-Parsing mit Commander
Commander übernimmt Argumente, Optionen, Flags und Subkommandos. Es generiert den Hilfetext automatisch aus deinen Definitionen.
// src/commands/init.ts
import { Command } from 'commander';
export const initCommand = new Command('init')
.description('Initialize a new project in the current directory')
.argument('<name>', 'project name (used for package.json and directory)')
.option('-t, --template <type>', 'project template', 'node')
.option('-p, --package-manager <pm>', 'package manager', 'npm')
.option('--typescript', 'use TypeScript', true)
.option('--no-typescript', 'use plain JavaScript')
.option('--dry-run', 'show what would be created without writing files')
.addHelpText('after', `
Templates:
node Basic Node.js project with Express
react React app with Vite
api REST API with Fastify
cli CLI tool scaffold
Examples:
$ create-project init my-app
$ create-project init my-api --template api --package-manager pnpm
$ create-project init my-cli --template cli --dry-run
`)
.action(async (name, options) => {
await handleInit(name, options);
});# Auto-generated help output
$ create-project init --help
Usage: create-project init [options] <name>
Initialize a new project in the current directory
Arguments:
name project name (used for package.json and directory)
Options:
-t, --template <type> project template (default: "node")
-p, --package-manager <pm> package manager (default: "npm")
--typescript use TypeScript (default: true)
--no-typescript use plain JavaScript
--dry-run show what would be created without writing files
-h, --help display help for commandInteraktive Prompts
Wenn Argumente fehlen, greife auf interaktive Prompts zurück. So funktioniert das CLI sowohl für Skripte (alle Flags gesetzt) als auch für Menschen (geführte Prompts).
// src/prompts.ts
import inquirer from 'inquirer';
interface ProjectOptions {
name: string;
template: string;
packageManager: string;
typescript: boolean;
git: boolean;
}
export async function promptForMissing(
partial: Partial<ProjectOptions>
): Promise<ProjectOptions> {
const questions = [];
if (!partial.name) {
questions.push({
type: 'input',
name: 'name',
message: 'Project name:',
validate: (input: string) => {
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Name must be lowercase alphanumeric with hyphens';
}
return true;
},
});
}
if (!partial.template) {
questions.push({
type: 'list',
name: 'template',
message: 'Select a template:',
choices: [
{ name: 'Node.js + Express', value: 'node' },
{ name: 'React + Vite', value: 'react' },
{ name: 'REST API + Fastify', value: 'api' },
{ name: 'CLI Tool', value: 'cli' },
],
});
}
if (!partial.packageManager) {
questions.push({
type: 'list',
name: 'packageManager',
message: 'Package manager:',
choices: ['npm', 'pnpm', 'yarn', 'bun'],
});
}
const answers = await inquirer.prompt(questions);
return { ...partial, ...answers } as ProjectOptions;
}# Without arguments — interactive mode
$ create-project init
? Project name: my-app
? Select a template: Node.js + Express
? Package manager: pnpm
# With arguments — no prompts, script-friendly
$ create-project init my-app -t node -p pnpmFarbige Ausgabe und Spinner
Die Terminal-Ausgabe sollte den Fortschritt klar kommunizieren. Nutze Farben für den Status, Spinner für langlaufende Operationen und strukturierte Ausgabe für Ergebnisse.
// src/ui.ts
import chalk from 'chalk';
import ora from 'ora';
export const log = {
info: (msg: string) => console.log(chalk.blue('ℹ'), msg),
success: (msg: string) => console.log(chalk.green('✓'), msg),
warn: (msg: string) => console.log(chalk.yellow('⚠'), msg),
error: (msg: string) => console.error(chalk.red('✗'), msg),
step: (num: number, total: number, msg: string) =>
console.log(chalk.dim(`[${num}/${total}]`), msg),
};
export async function withSpinner<T>(
message: string,
fn: () => Promise<T>
): Promise<T> {
const spinner = ora(message).start();
try {
const result = await fn();
spinner.succeed();
return result;
} catch (error) {
spinner.fail();
throw error;
}
}// src/create.ts — using the UI helpers
import { log, withSpinner } from './ui.js';
import { mkdir, writeFile } from 'fs/promises';
import { execSync } from 'child_process';
async function createProject(name: string, options: ProjectOptions) {
log.info(`Creating ${chalk.bold(name)} with template ${options.template}`);
console.log();
// Step 1: Create directory structure
log.step(1, 4, 'Creating project structure...');
await mkdir(`${name}/src`, { recursive: true });
await mkdir(`${name}/tests`, { recursive: true });
log.success('Project structure created');
// Step 2: Write config files
log.step(2, 4, 'Writing configuration files...');
await writeFile(`${name}/package.json`, generatePackageJson(name, options));
await writeFile(`${name}/tsconfig.json`, generateTsConfig());
log.success('Configuration files written');
// Step 3: Initialize git
if (options.git) {
log.step(3, 4, 'Initializing git repository...');
execSync('git init', { cwd: name, stdio: 'ignore' });
await writeFile(`${name}/.gitignore`, 'node_modules\ndist\n.env\n');
log.success('Git repository initialized');
} else {
log.step(3, 4, chalk.dim('Skipping git initialization'));
}
// Step 4: Install dependencies
if (options.install) {
await withSpinner('Installing dependencies...', async () => {
const cmd = options.packageManager === 'npm'
? 'npm install'
: `${options.packageManager} install`;
execSync(cmd, { cwd: name, stdio: 'ignore' });
});
}
console.log();
log.success(chalk.bold('Project created successfully!'));
console.log();
console.log(` ${chalk.cyan('cd')} ${name}`);
console.log(` ${chalk.cyan(options.packageManager)} run dev`);
console.log();
}Fehlerbehandlung für CLIs
CLI-Fehler brauchen klare Meldungen, keine Stack Traces. Fange Fehler auf der obersten Ebene ab und formatiere sie für Menschen.
// ❌ Unhandled errors dump stack traces to the terminal
program.action(async (name) => {
await createProject(name); // throws Error: EEXIST
});
// Error: EEXIST: file already exists, mkdir 'my-app'
// at Object.mkdirSync (node:fs:...)
// at createProject (/src/create.ts:15:3)
// ...20 more lines nobody reads
// ✅ Graceful error handling with actionable messages
program.action(async (name) => {
try {
await createProject(name);
} catch (error) {
if (isNodeError(error) && error.code === 'EEXIST') {
log.error(`Directory "${name}" already exists.`);
log.info(`Use a different name or remove the existing directory.`);
process.exit(1);
}
if (isNodeError(error) && error.code === 'EACCES') {
log.error(`Permission denied. Cannot write to this directory.`);
process.exit(1);
}
// Unknown error — show message without stack trace
log.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
});
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}// Global unhandled rejection handler — safety net
process.on('unhandledRejection', (reason) => {
log.error('An unexpected error occurred');
if (reason instanceof Error) {
log.error(reason.message);
}
process.exit(1);
});CLI-Kommandos testen
CLI-Tools testet man, indem man sie als Kindprozesse aufruft und Assertions auf stdout, stderr und Exit-Codes macht.
// tests/cli.test.ts
import { execSync } from 'child_process';
import { existsSync, rmSync } from 'fs';
const CLI = 'tsx src/index.ts';
describe('create-project CLI', () => {
afterEach(() => {
// Cleanup created directories
if (existsSync('test-project')) {
rmSync('test-project', { recursive: true });
}
});
it('creates a project with default options', () => {
const output = execSync(
`${CLI} init test-project --no-install`,
{ encoding: 'utf-8' }
);
expect(output).toContain('Project created successfully');
expect(existsSync('test-project/package.json')).toBe(true);
expect(existsSync('test-project/src')).toBe(true);
expect(existsSync('test-project/.gitignore')).toBe(true);
});
it('shows error for existing directory', () => {
execSync(`mkdir test-project`);
try {
execSync(`${CLI} init test-project`, { encoding: 'utf-8' });
fail('Should have thrown');
} catch (error: any) {
expect(error.stderr || error.stdout).toContain('already exists');
expect(error.status).toBe(1);
}
});
it('supports dry run mode', () => {
const output = execSync(
`${CLI} init test-project --dry-run`,
{ encoding: 'utf-8' }
);
expect(output).toContain('Dry run');
expect(existsSync('test-project')).toBe(false);
});
});Teste die Kommandozeilenschnittstelle von außen. Tests interner Funktionen sind Unit-Tests. CLI-Tests prüfen, dass Argument-Parsing, Dateierstellung und Ausgabeformatierung zusammen funktionieren.
Auf npm veröffentlichen
Sobald das Tool funktioniert, veröffentliche es, damit es jeder mit npx create-project oder npm install -g create-project nutzen kann.
# Build the TypeScript source
npm run build
# Ensure the shebang is in the compiled output
head -1 dist/index.js
# #!/usr/bin/env node
# Test the built version
node dist/index.js init test-project --dry-run
# Login and publish
npm login
npm publish
# Users can now run:
npx create-project init my-app
# or
npm install -g create-project
create-project init my-app// package.json fields that matter for publishing
{
"name": "create-project",
"version": "1.0.0",
"bin": { "create-project": "./dist/index.js" },
"files": ["dist"],
"engines": { "node": ">=18" },
"keywords": ["cli", "scaffold", "generator"],
"repository": { "type": "git", "url": "https://github.com/user/create-project" }
}Das files-Array stellt sicher, dass nur das kompilierte dist-Verzeichnis veröffentlicht wird — keine Tests, Quelldateien oder Entwicklungskonfigurationen.
Die wichtigsten Erkenntnisse
- Commander übernimmt das Argument-Parsing — definiere Argumente, Optionen und Subkommandos deklarativ mit automatisch generierter Hilfe
- Greife auf interaktive Prompts zurück — nutze
inquirer, wenn Nutzer nicht alle Argumente angeben, und mache das CLI damit skript- und menschenfreundlich - Nutze Spinner für langlaufende Operationen —
oraliefert visuelles Feedback während Installationen und Netzwerk-Anfragen - Fange Fehler sauber ab — übersetze Systemfehler (EEXIST, EACCES) in menschenlesbare Meldungen
- Teste über Kindprozess-Ausführung — rufe das CLI-Binary auf und prüfe stdout, Exit-Codes und Dateisystem-Effekte
- Veröffentliche mit minimalen Dateien — nimm nur
distundpackage.jsonins npm-Paket auf


