Zum Inhalt springen

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.

5 Min. Lesezeit
Terminal mit einem CLI-Tool mit farbiger Ausgabe, Fortschrittsbalken, interaktiven Prompts und strukturiertem Hilfetext, das professionelles Design von Kommandozeilenschnittstellen demonstriert

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.

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

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

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

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

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

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX