Aller au contenu principal
GiwiSoft
06

TypeScript pour construire un CLI

Giwi 12 min de lecture JavaScript, TypeScript

J’ai écrit pas mal d’outils en ligne de commande au fil des ans, en shell, en Node.js, même en Python. Mais depuis que je me suis sérieusement mis à TypeScript, le CLI est devenu mon terrain de jeu préféré pour ce langage. Pourquoi? Parce qu’un CLI est petit, autonome, et pourtant il brasse toutes les problématiques qui rendent TypeScript intéressant: typage des arguments, gestion des erreurs, parsing, tests. Dans cet article, je raconte comment je construis mes CLIs en TypeScript, de la structure minimale jusqu’au déploiement global.

Pourquoi TypeScript pour un CLI ?

Un CLI, à la base, peut se faire en JavaScript pur ou même en shell. Qu’est-ce que TypeScript apporte ?

Type safety sur un sujet piégeux: les arguments

Le premier écueil de tout CLI, c’est le parsing des arguments de ligne de commande. On reçoit des chaînes de caractères brutes et il faut leur donner du sens: --port 8080 doit devenir un nombre, --verbose un booléen, list app:prod une sous-commande avec une cible. En JavaScript pur, on se retrouve à écrire des vérifications manuelles à chaque fois:

// JavaScript: verification manuelle, source d'erreurs
const args = process.argv.slice(2);
let port = 3000;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--port') {
port = parseInt(args[i + 1], 10); // NaN si saisie invalide!
if (isNaN(port)) throw new Error('port invalide');
}
}

Avec TypeScript et une bibliothèque de parsing, le type du port est déjà correct, et le compilateur me dit si je fais une erreur avant même d’exécuter le CLI:

// parsed.port est deja un number si la bibliotheque est correctement typée
const options: CliOptions = parseArguments(process.argv.slice(2));
// options.port: number, options.verbose: boolean

Partager des types avec un serveur

Un scénario que j’utilise souvent: le même outil sert de CLI de gestion et de client pour une API. Si le CLI parle à un serveur que j’écris aussi, je peux partager les types entre les deux:

// types.ts - partagé entre CLI et serveur
export interface Deployment {
id: string;
service: string;
environment: 'staging' | 'production';
createdAt: string;
}

// cli.ts
import type { Deployment } from './types.js';

Une seule source de vérité pour les types. Quand le serveur fait évoluer sa structure, le CLI échoue à la compilation au lieu de produire des erreurs obscures au runtime.

Meilleure expérience de développement

L’autocomplétion, la navigation entre définitions, le refactoring: le même confort que pour une application web. Pour un outil qui vit longtemps, c’est un avantage net.

La structure de base

Un CLI TypeScript, c’est finalement peu de fichiers:

mon-cli/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # point d'entrée, parsing des arguments
│ ├── commands/
│ │ ├── deploy.ts
│ │ └── list.ts
│ └── utils/
│ └── output.ts # formatage de la sortie
└── tests/
└── cli.test.ts

Le package.json: la partie la plus importante

Le package.json fait trois choses essentielles: déclarer le point d’entrée binaire (bin), définir le shebang, et fournir les scripts de build.

{
"name": "@xavier/mon-cli",
"version": "1.0.0",
"type": "module",
"bin": {
"moncli": "./dist/index.js"
},
"files": ["dist"],
"scripts": {
"build": "tsc",
"dev": "tsx src/index.ts",
"test": "vitest run",
"prepublishOnly": "npm run build && npm test"
},
"dependencies": {
"commander": "^12.0.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"tsx": "^4.16.0",
"@types/node": "^20.0.0"
}
}

Le champ bin est le coeur: il dit à npm “quand on installe ce paquet, crée une commande moncli qui pointe vers ./dist/index.js“. Le nom dans bin devient le nom de la commande que les gens taperont. À noter:

  • "type": "module": on utilise les modules ES.
  • Le shebang doit être la première ligne de src/index.ts (voir plus bas).
  • tsx pour le développement: il exécute directement du TypeScript sans build intermédiaire, ce qui permet de tester le CLI avec npm run dev -- --port 8080.

Le shebang et l’exécutable

Un CLI Node.js doit être exécutable. Deux choses sont nécessaires.

D’abord, le shebang, une ligne spéciale en tout début de fichier qui dit au kernel quel interpréteur utiliser:

#!/usr/bin/env node
// src/index.ts - première ligne obligatoire

Sans elle, lancer ./dist/index.js en direct échouerait. Ensuite, le fichier doit avoir les permissions d’exécution, qu’on pose après le build:

# Après le build
chmod +x dist/index.js

Quand le paquet est installé globalement (npm i -g moncli), npm crée automatiquement un lien symbolique exécutable dans le PATH. C’est pour ça que bin + shebang sont essentiels: c’est eux qui rendent la commande invocable n’importe où.

Le parsing des arguments

J’utilise Commander comme bibliothèque de référence, car elle gère le --help, le --version, les flags et les sous-commandes avec très peu de code:

// src/index.ts
import { Command } from 'commander';

const program = new Command();

program
.name('moncli')
.description('Outil de gestion de mon infrastructure')
.version('1.0.0');

program
.command('deploy')
.description('Deploie un service')
.argument('<service>', 'nom du service a deployer')
.option('-e, --environment <env>', 'environnement', 'staging')
.option('-p, --port <port>', 'port', parseInt)
.action(async (service, options) => {
await deploy(service, options);
});

program
.command('list')
.description('Liste les services')
.option('-a, --all', 'inclure les services arretes', false)
.action(async (options) => {
await listServices(options);
});

program.parse();

Ce que commander fait pour moi:

  • Génère le --help automatiquement, avec la description de chaque sous-commande et de chaque option.
  • Gère --version (il lit version du package.json).
  • Parse les arguments positionnels (<service>) et les options (--environment, -e).
  • Convertit les types: .option('... <port>', '...', parseInt) transforme la chaîne en nombre.
  • Gère les erreurs de syntaxe avec un message clair et un code de sortie non nul.

L’alternative principale est Yargs, plus lourd mais très configurable, avec un typage intégré de toutes les options. Pour la majorité des cas, commander suffit. Pour une seule commande et une ou deux options, un parsing manuel bien validé est aussi acceptable, mais dès qu’il y a des sous-commandes, je reviens à commander.

Les flags typés et la sortie colorée

Flags avec types corrects

Le typage des options est la raison d’être de l’intérêt de TS ici. Voyons un exemple où le type évite un bug réel. Commander avec parseInt pour un port:

import { Command, InvalidArgumentError } from 'commander';

const program = new Command()
.command('serve')
.option('-p, --port <port>', 'port du serveur', (v: string) => {
const port = parseInt(v, 10);
if (isNaN(port) || port < 1 || port > 65535) {
throw new InvalidArgumentError(`Port invalide: ${v}`);
}
return port;
}, 8080);

interface ServeOptions {
port: number; // le type est garanti par la fonction de parsing
}

Sortie colorée

La couleur rend la lecture bien plus agréable, surtout pour les erreurs et les succès. Chalk est le classique:

import chalk from 'chalk';

export function success(msg: string) {
console.log(chalk.green('✔') + ' ' + msg);
}

export function error(msg: string) {
console.error(chalk.bgRed.white(' ERROR ') + ' ' + chalk.red(msg));
}

export function info(msg: string) {
console.log(chalk.cyan('i') + ' ' + msg);
}

Un retour d’expérience: quand la sortie est redirigée vers un fichier ou un pipe, les codes de couleur polluent le résultat. Chalk (et la plupart des bibliothèques) désactive les couleurs automatiquement si la sortie n’est pas un terminal (!process.stdout.isTTY). Vérifiez donc que vos helpers passent par la bibliothèque et n’écrivent pas les codes ANSI à la main. Pour les pipelines, proposez aussi un drapeau --no-color et --no-emoji.

Les codes de sortie et la gestion d’erreurs

Un CLI correct doit retourner un code de sortie: 0 en cas de succès, non nul en cas d’erreur. C’est ce que les scripts shell, les CI et les tests vérifient.

import { Command } from 'commander';
import { error } from './utils/output.js';

const program = new Command();

// ... definitions des commandes ...

// Point d entree: capturer les erreurs et sortir avec le bon code
try {
await program.parseAsync(process.argv);
} catch (err) {
error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
}

Trucs importants:

  • program.parseAsync: pour les handlers async. program.parse est synchrone et n’attend pas les promesses.
  • process.exitCode = 1 plutôt que process.exit(1): le premier laisse la sortie se terminer proprement (flush des buffers), le second tue tout immédiatement, ce qui peut tronquer la sortie.
  • process.stderr pour les erreurs, process.stdout pour les données: on écrit les erreurs sur stderr pour que les pipes ne mélangent pas données et erreurs.
function usageError(msg: string) {
console.error(`Erreur: ${msg}`);
console.error('Utilisez --help pour voir les options.');
process.exitCode = 2; // 2 = erreur d usage, 1 = erreur d execution
}

Lire stdin et gérer l’asynchrone

Un CLI doit souvent lire l’entrée standard: soit interactive (prompts), soit via un pipe (cat data.json | moncli import).

Lire stdin complet (pour les pipes)

function readStdin(): Promise<string> {
if (process.stdin.isTTY) return Promise.resolve('');
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf-8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
});
}

const raw = await readStdin();
const json = JSON.parse(raw || '{}');

Des prompts interactifs

Pour un workflow interactif, Inquirer accompagne commander parfaitement:

import inquirer from 'inquirer';

const answers = await inquirer.prompt([
{
type: 'list',
name: 'environment',
message: 'Choisissez un environnement:',
choices: [
{ name: 'Staging', value: 'staging' },
{ name: 'Production', value: 'production' },
],
},
{
type: 'confirm',
name: 'confirmDeploy',
message: 'Etes-vous sur de vouloir deployer?',
default: false,
},
]);

if (!answers.confirmDeploy) {
console.log('Deploiement annule.');
process.exitCode = 1;
}

Un retour d’expérience: pour une action destructive en production, ne vous contentez jamais d’une confirmation simple. Demandez la saisie du nom exact du service pour éviter les erreurs de frappe grossières.

Le build: de TypeScript à l’exécutable

La config TypeScript

// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"declaration": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src"]
}

Points clés:

  • strict: true: obligatoire pour tirer le bénéfice du typage. Sans lui, le CLI se fait envahir de any silencieux.
  • outDir: dist: le code compilé va dans dist.
  • module: NodeNext + moduleResolution: NodeNext: avec "type": "module" dans package.json, il faut des imports avec l’extension .js même en TypeScript source:
// APRES compilation, il faut .js
import { deploy } from './commands/deploy.js';

C’est un détail qui piège beaucoup de monde au premier build. Avec NodeNext, les imports relatifs doivent mentionner .js (l’extension du fichier compilé), pas .ts.

Deux stratégies de build

  1. tsc (simple): le compilateur standard de TypeScript. Sortie en dist, lisible, largement suffisant pour 99 % des CLIs.
npm run build
# dist/index.js dist/commands/deploy.js ...
  1. esbuild / bun (rapide, bundle): pour un fichier unique autonome. esbuild est très rapide (millisecondes) et peut bundler toutes les dépendances dans un seul fichier:
esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --banner:js="#!/usr/bin/env node"
chmod +x dist/index.js

Et Bun peut exécuter du TypeScript directement, sans étape de build.

Installer globalement et tester

# Depuis le dossier du projet
npm link
# ou
npm i -g .

# Maintenant la commande existe
moncli --version
moncli --help
moncli deploy mon-service --environment production

npm link crée un lien symbolique dans le PATH, pointant vers votre build local. Idéal pendant le développement. Pour une distribution : npm pack ou publier sur un registre interne, puis npm i -g.

Les tests d’un CLI

Tester un CLI, c’est avant tout tester que les commandes se comportent correctement avec des arguments variés. L’approche la plus robuste: lancer le CLI comme un vrai processus avec le test runner de Node ou Vitest:

import { describe, it, expect } from 'vitest';
import { execFile } from 'child_process';
import { promisify } from 'util';

const exec = promisify(execFile);

describe('moncli', () => {
it('--version affiche la version', async () => {
const { stdout } = await exec('node', ['dist/index.js', '--version']);
expect(stdout.trim()).toBe('1.0.0');
});

it('--help liste les commandes', async () => {
const { stdout } = await exec('node', ['dist/index.js', '--help']);
expect(stdout).toContain('deploy');
});

it('une commande inconnue retourne une erreur', async () => {
await expect(
exec('node', ['dist/index.js', 'commande-inexistante'])
).rejects.toMatchObject({ code: 1 });
});
});

Tester via execFile plutôt qu’en appelant les fonctions directement teste le vrai comportement: le parsing des arguments, le code de sortie, la sortie. C’est ce qui garantit que le CLI “de l’extérieur” se comporte comme prévu.

Consoles UX: les bonnes pratiques

Quelques habitudes qui transforment un CLI utilisable en un CLI agréable:

  • Toujours un --help clair avec la description de chaque commande et option. Commander le génère si on remplit les descriptions.
  • Toujours un --version: vital pour le debugging.
  • Des messages d’erreur explicites: “Port invalide: abc, attendu un nombre entre 1 et 65535”, pas juste “erreur”.
  • Pas d’emoji dans les pipelines: les emojis sont mal rendus dans les logs de CI. Utilisez des symboles ASCII ou un flag --no-emoji.
  • --verbose/--quiet: contrôler la verbosité est essentiel pour l’intégration dans des scripts.

Un exemple complet minimal

Voici l’exemple le plus complet possible dans un seul fichier, montrant le shebang, le parsing, une sous-commande, la sortie et les codes de sortie:

#!/usr/bin/env node
// src/index.ts
import { Command, InvalidArgumentError } from 'commander';
import chalk from 'chalk';

function parsePort(v: string) {
const n = parseInt(v, 10);
if (isNaN(n) || n < 1 || n > 65535) {
throw new InvalidArgumentError(`Port invalide: ${v}`);
}
return n;
}

const program = new Command();

program
.name('moncli')
.description('Exemple de CLI TypeScript')
.version('1.0.0')
.option('--no-color', 'desactive la couleur')
.option('--no-emoji', 'desactive les emojis');

program
.command('deploy')
.description('Deploie un service')
.argument('<service>', "nom du service a deployer")
.option('-e, --environment <env>', 'environnement', 'staging')
.option('-p, --port <port>', 'port', parsePort)
.action(async (service, opts) => {
console.log(chalk.cyan(`Deploiement de ${service} en ${opts.environment}`));
console.log(chalk.green(`Port: ${opts.port}`));
});

program
.command('list')
.description('Liste les services')
.option('-a, --all', 'inclure les arretes', false)
.action(async (opts) => {
if (opts.all) console.log('Tous les services:');
else console.log('Services actifs:');
console.log(' - mon-service (staging)');
console.log(' - mon-api (production)');
});

try {
await program.parseAsync(process.argv);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exitCode = 1;
}

Et le build qui va avec:

npm run build
npm link
moncli deploy mon-api --environment production --port 8080
moncli --help
moncli list --all

Pourquoi je persiste avec TypeScript

Après plusieurs CLIs, mon retour d’expérience est net: TypeScript transforme le développement de CLI d’un exercice de debug du parsing des arguments en un exercice de définition du bon modèle de commandes. Le typage des options, le partage des types avec un serveur, et l’autocomplétion me font gagner un temps considérable, même sur un outil de 200 lignes.

Le coût est un build supplémentaire (tsc ou esbuild) et une étape de plus entre le code et l’exécution. Mais avec tsx en dev (qui exécute le TS directement), ce coût disparaît presque, et le build final est trivial.

Si vous écrivez un CLI qui va vivre un peu, que ce soit pour votre usage ou celui de votre équipe, TypeScript est, à mon avis, le choix le plus raisonnable.

Pour aller plus loin