Sviluppare applicazioni CLI in Node.js
Node.js è diventato, quasi senza che ce ne accorgessimo, uno degli ecosistemi più ricchi di strumenti da riga di comando: npm, i compilatori TypeScript e Vue, ESLint, Prettier, Vite e migliaia di altri strumenti quotidiani sono applicazioni Node. Per anni scrivere una CLI in Node ha significato installare una libreria per il parsing degli argomenti, una per i colori e una per i test; oggi la piattaforma include tutto il necessario. In questo articolo costruiremo tasks, un gestore di attività con persistenza su file JSON, usando solo i moduli integrati di Node: util.parseArgs per gli argomenti, node:fs/promises per i file e node:test per i test.
Le convenzioni da rispettare
Una CLI ben fatta si comporta come gli altri strumenti del sistema. Fissiamo le regole che applicheremo:
- i risultati vanno su standard output, errori e diagnostica su standard error;
- il codice di uscita vale
0in caso di successo,1per gli errori di esecuzione e2per gli errori di utilizzo; - nessuno stack trace per gli errori previsti: l'utente deve ricevere un messaggio comprensibile;
- colori solo su terminale e mai se
NO_COLORè impostata; - un formato JSON per l'uso da script.
tasks add "Rinnovare il certificato TLS" --priority high
tasks list --all --format json
tasks done 3
tasks rm 3
Struttura del progetto
Useremo i moduli ECMAScript e una struttura che separa l'eseguibile, la logica della CLI e la persistenza:
tasks/
├── package.json
├── bin/
│ └── tasks.js
├── src/
│ ├── cli.js
│ └── store.js
└── test/
└── cli.test.js
{
"name": "@example/tasks",
"version": "1.0.0",
"description": "Un semplice gestore di attività da riga di comando",
"type": "module",
"bin": {
"tasks": "./bin/tasks.js"
},
"files": ["bin", "src"],
"engines": {
"node": ">=20.12"
},
"scripts": {
"test": "node --test"
}
}
Il campo bin è il meccanismo con cui npm installa un eseguibile: al momento di npm install -g, o quando il pacchetto è una dipendenza locale, npm crea un collegamento chiamato tasks nella directory degli eseguibili, su Windows sotto forma di script .cmd. Il campo files limita il contenuto del pacchetto pubblicato a ciò che serve davvero, escludendo test e configurazioni.
Il livello di persistenza
Il modulo store.js si occupa solo dei dati e non sa nulla della riga di comando. Definiamo una classe di errore per gli errori di esecuzione e la funzione che calcola il percorso predefinito del file secondo le convenzioni di ciascun sistema operativo.
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
export const PRIORITIES = ['low', 'normal', 'high'];
// Errore di esecuzione legato ai dati (codice di uscita 1).
export class StoreError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'StoreError';
}
}
// Restituisce il percorso del file dati rispettando le convenzioni del sistema.
export function defaultPath(env = process.env) {
if (env.TASKS_FILE) {
return env.TASKS_FILE;
}
let base;
if (process.platform === 'win32') {
base = env.APPDATA ?? join(homedir(), 'AppData', 'Roaming');
} else if (process.platform === 'darwin') {
base = join(homedir(), 'Library', 'Application Support');
} else {
base = env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
}
return join(base, 'tasks', 'tasks.json');
}
Notate che defaultPath riceve l'ambiente come parametro con un valore predefinito: è un piccolo accorgimento che permette ai test di passare un ambiente controllato senza modificare process.env globale.
La lettura tratta il file assente come una lista vuota e trasforma un JSON corrotto in un StoreError con un messaggio chiaro. La scrittura è atomica: si scrive su un file temporaneo nella stessa directory, si forza lo scaricamento su disco con sync e si rinomina sopra l'originale.
export class Store {
constructor(path) {
this.path = path;
}
async load() {
let raw;
try {
raw = await readFile(this.path, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return [];
}
throw new StoreError(`impossibile leggere ${this.path}: ${error.message}`, { cause: error });
}
try {
const tasks = JSON.parse(raw);
if (!Array.isArray(tasks)) {
throw new TypeError('il contenuto non è un array');
}
return tasks;
} catch (error) {
throw new StoreError(`il file ${this.path} non è valido: ${error.message}`, { cause: error });
}
}
async save(tasks) {
const dir = dirname(this.path);
await mkdir(dir, { recursive: true });
const tmpPath = join(dir, `.tasks-${randomBytes(6).toString('hex')}.json`);
const handle = await open(tmpPath, 'w', 0o644);
try {
await handle.writeFile(`${JSON.stringify(tasks, null, 2)}\n`, 'utf8');
// Senza sync, un crash dopo la rinomina potrebbe lasciare un file vuoto.
await handle.sync();
} catch (error) {
await handle.close();
await rm(tmpPath, { force: true });
throw new StoreError(`impossibile scrivere ${this.path}: ${error.message}`, { cause: error });
}
await handle.close();
await rename(tmpPath, this.path);
}
static nextId(tasks) {
return tasks.reduce((max, task) => Math.max(max, task.id), 0) + 1;
}
static indexOf(tasks, id) {
const index = tasks.findIndex((task) => task.id === id);
if (index === -1) {
throw new StoreError(`attività non trovata: ${id}`);
}
return index;
}
}
Il parsing degli argomenti con util.parseArgs
Dalla versione 18.3, Node include util.parseArgs, un parser minimale ma solido che gestisce opzioni lunghe e brevi, valori booleani e stringhe, opzioni ripetute e argomenti posizionali. Non supporta direttamente i sottocomandi, ma la soluzione è semplice: si legge il primo argomento per scegliere il comando, e si invoca parseArgs sul resto con la configurazione specifica di quel comando.
Con strict: true, che è il valore predefinito, parseArgs solleva un'eccezione per ogni opzione sconosciuta o con un valore del tipo sbagliato. Gli errori hanno una proprietà code che inizia con ERR_PARSE_ARGS: la useremo per distinguerli dagli altri e trasformarli in errori di utilizzo.
import { parseArgs } from 'node:util';
import { createRequire } from 'node:module';
import { PRIORITIES, Store, StoreError, defaultPath } from './store.js';
export const EXIT_OK = 0;
export const EXIT_FAILURE = 1;
export const EXIT_USAGE = 2;
const { version } = createRequire(import.meta.url)('../package.json');
// Errore di utilizzo (codice di uscita 2).
export class UsageError extends Error {
constructor(message) {
super(message);
this.name = 'UsageError';
}
}
const USAGE = `Uso: tasks <comando> [opzioni] [argomenti]
Comandi:
add <titolo> [-p, --priority low|normal|high] aggiunge un'attività
list [-a, --all] [-f, --format table|json] elenca le attività
done <id> segna un'attività come completata
rm <id> elimina un'attività
Opzioni globali:
-h, --help mostra questo aiuto
-v, --version mostra la versione
`;
// Esegue parseArgs convertendo i suoi errori in UsageError.
function parse(command, args, options = {}) {
try {
return parseArgs({
args,
options: { ...options, help: { type: 'boolean', short: 'h' } },
allowPositionals: true,
strict: true,
});
} catch (error) {
if (error.code?.startsWith('ERR_PARSE_ARGS')) {
throw new UsageError(`${command}: ${error.message}`);
}
throw error;
}
}
La versione viene letta dal package.json tramite createRequire, che è il modo più compatibile per importare un file JSON da un modulo ECMAScript. Le versioni recenti di Node supportano anche gli import attributes, import pkg from '../package.json' with { type: 'json' }, che possono sostituirlo se si richiede una versione di Node sufficientemente recente.
Il contesto di esecuzione
Per rendere la CLI testabile senza avviare processi separati, tutti i punti di contatto con l'esterno, cioè standard output, standard error e variabili d'ambiente, vengono raccolti in un oggetto di contesto passato a ogni comando. In produzione il contesto contiene i flussi reali del processo; nei test conterrà oggetti che accumulano il testo in memoria.
const COLORS = { high: '\x1b[31m', normal: '\x1b[33m', low: '\x1b[90m' };
const RESET = '\x1b[0m';
// I colori sono attivi solo su un terminale e se NO_COLOR non è impostata.
function colorEnabled(stream, env) {
return Boolean(stream.isTTY) && !env.NO_COLOR;
}
function createContext({ stdout, stderr, env }) {
const color = colorEnabled(stdout, env);
return {
env,
store: new Store(defaultPath(env)),
out: (line = '') => stdout.write(`${line}\n`),
err: (line) => stderr.write(`${line}\n`),
paint: (priority) => (color ? `${COLORS[priority]}${priority}${RESET}` : priority),
};
}
Node mette a disposizione anche util.styleText, che applica stili ANSI in modo dichiarativo e nelle versioni più recenti verifica automaticamente il flusso di destinazione e le variabili d'ambiente come NO_COLOR. Qui preferiamo una funzione esplicita per mantenere il comportamento identico su tutte le versioni supportate e per mostrare chiaramente la logica.
I comandi
Ogni comando è una funzione asincrona che riceve gli argomenti rimanenti e il contesto, e solleva un UsageError o uno StoreError in caso di problemi. Il comando add unisce gli argomenti posizionali, così che tasks add Comprare il latte funzioni senza virgolette.
async function add(args, ctx) {
const { values, positionals } = parse('add', args, {
priority: { type: 'string', short: 'p', default: 'normal' },
});
if (values.help) {
ctx.out('Uso: tasks add <titolo> [-p, --priority low|normal|high]');
return;
}
const title = positionals.join(' ').trim();
if (!title) {
throw new UsageError('add: il titolo è obbligatorio');
}
if (!PRIORITIES.includes(values.priority)) {
throw new UsageError(
`add: priorità non valida "${values.priority}" (valori ammessi: ${PRIORITIES.join(', ')})`,
);
}
const tasks = await ctx.store.load();
const task = {
id: Store.nextId(tasks),
title,
priority: values.priority,
done: false,
createdAt: new Date().toISOString(),
};
tasks.push(task);
await ctx.store.save(tasks);
ctx.out(`Aggiunta l'attività ${task.id}: ${task.title}`);
}
Il comando list produce una tabella per le persone o un array JSON per le macchine. Il calcolo delle larghezze delle colonne avviene sul testo semplice, e il colore viene applicato solo dopo il riempimento con padEnd, altrimenti i caratteri invisibili dei codici ANSI altererebbero l'allineamento.
async function list(args, ctx) {
const { values, positionals } = parse('list', args, {
all: { type: 'boolean', short: 'a', default: false },
format: { type: 'string', short: 'f', default: 'table' },
});
if (values.help) {
ctx.out('Uso: tasks list [-a, --all] [-f, --format table|json]');
return;
}
if (positionals.length > 0) {
throw new UsageError(`list: argomenti inattesi: ${positionals.join(' ')}`);
}
if (!['table', 'json'].includes(values.format)) {
throw new UsageError(`list: formato non supportato "${values.format}"`);
}
const tasks = (await ctx.store.load()).filter((task) => values.all || !task.done);
if (values.format === 'json') {
ctx.out(JSON.stringify(tasks, null, 2));
return;
}
printTable(tasks, ctx);
}
function printTable(tasks, ctx) {
if (tasks.length === 0) {
ctx.out('Nessuna attività.');
return;
}
const headers = ['ID', 'STATO', 'PRIORITÀ', 'TITOLO'];
const rows = tasks.map((t) => [String(t.id), t.done ? 'fatta' : 'aperta', t.priority, t.title]);
const widths = [0, 1, 2].map((i) => Math.max(...[headers, ...rows].map((row) => row[i].length)));
const format = (row, colored) => {
const cells = [0, 1, 2].map((i) => row[i].padEnd(widths[i]));
if (colored) {
// Il colore avvolge la cella già riempita: l'allineamento resta corretto.
cells[2] = cells[2].replace(row[2], ctx.paint(row[2]));
}
return [...cells, row[3]].join(' ');
};
ctx.out(format(headers, false));
for (const row of rows) {
ctx.out(format(row, true));
}
}
I comandi done e rm condividono la lettura di un ID numerico. Usiamo Number invece di parseInt perché quest'ultimo accetterebbe silenziosamente input come 3abc, restituendo 3.
function parseId(command, args) {
const { positionals } = parse(command, args);
if (positionals.length !== 1) {
throw new UsageError(`${command}: è richiesto esattamente un ID`);
}
const id = Number(positionals[0]);
if (!Number.isInteger(id) || id <= 0) {
throw new UsageError(`${command}: ID non valido "${positionals[0]}"`);
}
return id;
}
async function done(args, ctx) {
const id = parseId('done', args);
const tasks = await ctx.store.load();
tasks[Store.indexOf(tasks, id)].done = true;
await ctx.store.save(tasks);
ctx.out(`Attività ${id} completata.`);
}
async function remove(args, ctx) {
const id = parseId('rm', args);
const tasks = await ctx.store.load();
tasks.splice(Store.indexOf(tasks, id), 1);
await ctx.store.save(tasks);
ctx.out(`Attività ${id} eliminata.`);
}
const COMMANDS = { add, list, ls: list, done, rm: remove, remove };
La funzione run
run è l'unico punto in cui gli errori diventano messaggi e codici di uscita. Restituisce il codice invece di chiamare process.exit: questo la rende testabile e, come vedremo, evita anche un problema concreto di troncamento dell'output.
export async function run(argv, io = process) {
const ctx = createContext(io);
const [command, ...rest] = argv;
if (command === undefined) {
io.stderr.write(USAGE);
return EXIT_USAGE;
}
if (command === '-h' || command === '--help' || command === 'help') {
io.stdout.write(USAGE);
return EXIT_OK;
}
if (command === '-v' || command === '--version') {
ctx.out(`tasks ${version}`);
return EXIT_OK;
}
const handler = Object.hasOwn(COMMANDS, command) ? COMMANDS[command] : undefined;
try {
if (!handler) {
throw new UsageError(`comando sconosciuto "${command}"`);
}
await handler(rest, ctx);
return EXIT_OK;
} catch (error) {
if (error instanceof UsageError) {
ctx.err(`tasks: ${error.message}`);
ctx.err('Esegui "tasks --help" per l\'elenco dei comandi.');
return EXIT_USAGE;
}
if (error instanceof StoreError) {
ctx.err(`tasks: ${error.message}`);
return EXIT_FAILURE;
}
// Errore imprevisto: è un bug, quindi mostriamo lo stack se richiesto.
ctx.err(`tasks: errore interno: ${error.message}`);
if (io.env.DEBUG) {
ctx.err(error.stack);
}
return EXIT_FAILURE;
}
}
L'uso di Object.hasOwn per cercare il comando non è un eccesso di zelo: con un semplice COMMANDS[command], l'input tasks constructor troverebbe una proprietà ereditata da Object.prototype e proverebbe a eseguirla.
L'eseguibile e il problema di process.exit
Il file bin/tasks.js inizia con la riga shebang, che indica al sistema quale interprete usare quando il file viene eseguito direttamente, e deve avere il permesso di esecuzione.
#!/usr/bin/env node
import { run } from '../src/cli.js';
process.exitCode = await run(process.argv.slice(2));
chmod +x bin/tasks.js
Si noti che non chiamiamo process.exit(code), ma assegniamo process.exitCode. La differenza è importante: quando lo standard output è una pipe, le scritture in Node sono asincrone, e process.exit termina il processo immediatamente, scartando i dati ancora in coda. Il sintomo tipico è un JSON troncato quando l'output di un comando viene passato a jq o salvato in un file. Impostando process.exitCode, invece, Node termina naturalmente quando il ciclo degli eventi si svuota, dopo aver scritto tutto, e usa quel valore come codice di uscita.
Gestire i segnali
Per impostazione predefinita, Node termina immediatamente alla ricezione di SIGINT. Per operazioni lunghe conviene intercettare il segnale e usare un AbortController per propagare l'annullamento alle operazioni in corso, che in Node accettano quasi tutte un'opzione signal:
const controller = new AbortController();
process.once('SIGINT', () => {
process.stderr.write('\ntasks: interruzione in corso...\n');
controller.abort();
// Un secondo Ctrl+C termina subito, come si aspetta l'utente.
process.once('SIGINT', () => process.exit(130));
});
try {
await importTasks(source, { signal: controller.signal });
} catch (error) {
if (error.name === 'AbortError') {
process.exitCode = 130;
} else {
throw error;
}
}
Test con node:test
Node include un test runner completo, avviato con node --test, che individua automaticamente i file con suffisso .test.js. Per i test in-process creiamo un oggetto io fittizio che accumula l'output in stringhe; per verificare il comportamento reale del processo, codici di uscita e shebang inclusi, eseguiamo anche l'eseguibile vero con spawnSync.
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { EXIT_FAILURE, EXIT_OK, EXIT_USAGE, run } from '../src/cli.js';
// Crea un flusso finto che accumula il testo scritto.
function memoryStream() {
return {
isTTY: false,
data: '',
write(chunk) {
this.data += chunk;
return true;
},
};
}
describe('tasks', () => {
let env;
beforeEach(async () => {
const dir = await mkdtemp(join(tmpdir(), 'tasks-test-'));
env = { TASKS_FILE: join(dir, 'tasks.json'), NO_COLOR: '1' };
});
async function exec(...argv) {
const io = { stdout: memoryStream(), stderr: memoryStream(), env };
const code = await run(argv, io);
return { code, stdout: io.stdout.data, stderr: io.stderr.data };
}
it('aggiunge e elenca le attività in JSON', async () => {
const added = await exec('add', 'Scrivere', 'i', 'test', '--priority', 'high');
assert.equal(added.code, EXIT_OK);
const listed = await exec('list', '--format', 'json');
const tasks = JSON.parse(listed.stdout);
assert.equal(tasks.length, 1);
assert.equal(tasks[0].title, 'Scrivere i test');
assert.equal(tasks[0].priority, 'high');
});
it('nasconde le attività completate se non si usa --all', async () => {
await exec('add', 'Prima');
await exec('add', 'Seconda');
await exec('done', '1');
const open = JSON.parse((await exec('list', '-f', 'json')).stdout);
assert.deepEqual(open.map((t) => t.id), [2]);
const all = JSON.parse((await exec('list', '-a', '-f', 'json')).stdout);
assert.deepEqual(all.map((t) => t.id), [1, 2]);
});
const cases = [
[[], EXIT_USAGE],
[['fly'], EXIT_USAGE],
[['constructor'], EXIT_USAGE],
[['add'], EXIT_USAGE],
[['add', 'x', '--priority', 'urgent'], EXIT_USAGE],
[['list', '--colour'], EXIT_USAGE],
[['done', '3abc'], EXIT_USAGE],
[['done', '42'], EXIT_FAILURE],
[['--help'], EXIT_OK],
];
for (const [argv, expected] of cases) {
it(`esce con ${expected} per "${argv.join(' ')}"`, async () => {
assert.equal((await exec(...argv)).code, expected);
});
}
it('scrive gli errori solo su stderr', async () => {
const result = await exec('rm', '7');
assert.equal(result.stdout, '');
assert.match(result.stderr, /^tasks: attività non trovata/);
});
it('funziona come processo reale', () => {
const bin = fileURLToPath(new URL('../bin/tasks.js', import.meta.url));
const result = spawnSync(process.execPath, [bin, 'list', '--format', 'json'], {
env: { ...process.env, ...env },
encoding: 'utf8',
});
assert.equal(result.status, EXIT_OK);
assert.deepEqual(JSON.parse(result.stdout), []);
});
});
npm test
Distribuzione
Il canale di distribuzione naturale è il registry npm, pubblico o privato. Una volta pubblicato, il pacchetto può essere installato globalmente oppure eseguito al volo con npx, senza installazione permanente.
# Prova locale: crea un collegamento globale ai sorgenti
npm link
tasks --version
# Pubblicazione e installazione
npm publish --access public
npm install --global @example/tasks
npx @example/tasks list
Quando gli utenti non hanno Node installato, la piattaforma offre le Single Executable Applications: un meccanismo integrato che inserisce uno script, preventivamente raggruppato in un unico file con uno strumento come esbuild, all'interno di una copia dell'eseguibile di Node. Il risultato è un binario autonomo, al prezzo di dimensioni importanti (l'intero runtime è incluso) e della necessità di generarlo separatamente per ciascuna piattaforma di destinazione. Per strumenti interni distribuiti su macchine dove Node è già presente, il pacchetto npm resta la soluzione più semplice.
Conclusione
Abbiamo costruito una CLI completa senza nessuna dipendenza esterna: util.parseArgs per opzioni e argomenti, node:fs/promises per una persistenza atomica, node:test per i test. Le scelte progettuali sono le stesse che rendono solida una CLI in qualsiasi linguaggio, con in più due accorgimenti specifici di Node che vale la pena ricordare: impostare process.exitCode invece di chiamare process.exit, per non perdere output nelle pipe, e raccogliere tutti gli accessi a flussi e ambiente in un contesto iniettabile, per poter testare l'applicazione senza avviare processi. Quando i comandi diventeranno decine, librerie come Commander o yargs aggiungeranno completamento automatico e aiuto più ricco, ma la struttura dell'applicazione potrà restare la stessa.