Zum Inhalt springen

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.

6 Min. Lesezeit
Terminal mit einem eigenen CLI-Tool mit farbiger Ausgabe und interaktiven Prompts

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.

jsonjson
{
  "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"
  }
}
tstypescript
#!/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.

tstypescript
// 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);
  });
shbash
# 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 command

Interaktive 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).

tstypescript
// 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;
}
shbash
# 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 pnpm

Farbige 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.

tstypescript
// 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;
  }
}
tstypescript
// 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.

tstypescript
// ❌ 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;
}
tstypescript
// 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.

tstypescript
// 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.

shbash
# 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
jsonjson
// 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

  1. Commander übernimmt das Argument-Parsing — definiere Argumente, Optionen und Subkommandos deklarativ mit automatisch generierter Hilfe
  2. Greife auf interaktive Prompts zurück — nutze inquirer, wenn Nutzer nicht alle Argumente angeben, und mache das CLI damit skript- und menschenfreundlich
  3. Nutze Spinner für langlaufende Operationen — ora liefert visuelles Feedback während Installationen und Netzwerk-Anfragen
  4. Fange Fehler sauber ab — übersetze Systemfehler (EEXIST, EACCES) in menschenlesbare Meldungen
  5. Teste über Kindprozess-Ausführung — rufe das CLI-Binary auf und prüfe stdout, Exit-Codes und Dateisystem-Effekte
  6. Veröffentliche mit minimalen Dateien — nimm nur dist und package.json ins npm-Paket auf
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX