Écrire un outil en ligne de commande simple avec Node.js
La plupart des outils CLI internes n'ont pas besoin d'un framework. Un script qui renomme un lot de fichiers, appelle une API interne ou enveloppe une séquence git répétitive peut tenir en un seul fichier Node avec une ligne shebang et une entrée dans package.json, sans Commander, sans Yargs, sans étape de build. Recourez à un framework dès que vous avez de vraies sous-commandes et besoin d'une sortie --help générée ; d'ici là, c'est du surcoût pour un problème que vous n'avez pas encore.
Le CLI minimum viable
Tout CLI Node commence de la même façon : un shebang pour que le shell sache comment l'exécuter, et process.argv pour les arguments bruts.
#!/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 commence toujours par le chemin du binaire Node puis celui du script lui-même : .slice(2) supprime les deux et vous laisse avec ce que l'utilisateur a réellement tapé.
Analyser les options sans dépendance
Au-delà d'une ou deux options, écrire un analyseur à la main devient vite fastidieux, mais il n'est pas nécessaire d'avoir une bibliothèque complète d'analyse d'arguments pour le faire proprement. util.parseArgs, intégré à Node (stable depuis Node 20), couvre les cas courants sans ajouter de dépendance :
#!/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/
Vous obtenez des options nommées, des alias courts, des valeurs par défaut et une conversion de type (--dry-run devient un vrai booléen, et non la chaîne "true") sans aucune dépendance externe : bon à savoir avant de recourir par habitude à une bibliothèque.
Lire des fichiers et faire le vrai travail
Un outil de renommage doit lire un répertoire et agir sur chaque entrée. L'API fs/promises de Node garde cela lisible avec async/await au lieu de callbacks imbriqués :
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}`);
}
}
}
Remarquez la branche dry-run : pour tout ce qui modifie le système de fichiers, un mode aperçu qui montre exactement ce qui se passerait, sans le faire, vaut bien dix lignes de plus. C'est la différence entre un outil que l'on ose lancer sur un vrai répertoire et un outil que l'on n'ose lancer que sur une sauvegarde d'abord.
Fournir des codes de sortie et des erreurs utiles
Un script qui se termine toujours avec le code 0 est invisible pour les scripts shell et la CI : les chaînes && et les tests if dépendent du code de sortie, pas de l'analyse de vos logs.
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) à la fois pour l'erreur d'usage et pour l'exception interceptée signifie que rename-files.js photos/ && echo done n'affiche « done » que si le renommage a réellement réussi : exactement ce qu'il faut dans un pipeline ou un hook de pré-commit.
En faire une vraie commande installable
Un script qui vit dans un seul dépôt et qui s'exécute avec node scripts/rename-files.js convient pour un usage ponctuel. Pour qu'il se comporte comme une vraie commande, exécutable sous le nom rename-photos depuis n'importe où, ajoutez un champ bin à package.json :
{
"name": "rename-photos",
"version": "1.0.0",
"type": "module",
"bin": {
"rename-photos": "./rename-files.js"
}
}
npm link
rename-photos --dry-run photos/
npm link crée un lien symbolique dans votre node_modules/.bin global, de sorte que la commande devient disponible sur tout le système pendant le développement. Une fois publié sur npm, ce même champ bin est ce qui fait fonctionner npx rename-photos ou un npm install -g global pour n'importe qui d'autre.
Tester sans simuler le système de fichiers
Comme renamePhotos prend un chemin de répertoire en paramètre au lieu d'en coder un en dur, vous pouvez le pointer vers un vrai répertoire temporaire dans les tests au lieu de simuler fs :
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 });
});
Un vrai répertoire (temporaire) détecte les bugs de jonction de chemins et de gestion des extensions qu'un module fs simulé laisserait passer sans broncher.
Quand recourir réellement à un framework
Dès qu'un outil développe de vraies sous-commandes (mytool deploy, mytool rollback, mytool status), chacune avec ses propres options et son texte d'aide, écrire à la main cette logique de répartition ne rapporte plus : c'est le moment d'introduire Commander ou une bibliothèque similaire pour le routage des sous-commandes et le --help généré. Pour un script à usage unique, en revanche, parseArgs plus une entrée bin vous donnent une vraie commande partageable sans ajouter une dépendance qu'il faudrait sinon maintenir à jour.
À retenir
Un CLI utile ne demande pas de framework : il demande une ligne shebang, une analyse des arguments (intégrée au Node moderne via parseArgs), des codes de sortie significatifs et un mode dry-run s'il touche au système de fichiers. Ajoutez un champ bin quand il est temps de cesser de taper node scripts/thing.js pour taper le nom de la commande.