Sviluppare applicazioni CLI in Node.js

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 0 in caso di successo, 1 per gli errori di esecuzione e 2 per 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.