Ein einfaches Kommandozeilen-Tool in Node.js schreiben
Die meisten internen CLI-Tools brauchen kein Framework. Ein Skript, das eine Reihe von Dateien umbenennt, eine interne API aufruft oder eine sich wiederholende git-Abfolge kapselt, kann eine einzige Node-Datei mit Shebang-Zeile und einem package.json-Eintrag sein – kein Commander, kein Yargs, kein Build-Schritt. Greife zu einem Framework, sobald du echte Subkommandos hast und eine generierte --help-Ausgabe brauchst; bis dahin ist es Overhead für ein Problem, das du noch nicht hast.
Das minimal lauffähige CLI
Jedes Node-CLI beginnt gleich: ein Shebang, damit die Shell weiß, wie es auszuführen ist, und process.argv für die rohen Argumente.
#!/usr/bin/env node
const args = process.argv.slice(2);
console.log('You passed:', args);
chmod +x rename-files.js
./rename-files.js --dry-run photos/
# You passed: [ '--dry-run', 'photos/' ]
process.argv beginnt immer mit dem Pfad zur Node-Binärdatei und dem Pfad zum Skript selbst – .slice(2) lässt beides weg und liefert, was der Nutzer tatsächlich getippt hat.
Flags ohne Abhängigkeit parsen
Für alles jenseits von ein oder zwei Flags wird ein selbstgebauter Parser schnell mühsam, aber man braucht keine vollwertige Argument-Parsing-Bibliothek, um es sauber zu machen. Node's eingebautes util.parseArgs (stabil seit Node 20) deckt die gängigen Fälle ohne zusätzliche Abhängigkeit ab:
#!/usr/bin/env node
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
options: {
'dry-run': { type: 'boolean', default: false },
output: { type: 'string', short: 'o', default: './out' },
verbose: { type: 'boolean', short: 'v', default: false },
},
allowPositionals: true,
});
console.log(values); // { 'dry-run': true, output: './out', verbose: false }
console.log(positionals); // ['photos/']
./rename-files.js --dry-run -o ./renamed photos/
Damit bekommt man benannte Flags, kurze Aliase, Standardwerte und Typumwandlung (--dry-run wird ein echter Boolean, nicht der String "true") ganz ohne externe Abhängigkeiten – gut zu wissen, bevor man aus Gewohnheit zu einer Bibliothek greift.
Dateien lesen und die eigentliche Arbeit erledigen
Ein Umbenennungs-Tool muss ein Verzeichnis lesen und auf jeden Eintrag reagieren. Node's fs/promises-API hält das mit async/await statt verschachtelter Callbacks lesbar:
import { readdir, rename } from 'node:fs/promises';
import { join, extname } from 'node:path';
async function renamePhotos(dir, { dryRun }) {
const entries = await readdir(dir);
const photos = entries.filter((name) => ['.jpg', '.png'].includes(extname(name).toLowerCase()));
for (const [index, name] of photos.entries()) {
const ext = extname(name);
const newName = `photo-${String(index + 1).padStart(3, '0')}${ext}`;
const from = join(dir, name);
const to = join(dir, newName);
if (dryRun) {
console.log(`${name} -> ${newName} (dry run)`);
} else {
await rename(from, to);
console.log(`${name} -> ${newName}`);
}
}
}
Beachte den Dry-Run-Zweig: Für alles, was das Dateisystem verändert, ist ein Vorschau-Modus, der genau zeigt, was passieren würde – ohne es zu tun –, die zehn zusätzlichen Zeilen wert. Er ist der Unterschied zwischen einem Tool, dem man traut, es gegen ein echtes Verzeichnis laufen zu lassen, und einem, das man sich nur zuerst gegen ein Backup traut.
Nützliche Exit-Codes und Fehler liefern
Ein Skript, das immer mit 0 endet, ist für Shell-Skripting und CI unsichtbar – &&-Ketten und if-Prüfungen hängen vom Exit-Code ab, nicht vom Parsen deiner Log-Ausgabe.
async function main() {
const dir = positionals[0];
if (!dir) {
console.error('Error: missing target directory');
console.error('Usage: rename-files.js [--dry-run] [-o <dir>] <directory>');
process.exit(1);
}
try {
await renamePhotos(dir, { dryRun: values['dry-run'] });
} catch (err) {
console.error('Failed:', err.message);
process.exit(1);
}
}
main();
process.exit(1) sowohl beim Aufruffehler als auch bei der abgefangenen Exception bedeutet, dass rename-files.js photos/ && echo done nur dann „done" ausgibt, wenn das Umbenennen tatsächlich geklappt hat – genau das, was man in einer Pipeline oder einem Pre-Commit-Hook will.
Als echten Befehl installierbar machen
Ein Skript, das in einem Repo liegt und mit node scripts/rename-files.js ausgeführt wird, ist für einen Einmaleinsatz in Ordnung. Damit es sich wie ein echter Befehl anfühlt – von überall als rename-photos aufrufbar –, füge der package.json ein bin-Feld hinzu:
{
"name": "rename-photos",
"version": "1.0.0",
"type": "module",
"bin": {
"rename-photos": "./rename-files.js"
}
}
npm link
rename-photos --dry-run photos/
npm link legt einen Symlink in dein globales node_modules/.bin, sodass der Befehl während der Entwicklung systemweit verfügbar wird. Bei auf npm veröffentlichtem Paket ist dasselbe bin-Feld das, was npx rename-photos oder ein globales npm install -g für alle anderen funktionieren lässt.
Testen, ohne das Dateisystem zu mocken
Weil renamePhotos einen Verzeichnispfad als Parameter nimmt, statt einen fest zu verdrahten, kann man es in Tests auf ein echtes temporäres Verzeichnis richten, statt fs zu mocken:
import { mkdtemp, writeFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, expect } from 'vitest';
test('renames jpg and png files sequentially', async () => {
const dir = await mkdtemp(join(tmpdir(), 'rename-test-'));
await writeFile(join(dir, 'holiday.jpg'), '');
await writeFile(join(dir, 'notes.txt'), '');
await renamePhotos(dir, { dryRun: false });
const files = await readdir(dir);
expect(files).toContain('photo-001.jpg');
expect(files).toContain('notes.txt'); // untouched — not a photo extension
await rm(dir, { recursive: true });
});
Ein echtes (temporäres) Verzeichnis fängt Fehler beim Zusammensetzen von Pfaden und bei der Behandlung von Dateiendungen ab, die ein gemocktes fs-Modul gern übertünchen würde.
Wann man tatsächlich zu einem Framework greifen sollte
Sobald ein Tool echte Subkommandos bekommt (mytool deploy, mytool rollback, mytool status), jeweils mit eigenen Flags und Hilfetexten, lohnt sich das Selbstbauen dieser Dispatch-Logik nicht mehr – das ist der Punkt, Commander oder eine ähnliche Bibliothek für das Routing der Subkommandos und die generierte --help hereinzuholen. Für ein Skript mit einem einzigen Zweck bringen parseArgs plus ein bin-Eintrag dagegen einen echten, teilbaren Befehl, ohne eine Abhängigkeit hinzuzufügen, die man sonst aktuell halten müsste.
Fazit
Ein nützliches CLI braucht kein Framework – es braucht eine Shebang-Zeile, Argument-Parsing (in modernem Node über parseArgs eingebaut), aussagekräftige Exit-Codes und einen Dry-Run-Modus, wenn es das Dateisystem berührt. Füge ein bin-Feld hinzu, wenn es Zeit wird, aufzuhören, node scripts/thing.js zu tippen, und stattdessen den Befehlsnamen zu tippen.