💡

Vous êtes en train de lire le Chapitre 8 du livre “Node.js”, écrit par Thomas Parisot et publié aux Éditions Eyrolles.

L’ouvrage vous plaît ? Achetez-le sur Amazon.fr ou en librairie. Donnez quelques euros pour contribuer à sa gratuité en ligne.

Créer un outil en ligne de commande est un savoir utile pour forger ses propres outils, automatiser des actions et mieux s’intégrer au système d’exploitation.

Sommaire
  • Créer un script exécutable.

  • Du script au programme interactif.

  • Vers un code réutilisable et testable.

  • Utilisation d’un framework d’application en ligne de commandes.

  • Gérer les chemins d’accès et les flux de données.

  • Rendre le programme indépendant de Node.

Ce chapitre est une invitation à créer des applications au plus proche des systèmes d’exploitation, là où Node excelle.

Nous apprendrons à passer d’un script Node ordinaire à un script qui s’exécute comme un programme de notre système d’exploitation.

Nous compléterons ce programme en améliorant son expérience utilisateur mais aussi en le rendant robuste grâce aux tests et à l’écriture d’une documentation minimaliste, générée automatiquement.

Enfin, nous verrons aussi comment aller plus loin en organisation son code comme dans une véritable application, avec une compréhension plus poussée des chemins d’accès et des traitements en continu sur des flux de données.

💬
Remarque Versions de Node et npm

Le contenu de ce chapitre utilise les versions Node v10 et npm v6. Ce sont les versions stables recommandées en 2022.

Un script en ligne de commande revient souvent à une installation globale d’un module npm (npm install --global <module>). Il prend aussi bien la forme d’un petit outil que d’une application complète. Dans tous les cas, le terminal est l’interface d’affichage.

Node est particulièrement adapté à la création d’outils en ligne de commande grâce à son modèle de gestion mémoire et son processus unique. Il doit toutefois partager la mémoire et les ressources de la machine avec les autres programmes – à nous de faire le choix de la frugalité.

Ces codes nous servent à outiller nos projets, à créer des programmes autonomes, des interfaces visuelles dans un terminal et à automatiser ce qui doit l’être. Leur distribution sur le registre npm (chapitre 5) en facilite l’accès et le partage, surtout si vous les avez bien testés et documentés.

9. Créer un script exécutable

La première étape est de rendre exécutable votre script Node. Le système d’exploitation ne le percevra plus comme un simple fichier texte, mais bel et bien comme un programme, au même titre que l’exécutable npm.

Nous allons apprendre ce cheminement ensemble, jusqu’à rendre notre code distribuable sous forme d’un module npm (chapitre 5).

9.1. Au départ, un simple script Node

Ce dont nous avons besoin pour démarrer, c’est d’un script Node que nous pouvons appeler depuis notre terminal. Nous allons placer l’exemple suivant dans le répertoire bin (pour binary en anglais, c’est-à-dire exécutable). Cela n’a pas d’incidence technique, mais c’est une pratique courante au sein de la communauté Node pour repérer plus facilement les exécutables sans ambiguïté.

bin/time.js
'use strict';

const date = new Date();                    // (1)
const hour = date.getHours();
const minutes = date.getMinutes();

console.log(`Il est ${hour}h${minutes}.`);
  1. Crée un objet qui représente la date courante (chapitre 3).

💡
Pratique Jouer avec les exemples dans un terminal

Les exemples titrés d’un nom de fichier peuvent être installés sur votre ordinateur. Exécutez-les dans un terminal et amusez-vous à les modifier en parallèle de votre lecture pour voir ce qui change.

Installation des exemples via le module npm nodebook
npm install --global nodebook
nodebook install chapter-08
cd $(nodebook dir chapter-08)

La commande suivante devrait afficher un résultat qui confirme que vous êtes au bon endroit :

node hello.js

Suivez à nouveau les instructions d’installation pour rétablir les exemples dans leur état initial.

L’exécution du script avec Node retourne la date et l’heure courante – selon l’horloge de l’ordinateur qui exécute le code.

node bin/time.js
Il est 13h42.

9.2. Modifier les permissions du script

Les systèmes d’exploitation modernes distinguent les fichiers ordinaires des fichiers exécutables. L’appel à un fichier exécutable se fait sans avoir à connaître quoi que ce soit d’autre que son emplacement.

Essayons d’exécuter le script précédent pour nous en rendre compte. Pour ce faire, nous allons l’invoquer seulement avec son chemin – ici, son chemin relatif :

./bin/time.js
sh: permission denied: ./bin/time.js

Le système refuse de l’exécuter car les permissions du fichier ne sont pas adéquates. Comme nous ne les connaissons pas, utilisons la commande ls ainsi que l’option -l pour afficher ses informations détaillées :

ls -l bin/time.js
-rw-r--r-- oncletom  staff  175 Jun 14 13:47 bin/time.js

Cet affichage détaille les permissions du fichier, l’utilisateur et le groupe propriétaire, son poids et enfin la date de dernière modification.

💬
Déchiffrer Lire les permissions Unix

Le premier caractère spécifie le type (fichier, répertoire, lien symbolique) et ensuite, ce sont des blocs de trois caractères qui décrivent les permissions de l’utilisateur propriétaire, du groupe propriétaire et du reste des utilisateurs du système d’exploitation.

Chaque bloc affiche r s’il est lisible, w s’il est modifiable et x s’il est exécutable – c’est ce dernier qui nous intéresse.

Nous allons rendre le fichier exécutable (+x) pour notre utilisateur (u) grâce à la commande chmod. Je préfère utiliser cette notation car elle évite des effets de bord :

chmod u+x bin/time.js

L’utilisation renouvelée de la commande ls confirme que la permission exécutable du fichier a été attribuée à l’utilisateur propriétaire du fichier :

ls -l bin/time.js
-rwxr--r-- oncletom  staff  175 Jun 14 13:47 bin/time.js

Nous sommes accueillis avec un nouveau message d’erreur lorsque nous tentons d’exécuter le fichier bin/time.js :

./bin/time.js
./bin/time.js: line 1: use strict: command not found
./bin/time.js: line 3: syntax error near unexpected token `('

La bonne nouvelle, c’est que le fichier est exécutable. Néanmoins, il semblerait que le système d’exploitation ait du mal à l’interpréter.

9.3. Préciser le contexte d’exécution (shebang)

Donner les permissions d’exécution à un fichier ne suffit donc pas. Nous avons perdu un élément contextuel en supprimant l’appel à node dans l’exécution du script.

⚠️
Interopérabilité Un fonctionnement différent sous Windows

Ce mécanisme n’est pas compris par le système d’exploitation Windows. Ce dernier utilise une surcouche qui serait trop longue à expliquer dans cet ouvrage.

Je recommande cependant de conserver le contexte d’exécution sous Windows car l’exécutable npm gère l’interopérabilité pour nous. Nous verrons comment dans la section suivante.

Le caractère ++ placé en début de ligne d’un script système signale une ligne placée en commentaire. C’est l’équivalent de +//+ en ECMAScript. Il existe un cas spécial : lorsque le caractère ++ est suivi d’un ! et lorsqu’il s’agit de la première ligne d’un fichier. Le contenu du commentaire est alors utilisé par le système d’exploitation pour déterminer quel programme utiliser pour interpréter le script. C’est ce qu’on appelle shebang.

Modifions le script de la section précédente pour ajouter le shebang :

bin/time-sh.js
#!/usr/bin/env node    <span data-bash-conum="1"></span>

'use strict';

const date = new Date();
...
  1. Le programme /usr/bin/env est exécuté avec un argument, node.

Le programme /usr/bin/env crée un nouvel environnement d’exécution et le reste du script est passé au programme référencé en argument – ici, node. Ce nouvel environnement dure le temps de l’exécution du script.

./bin/time-sh.js
Il est 13h42.

Le dernier effort à faire pour distribuer ce script exécutable de manière interopérable est de le lier à un module npm.

9.4. Faire le lien avec un module npm

Nous avons vu dans le chapitre 5 que Node utilisait la valeur main du fichier package.json pour déterminer quel script inclure en faisant require('<module>') ou import <module> from '<module>'.

Le champ bin est une transposition de main pour associer un script exécutable à notre module npm :

package.json
{
  "name": "nodebook.chapter-08",
  "bin": "examples/bin/time-sh.js",
  "...": "..."
}

Le moyen le plus simple pour tester l’intégration de l’exécutable avec notre système d’exploitation est de l'installer globalement. L’exécutable npm sait aussi installer un module à partir d’un chemin vers un répertoire contenant un fichier package.json :

npm install --global .

Par défaut, l’exécutable est disponible sous le nom du module en question, déclaré dans le champ name du fichier package.json :

nodebook.chapter-08
Il est 13h42.
💡
Pratique Un autre nom ou plusieurs exécutables

Le champ bin s’écrit sous forme d’un objet si vous souhaitez utiliser un autre nom que celui du module npm. La clé correspond au nom de l’exécutable tel qu’il sera utilisable sur le système, tandis que la valeur contient le chemin d’accès au script exécutable. Plusieurs exécutables sont alors installés si nous renseignons plusieurs clés et valeurs.

package.json
{
  "name": "nodebook.chapter-08",
  "bin": {
    "quelle-heure-est-il": "examples/bin/time-sh.js"
  }
}

L’installation de l’exécutable examples/bin/time-sh.js se fera sous le nom quelle-heure-est-il.

10. Du script au programme interactif

Nous avons appris à transformer un script Node ordinaire en un script exécutable et prêt à publier sur un registre npm (chapitre 5).

Cette section se focalise sur l’enrichissement d’un tel script pour en faire une application plus complète, interactive et robuste, de quoi se constituer un outillage sur mesure, partageable avec le reste de notre équipe et de l’écosystème de modules npm.

10.1. Utiliser des arguments et des options

Nous avons vu comment récupérer les arguments d’un script Node en découvrant le module process au chapitre 4. Pour rappel, la variable process.argv est un tableau qui contient tous les arguments passés au script principal :

options/intro.js
'use strict';

const args = process.argv.slice(2);
console.log(args);

Cela donne le résultat suivant quand nous lançons ce script dans un terminal :

node options/intro.js --country FR --fast
[ '--country', 'FR', '--fast' ]

Ce tableau est un peu “léger” car il se contente de retourner les arguments, sans compréhension de la logique recherchée. C’est à nous de dire que FR est une valeur associée à --country.

Dans le contexte des outils en ligne de commandes, les arguments et les options sont des paramètres qui sont interprétés par le programme pour contextualiser son action. Ils fonctionnent un peu comme des arguments de fonction et des paramètres d’URL.

L’enjeu des arguments et des options est de les transformer en une structure de données afin de les passer en tant que paramètres d’une fonction.

Tableau 1. Représentation des arguments et des options dans un outil en ligne de commandes, une fonction et une URL
Options Arguments

Fonction

prog({country: 'FR', 'fast': true})

prog('FR', 'fast')

Ligne de commande

prog --country FR --fast

prog FR fast

URL

prog/?country=FR&fast=true

prog/FR/fast

Nous allons utiliser le module npm minimist (npmjs.com/minimist) dans les exemples suivants. Il prend en charge la complexité de l’interprétation de process.argv et je le trouve robuste, bien testé et minimaliste.

💬
Histoire Au commencement était getopt

getopt (linux.die.net/man/3/getopt) est le programme Linux qui sert à l’analyse des arguments et des options. Les modules npm se calquent sur son modèle de fonctionnement.

Commençons par les options et voyons ce que minimist affiche :

node options/parse.js --country FR --fast
{ _: [], country: 'FR', fast: true }   
  1. La valeur _ contient les arguments d’exécution – nous y reviendrons.

Les options sont adaptées pour nommer des paramètres facultatifs dont l’ordre n’a pas d’importance, sous forme d’un “interrupteur” dans le cas d’un booléen ou d’une valeur – nombre ou chaîne de caractères, peu importe.

options/parse.js
'use strict';

const parse = require('minimist');
const args = parse(process.argv.slice(2));
console.log(args);

Les options s’écrivent sous une forme raccourcie (alias). Un alias réduit l’encombrement visuel et est signalé avec un seul tiret (au lieu de deux pour leur forme complète) :

node options/alias.js -c FR
{ _: [], c: 'FR', country: 'FR' }
options/alias.js
'use strict';

const parse = require('minimist');
const alias = { 'c': 'country' }; // (1)

const args = parse(process.argv.slice(2), { alias });
console.log(args);                // (2)
  1. Définition de l’option -c en tant qu’alias de --country.

  2. L’affichage représente à la fois la valeur de l’option et celle de l’alias.

La lecture est rendue plus difficile pour celui ou celle qui n’a pas consulté le manuel d’utilisation en détail – ce qui est souvent le cas, surtout pour des personnes qui découvrent un nouveau logiciel.

J’ai tendance à utiliser les alias pour les options principales ou importantes. Je privilégie la forme longue dans les exemples et dans la documentation, afin d’augmenter les chances de compréhension.

Les valeurs par défaut simplifient le paramétrage en rendant certaines valeurs implicites :

node options/defaults.js --fast
{ _: [], country: 'FR', fast: true }  
  1. La clé country affiche une valeur alors que nous ne l’avons pas spécifiée dans la commande.

L’enjeu réside donc dans l’utilisation des valeurs à bon escient, pour que le programme fasse ce qui est attendu d’un point de vue utilisateur. Le paramétrage est similaire à celui des alias.

options/defaults.js
'use strict';

const parse = require('minimist');
const options = {defaults: {country: 'FR'}};      // (1)

const args = parse(process.argv.slice(2), options);
console.log(args);
  1. L’option --country aura FR comme valeur par défaut.

Un autre concept utile est celui des types. Nous définissons explicitement nos attentes sur ce que telle ou telle option est censée recevoir :

node options/types.js --country --fast furious   
{ _: [ 'furious' ], fast: true, country: '' }
node options/types.js --country FR
{ _: [], fast: false, country: 'FR' }              
  1. Omission de la valeur de --option et tentative d’affectation de valeur à l’option booléenne --fast.

  2. Une option vaut implicitement false par défaut quand elle est typée comme booléenne.

L’exemple précédent renforce nos attentes : l’option --country sans valeur ne sera pas comprise comme un booléen et, au contraire, l’option booléenne --fast n’accepte pas de valeur – cette dernière est interprétée comme un argument.

options/types.js
'use strict';

const argv = process.argv.slice(2);
const parse = require('minimist');
const string = ['country'];
const boolean = ['fast'];

console.log(parse(argv, { string, boolean }));
Synthèse des différentes formes d’options
--country FR
-c FR

Un nombre ou une chaîne de caractères.

--country FR UK
--country FR --country UK

Un ou plusieurs nombre(s) ou chaîne(s) de caractères.

--fast
--no-fast
-f

Un booléen dont la valeur vaut true. La valeur vaut false quand l’option débute avec no-. C’est utile quand nous avons besoin d’activer ou de désactiver quelque chose.

--verbose --verbose
-vv

Certaines bibliothèques utilisent la répétition d’un booléen comme un compteur. Dans ce cas, la notation --verbose --verbose correspond à la valeur {verbose: 2}. C’est utile pour gérer la gradation d’une option comme la loquacité de l’affichage des logs.

Les arguments sont adaptés à des situations où des valeurs sont obligatoires, n’ont pas besoin d’être nommées et pour en accepter un nombre arbitraire. Si les valeurs ne correspondent pas à une liste, le positionnement des arguments est important car il détermine leur identification. Les arguments se prêtent particulièrement bien à exprimer une liste de fichiers ou d’identifiants.

node options/parse.js Europe/London
{ _: [ 'Europe/London' ] }

Le paramètre passé en argument est l’expression d’un fuseau horaire selon l’organisme de standardisation IANA (www.iana.org/time-zones). Une liste de fuseaux se trouve sur time.is/time_zones. L’idée est d’afficher l’heure courante selon le fuseau horaire donné en argument. C’est un paramètre obligatoire qu’il n’est pas nécessaire de nommer :

node options/timezone.js Europe/London
22:29
node options/timezone.js America/New_York
17:29
node options/timezone.js Indian/Antananarivo   
17/6/2018, 00:36
  1. Fuseau horaire de l’île de Madagascar.

Nous pouvons travailler avec les fuseaux horaires sans module additionnel, grâce à la fonctionnalité de formatage internationalisé (chapitre 3) :

options/timezone.js
'use strict';

const parse = require('minimist');
const args = parse(process.argv.slice(2));
const [timezone] = args._;                          // (1)

if (!timezone) {
  throw Error('Merci d\'indiquer un fuseau horaire :-)');
}

const options = {
  timeZone: timezone,
  hour: 'numeric', minute: 'numeric', hour12: false // (2)
};

const text = new Date().toLocaleDateString('fr-FR', options);
console.log(text);                                  // (3)
  1. Nous affectons à timezone la première valeur du tableau d’arguments.

  2. Configuration des préférences d’affichage de l’heure.

  3. Utilisation de la méthode toLocaleDateString() avec nos options pour afficher la date courante.

Cet exemple se transformerait de la manière suivante si nous souhaitions étendre l’affichage de l’heure à autant de fuseaux horaires que voulus :

node options/timezones.js Europe/London America/New_York
Europe/London : 22:29
America/New_York : 17:29
options/timezones.js
'use strict';

const parse = require('minimist');
const args = parse(process.argv.slice(2));
const timezones = args._;

const output = timezones.map(timeZone => {              // (1)
  const date = new Date().toLocaleDateString('fr-FR', {
    timeZone,
    hour: 'numeric', minute: 'numeric', hour12: false
  });
  return `${timeZone} : ${date}`;                       // (2)
});

console.log(output.join('\n'));                         // (3)
  1. Nous constituons un nouveau tableau en itérant sur chacun des arguments.

  2. Valeur de retour utilisée dans le nouveau tableau output (chapitre 3).

  3. Le tableau est joint pour constituer une chaîne de caractères sur plusieurs lignes.

Ce script accepte un nombre indéfini d’arguments et son temps d’exécution dépendra de la longueur de cette liste. Elle n’a d’ailleurs pas forcément à être connue à l’avance et les valeurs s’obtiennent dynamiquement, en les listant depuis un fichier, par exemple :

options/zones.txt
Europe/London
America/New_York

Le contenu du fichier s’obtient d’une traite en bash avec l’utilisation combinée de capture de valeur ($( )) et de l’opérateur de redirection < :

node options/timezones.js $(< options/zones.txt)
Europe/London : 22:29
America/New_York : 17:29

C’est minimal, mais le fichier doit être lu dans son intégralité et copié entièrement dans la mémoire de Node avant d’en faire quelque chose. Une approche plus économique est d’utiliser les flux de données (chapitre 4. Nous y reviendrons plus loin.

Synthèse des différentes formes d’arguments
prog [argument1, …]

L’ordre de placement étant important, les arguments facultatifs doivent être placés à droite des arguments obligatoires.

prog sous-commande [argument1, …]

C’est un cas particulier pour découper un programme complet en plusieurs domaines d’action. Le premier argument est alors utilisé comme identifiant d’action, avec ses propres arguments et options.

L’utilisation des sous-commandes est la bienvenue pour organiser des actions de manière indépendante, là où les choses deviendraient implicites et chaotiques avec les options. C’est l’équivalent d’une route dans une application web (chapitre 7).

Les commandes suivantes illustrent la génération de résultats aléatoires. Leur nature varie en fonction de la sous-commande employée ;

node options/random.js number
51151
node options/random.js words 2
streamline THX

Nous associons une fonction différente à chaque sous-commande pour renforcer cette notion d’actions indépendantes mais qui partagent les options et arguments du script.

options/random.js
const {random} = require('faker/locale/fr');
const args = require('minimist')(process.argv.slice(2));
const [action, ...actionArgs] = args._;               // (1)

const number = () => random.number();                 // (2)
const words = (count=5) => random.words(count);       // (3)
const log = (result) => console.log(result);

if (action === 'words')  log(words(...actionArgs));   // (4)
if (action === 'number') log(number());
  1. L’action est le premier argument ; le reste est accumulé dans le tableau actionArgs.

  2. Fonction qui retourne un nombre aléatoire.

  3. Fonction qui retourne un nombre défini de mots – 5 par défaut.

  4. Chaque argument qui suit la sous-commande correspondra ainsi à un argument de la fonction words.

L'exécutable npm est un exemple d’application qui repose sur des sous-commandes pour déterminer quelle action exécuter – installer un module, lancer une recherche ou encore initialiser un projet, entre autres.

Des applications en ligne de commande plus complexes soulèvent de nouveaux besoins pour éviter les effets de bord et pour faciliter la modularité de notre code : la validation des paramètres, une gestion plus fine des sous-commandes, ainsi que la génération automatique de la documentation. Nous y reviendrons dans la section “Utilisation d’un framework”.

10.2. Améliorer la lisibilité grâce aux couleurs

Les couleurs vont nous aider à faire ressortir, différencier et distinguer des éléments au sein d’une interface monochrome.

cli colors
Figure 1. Exemple d’utilisation de couleurs dans un terminal

L’alternance de texte coloré et monochrome a été créée à partir du script suivant :

colors/ansi.js
'use strict';

console.log('\x1B[31mHello\x1B[0m World');

Par défaut, tout caractère envoyé vers la console est affiché. Un mécanisme de commandes s’active en envoyant un caractère invisible suivi d’une série de symboles. Ces commandes changent la couleur du texte, sa couleur de fond, l’emphase, le soulignement et même la position du curseur.

💬
ANSI Structure d’une commande d’échappement

Le caractère d'échappement est un caractère invisible, ici représenté en hexadécimal par \x1B. Il existe sur nos claviers d’ordinateur : c’est la fameuse touche ECHAP ou ESC !
Une commande suit le caractère [ jusqu’au caractère m. Les commandes composées utilisent le caractère ; comme séparateur.

Pour résumer, une commande a la forme <ECHAP>[<commande>m ou <ECHAP>[<commande;commande;…​>m.

Pour en savoir plus à propos de cette syntaxe, je vous recommande la lecture de la page Wikipédia suivante :

Les huit couleurs principales et les huit couleurs vives s’affichent dans à peu près tous les terminaux. Cela vaut aussi pour leur utilisation en couleur de fond.

cli colors all
Figure 2. Affichage des huit couleurs principales et des huit couleurs vives
colors/all.js
'use strict';

const colors = [30, 31, 32, 33, 34, 35, 36, 37];
const brightColors = [90, 91, 92, 93, 94, 95, 96, 97];

[...colors, ...brightColors].forEach(code => {
  const color = `\x1B[${code}m`;            // (1)
  const bgColor = `\x1B[30;${code + 10}m`;  // (2)
  const reset = '\x1B[0m';                  // (3)

  console.log(`${color}Hello ${bgColor}World${reset}`);
});
  1. Couleur d’affichage du texte.

  2. Couleur de fond du texte tandis que les caractères sont en noir (30).

  3. Remise à zéro de tous les styles.

💬
Compatibilité Combien de couleurs dans mon terminal ?

Les terminaux proposent un nombre limité de couleurs, au moins 8, en majorité 256 et parfois plusieurs millions selon le logiciel utilisé. Le programme Linux et macOS tput fournit des informations à propos du terminal, dont le nombre de couleurs :

tput colors
256

Un autre type de commande sélectionne dans une palette de 256 couleurs mais aussi dans la palette RGB (Red Green Blue, Rouge Vert Bleu). Ces commandes débutent par [38;5 et [38;2, respectivement :

colors/palette.js
'use strict';

console.log('\x1B[38;5;213mHello\x1B[0m World');      // (1)

console.log('\x1B[38;2;255;69;0mHello\x1B[0m World'); // (2)
  1. Utilisation de la palette 8 bits (256 couleurs) - 213 est un rose clair.

  2. Utilisation de la palette RGB (millions de couleurs) – 255,69,0 correspond au orange.

Le module npm chalk (npmjs.com/chalk) facilite l’utilisation des codes ANSI en leur donnant des libellés mémorisables, en gérant la compatibilité du nombre des couleurs supportées par le terminal voire en désactivant les couleurs si nécessaire.

colors/chalk.js
'use strict';

const {magentaBright, green, bgRed} = require('chalk');

console.log(`${magentaBright('Hello')} World`); // (1)
console.log(`${green.italic('Hello')} World`);  // (2)
console.log(`${bgRed('Hello')} World`);         // (3)
  1. Utilisation de la couleur vive magenta.

  2. Utilisation de la couleur verte et de l’italique.

  3. Utilisation de la couleur de fond rouge.

Le module s’utilise aussi comme modèle avec les guillemets obliques, ce qui augmente encore plus la clarté d’affichage.

colors/chalk-literal.js
'use strict';

const chalk = require('chalk');

console.log(chalk`{magentaBright Hello} World`);
console.log(chalk`{green.italic Hello} World`);
console.log(chalk`{bgRed Hello} World`);

console.log(chalk`{rgb(255,69,0) Hello} World`);

10.3. Demander une série d’informations

Les programmes interactifs demandent des informations de manière guidée à la personne utilisant l’exécutable, en plus des arguments et options. Poser une série de questions est une manière d’accompagner une personne dans une décision, d’exposer des choix dynamiques et de réduire les erreurs dans la création de fichiers, par exemple.

node prompt/intro.js
Quel est ton nom ? Thomas
Coucou Thomas !

Le module readline proposé par Node est peu connu. Il sert à transformer les flux d’entrée et de sortie (chapitre 4. Le système de curseur sait mettre en pause, revenir en arrière et effacer tout ou une partie d’une ligne, entre autres. Ce module sait aussi poser des questions (!) :

prompt/intro.js
'use strict';

const readline = require('readline');
const rl = readline.createInterface({             // (1)
  input: process.stdin,
  output: process.stdout
});

rl.question('Quel est ton nom ? ', (answer) => {  // (2)
  console.log(`Coucou ${answer} !`);

  rl.close();                                     // (3)
});
  1. Création de l’interface qui s’intercale entre les flux d’entrée et de sortie – ici, l’entrée et la sortie standard.

  2. La fonction de rappel est invoquée dès que l’utilisateur a saisi sa réponse.

  3. La méthode close() stoppe l’interface et rend la main au script – sans cet appel le script tournerait indéfiniment.

Nous allons utiliser le module npm inquirer (npmjs.com/inquirer) dans la suite de cette section. Il se base sur readline et simplifie grandement l’interactivité : question, liste à choix unique navigable au clavier, boîte à cocher, confirmation, validation de réponse et exécution conditionnelle, si telle ou telle question contient les valeurs qui nous intéressent, par exemple.

L’exemple précédent peut être réécrit de la sorte :

prompt/question.js
'use strict';

const {prompt} = require('inquirer');

const questions = [
  { name: 'name', message : 'Quel est ton nom ?' }, // (1)
];

prompt(questions).then(answers => {                 // (2)
  console.log(`Coucou ${answers.name} !`);          // (3)
});
  1. La question est créée avec l’identifiant name – cela facilite l’utilisation des réponses.

  2. Les résultats sont retournés dans une promesse – c’est plus élégant et pratique à gérer qu’une fonction de rappel.

  3. Les valeurs des réponses s’obtiennent grâce à leurs identifiants.

Ce n’est pas beaucoup plus compliqué de poser plusieurs questions et de les personnaliser avec les réponses aux précédentes questions :

node prompt/questions.js
? Quel est ton nom ? Thomas
? Quel âge as-tu Thomas ? 35
Thomas, tu as 35 ans.
prompt/questions.js
'use strict';

const {prompt} = require('inquirer');

const questions = [
  { name: 'name', message : 'Quel est ton nom ?' },
  { name: 'age', message: (answers) => {            // (1)
    return `Quel âge as-tu ${answers.name} ?`;      // (2)
  }}
];

prompt(questions).then(answers => {
  console.log(`${answers.name}, tu as ${answers.age} ans.`);
});
  1. La valeur de message peut être une fonction à laquelle est passée l’intégralité des réponses aux questions précédentes.

  2. La valeur d’une réponse s’obtient au travers de son identifiant.

Dans cet exemple, rien ne nous empêchait de saisir autre chose qu’un nombre dans la question de l’âge. C’est gênant, mais cela se résout à l’aide de la propriété de configuration validate.

node prompt/validate.js
? Devine le nombre secret (entre 1 et 100): trois
>> Ce n'est pas un nombre
? Devine le nombre secret (entre 1 et 100): 10
>> C'est plus petit.
? Devine le nombre secret (entre 1 et 100): 5
Bravo, la réponse est 5 !

Cette fonction est appelée à chaque tentative de réponse. La question est inlassablement posée tant que la fonction validate retourne autre chose que le booléen true :

prompt/validate.js
'use strict';

const {prompt} = require('inquirer');
const secret_number = Math.floor(Math.random() * 100);

const questions = [
  { name: 'Devine le nombre secret (entre 1 et 100)',
    validate: (input, answers) => {
      if (Number.isNaN(parseInt(input))) {    // (1)
        return 'Ce n\'est pas un nombre';     // (2)
      }
      if (input > secret_number) {
        return 'C\'est plus petit.';
      }
      if (input < secret_number) {
        return 'C\'est plus grand.';
      }
      return true;                            // (3)
  }}
];

prompt(questions).then(answers => {
  console.log(`Bravo, la réponse est ${secret_number} !`);
});
  1. Nous nous assurons que la valeur saisie est assimilée à un nombre.

  2. Sinon, nous retournons un texte utilisé comme message d’erreur.

  3. Cette condition remplie, le module passera à la question suivante.

Enfin, deux modes de liste sont proposés par le module inquirer : list et checkbox. Dans le premier cas, nous naviguons au clavier pour sélectionner une seule réponse. Dans le deuxième, nous naviguons au clavier et sélectionnons les choix à l’aide de la touche Espace :

node prompt/list.js
? Tu fais quoi lundi ?
  Je quitte mon job
❯ Je pars en vacances
  J'apprends Node.js
prompt/list.js
'use strict';

const {prompt} = require('inquirer');

prompt([
  { name: 'type',
    type: 'list',                     // (1)
    message : 'Tu fais quoi lundi ?',
    choices: [                        // (2)
      'Je quitte mon job',
      'Je pars en vacances',
      'J\'apprends Node.js'
    ]
  }
]);
  1. Le champ type sert à expliciter la nature de la question – l’interface utilisateur s’adapte.

  2. Le champ choices contient la liste des choix proposés à l’écran.

Le potentiel de combinaison de ces éléments est vraiment intéressant. Nous pourrions créer des quiz, des interfaces de recherche ou faciliter la création de fichiers de configuration sans avoir à mettre les mains dans le cambouis. Les choix mis à disposition dans l’interface se créent aussi dynamiquement, à partir de données obtenues depuis une ressource distante – ils n’est pas nécessaire de les écrire en dur dans le code.

10.4. Informer de la progression

Je trouve que transmettre un feedback est un élément différenciant dans la conception d’une application – qu’elle soit utilisée sur le Web ou dans un terminal. Informer de la progression est un des moyens d’y parvenir. La progression concerne aussi bien l’indication du franchissement d’étapes que celle de pourcentage d’accomplissement d’une tâche.

node progress/intro.js
(•··) Un-deux-trois
(••·) Un-deux-trois
(•••) Un-deux-trois ☀️

Cette approche est une première tentative d’indiquer une progression à travers deux mécanismes : le passage du vide au plein en utilisant un caractère qui véhicule ce changement et l’utilisation d’un signal visuel pour informer de l’accomplissement de la tâche.

progress/intro.js
'use strict';

let counter = 0;

const display = (count) => {
  const progress = '•'.repeat(count).padEnd(3, '·');  // (2)
  const sun = count === 3 ? '☀️' : '';

  console.log(`(${progress}) Un-deux-trois ${sun}`);  // (3)
}

setInterval(() => {
  counter++;
  display(counter);                   // (1)

  if (counter === 3) process.exit(0);
}, 1000);
  1. Cette fonction affiche la progression toutes les secondes.

  2. La méthode padEnd() (chapitre 3) complète la barre de progression jusqu’à atteindre le nombre souhaité de points.

  3. Le message de progression est paramétré pour afficher les éléments nécessaires – l’émoji “soleil” s’affiche quand le compteur atteint 3.

💡
Performance La vitesse est toute relative

Une action qui est instantanée sur notre ordinateur peut durer plusieurs secondes sur un autre, du fait de ressources moindres ou d’un accès réseau moins favorable par exemple.

La seule chose qu’il manque à mon goût est d’avoir un réel sens de progression, c’est-à-dire une actualisation du contenu qui informe de l’avancement.

Nous avons parlé des commandes ANSI pour changer les couleurs. Il se trouve que certaines de ces commandes contrôlent aussi la position du curseur. Ainsi, au lieu d’écrire à la suite, nous pouvons revenir en arrière et même effacer le contenu d’une ligne.

cli dots
Figure 3. Pendant et après la progression d’un script Node

Il n’y a pas beaucoup de code à changer dans l’exemple précédent pour y parvenir.

progress/dots.js
'use strict';

let counter = 0;

const display = (count) => {
  const dots = '•'.repeat(count).padEnd(3, '·');
  const sun = count === 3 ? '☀️' : '';
  const cmd = counter !== 1 ? '\x1B[1F' : '';           // (1)

  console.log(`${cmd}(${dots}) Un-deux-trois ${sun}`);  // (2)
}

setInterval(() => {
  counter++;
  display(counter);

  if (counter === 3) process.exit(0);
}, 1000);
  1. La séquence d’échappement \x1B suivie de la commande 1F déplace le curseur d’une ligne vers le haut.

  2. Cette séquence est ajoutée en début de ligne remonter d’une ligne avant d’écrire la suite des caractères.

Tableau 2. Liste de commandes ANSI pour déplacer le curseur
Commande Effet

<n>E

Descend le curseur de <n> lignes.

<n>F

Remonte le curseur de <n> lignes.

<n>K

Efface le contenu de la ligne, jusqu’à la fin si <n> vaut 0, jusqu’au début si <n> vaut 1, entièrement si <n> vaut 2.

<n>A

Déplace le curseur de <n> cases vers le haut.

<n>B

Déplace le curseur de <n> cases vers le bas.

<n>C

Déplace le curseur de <n> cases vers la droite.

<n>D

Déplace le curseur de <n> cases vers la gauche.

À partir de là, nous sommes libres d’écrire nos propres barres de progression, des indicateurs d’activité et de ne garder à l’écran que les informations reflétant l’état actuel de l’application.

Deux modules npm simplifient la vie quand on n’apprécie pas trop les commandes ANSI :

  • ansi-escapes (npmjs.com/ansi-escapes) est l’équivalent de chalk mais pour déplacer le curseur. L’utilisation de méthodes nommées remplace celle des commandes ANSI.

  • progress-string (npmjs.com/progress-string) met à disposition une base pour afficher des barres de progression avec un minimum d’options.

Terminons cette section avec l’utilisation d’un indicateur d’activité et une information de réussite ou non de notre action. Nous nous aidons du module npm ora (npmjs.com/ora). Il est rapide à configurer et propose une palette intéressante d’animations.

cli spinner
Figure 4. Pendant et après la progression avec le module ora

Nous nous retrouvons à enlever encore quelques lignes par rapport aux exemples précédents :

progress/spinner.js
'use strict';

const ora = require('ora');
let counter = 0;
const progress = ora().start('Un-deux-trois…'); // (1)

setInterval(() => {
  counter++;
  if (counter === 3) {
    progress.succeed('Un-deux-trois… Soleil !');// (2)
    process.exit(0);
  }
}, 1000);
  1. Crée et affiche un indicateur de progression animé.

  2. Nous indiquons que la tâche est terminée – l’animation est remplacée par une marque de succès.

Nous pouvons aussi tirer parti du fonctionnement du module ora en reproduisant l’animation et en personnalisant le symbole de réussite.

cli spinner custom
Figure 5. Pendant et après l’indicateur de progression personnalisé avec le module ora
progress/spinner-custom.js
'use strict';

const ora = require('ora');
const progress = ora({
  color: 'yellow',
  spinner: {
    frames: ['···', '•··', '••·', '•••'], // (1)
    interval: 1000                        // (2)
  }
});

progress.start('Un-deux-trois…');
setTimeout(() => {
  progress.stopAndPersist({ symbol: '☀️', text: 'Soleil !' });
  process.exit(0);
}, 4000);
  1. Création d’une série d’éléments d’animation.

  2. Nous passons d’un index à l’autre à la vitesse exprimée en millisecondes.

10.5. Afficher des informations sous forme de tableau

L’affichage d’informations sous forme de tableau est idéal pour le confort de lecture de listes. Notre lecture gagne en qualité quand notre vision s’attend à retrouver une structure prédictible.

node table.js
╔═════════════════════════════╤════════════════╗
║ Titre                       │ ISBN           ║
╟─────────────────────────────┼────────────────╢
║ Node.js                     │ 978-2212139938 ║
╟─────────────────────────────┼────────────────╢
║ Sass pour les web designers │ 978-2212141474 ║
╟─────────────────────────────┼────────────────╢
║ Design Systems              │ 978-3945749586 ║
╚═════════════════════════════╧════════════════╝

Cet exemple est généré à l’aide du module npm table (npmjs.com/table). Il s’utilise sans configuration pour démarrer. Il a la capacité de tronquer et limiter la largeur des colonnes, mais aussi de gérer l’alignement des contenus dans les cellules.

table.js
'use strict';

const {table} = require('table');
const {bold, green:g} = require('chalk');

const data = [
  [bold('Titre'), bold('ISBN')],      // (1)
  [g('Node.js'), '978-2212139938'],   // (2)
  [g('Sass pour les web designers'), '978-2212141474'],
  [g('Design Systems'), '978-3945749586'],
];

console.log(table(data));             // (3)
  1. L’en-tête du tableau se distingue grâce à un style différent – du texte en gras.

  2. Chaque ligne du tableau est elle-même un tableau, à deux colonnes dans ce cas de figure.

  3. L’affichage se fait sur un simple appel de fonction – table calcule la largeur des colonnes pour nous.

💡
Pratique Utilisation des couleurs

La lecture de la section “améliorer la lisibilité grâce aux couleurs” vous aidera à améliorer la lisibilité de vos tableaux.

Il est à noter que le module table expose aussi une interface en flux, pour ajouter des lignes au fur et à mesure – par exemple en cas de lecture continue ou sur un fichier volumineux.

10.6. Inviter à mettre à jour le module

L’inconvénient d'installer un module exécutable, c’est la difficulté de savoir si une mise à jour intéressante a été publiée (probablement parce que je ne pense pas à lancer la commande npm outdated --global). Il existe toutefois des moyens de signaler aux personnes qui utilisent votre exécutable qu’une version plus récente existe.

J’aime l’approche minimaliste du module npm update-check (npmjs.com/update-check). Il compare le numéro de version passé en paramètre avec celui de la dernière version de ce même module, sur le registre npm.

node update/intro.js
{ latest: '0.10.0', fromCache: false }

Dans cet exemple, update-check interroge le registre npm pour déterminer la version la plus récente de nodebook. S’il estime que la version installée localement est plus ancienne, il retourne un objet avec le numéro de version à installer.

update/intro.js
'use strict';

const check = require('update-check');
const pkg = { name: 'nodebook', version: '0.8.0' }; // (1)

check(pkg)                              // (2)
  .then(update => console.log(update)); // (3)
  1. Les champs name et version suffisent à accomplir la comparaison.

  2. Démarrage de la comparaison.

  3. Un objet est retourné en cas de version plus récente ; sinon, c’est la valeur null.

En temps normal, c’est-à-dire dans le cas d’un module dont nous sommes à l’origine et que nous publions sur le registre npm, nous aurions tendance à utiliser le contenu du fichier package.json en argument de la fonction check.

Maintenant, nous pouvons présenter une information plus digeste et actionnable à la personne qui utilise notre module :

node update/cli.js
nodebook@0.10.0 est dispo
Tape 'npm install --global nodebook'
update/cli.js
'use strict';

const parse = require('minimist');
const check = require('update-check');
const pkg = { name: 'nodebook', version: '0.8.0' };

const logError = ({message}) => console.error(message);
const checkUpdate = (update) => {
  if (update) {
    const {name} = pkg;
    console.log(`${name}@${update.latest} est dispo`);
    console.log(`Tape 'npm install -g ${pkg.name}'`);
  }
};

check(pkg)
  .then(checkUpdate, logError)      // (1)
  .then(() => {
    const args = parse(process.argv.slice(2));
    // ...                          // (2)
  });
  1. Nous vérifions et affichons la mise à jour si nécessaire ; nous gérons aussi une erreur (par exemple, réseau indisponible ou registre HS).

  2. Nous gérons ensuite le code de notre exécutable – parsing des arguments, gestion des actions, etc.

Les utilisatrices et utilisateurs sont informés de la disponibilité d’une mise à jour dès qu’elles se servenrt du module exécutable en question. La décision d’actualiser leur appartient toutefois.

11. Vers un code réutilisable et testable

Nous avons appris comment transformer un script Node en un programme paramétrable, clair et agréable à utiliser. Cette section a pour but de renforcer la robustesse de notre code. Nous allons tout d’abord séparer ce qui est réutilisable de l’interface en ligne de commande pour progressivement tester notre code, puis l’exécutable lui-même.

Notre progression va s’effectuer en transformant l’exemple options/timezone.js de la section “Utiliser des arguments et des options”.

options/timezone.js
'use strict';

const parse = require('minimist');
const args = parse(process.argv.slice(2));
const [timezone] = args._;                          // (1)

if (!timezone) {
  throw Error('Merci d\'indiquer un fuseau horaire :-)');
}

const options = {
  timeZone: timezone,
  hour: 'numeric', minute: 'numeric', hour12: false // (2)
};

const text = new Date().toLocaleDateString('fr-FR', options);
console.log(text);                                  // (3)
  1. Nous affectons à timezone la première valeur du tableau d’arguments.

  2. Configuration des préférences d’affichage de l’heure.

  3. Utilisation de la méthode toLocaleDateString() avec nos options pour afficher la date courante.

11.1. Modulariser le code du fichier exécutable

Un programme qui accepte beaucoup d’options devient de plus en plus compliqué à maintenir car il est difficile de tester tous les cas de figure à la main.

Un programme exécutable robuste est un script qui contient le moins possible de code spécifique à la gestion de la ligne de commandes. Pour ce faire, nous allons séparer la logique d’exécution en la plaçant dans une fonction, dans un autre module.

testing/01/cli.js
'use strict';

const getTime = require('./lib.js');
const args = require('minimist')(process.argv.slice(2));
const [timezone] = args._;

console.log(getTime(timezone));  // (1)
  1. Tout le code a été modularisé sous forme d’une seule fonction.

Nous avons opéré un changement : l’utilisation de console.log() pour l’affichage des résultats revient du côté de l’exécutable.

Notre code est portable s’il retourne un résultat et en laissant la responsabilité de l’affichage au code le plus proche de l’utilisateur – c’est le cas ici avec la fonction getTime().

testing/01/lib.js
'use strict';

module.exports = (timezone) => {
  if (!timezone) {
    throw Error('Merci d\'indiquer un fuseau horaire :-)');
  }

  const options = {
    timeZone: timezone,
    hour: 'numeric', minute: 'numeric', hour12: false
  };

  return new Date().toLocaleDateString('fr-FR', options);
}

Cette écriture facilite l’écriture de tests. Justement, parlons-en.

11.2. Tester le code partagé

L’écriture de tests nous aide à découvrir qu’une modification produit un résultat différent de celui attendu. Les tests documentent aussi les cas à la marge de notre code. En général, dès qu’il y a un if …​ else, cela implique d’écrire au moins une nouvelle assertion.

💬
Glossaire Test et assertion

Une assertion est l’expression d’une attente quant au fonctionnement de notre code.

Un test s’applique à une fonction et utilise une à plusieurs assertion(s) pour couvrir le spectre de ses fonctionnalités.

Pour des besoins simples, j’utilise le module npm tape (npmjs.com/tape). Il gère l’exécution des tests et il fournit quelques méthodes pour exprimer nos attentes – les assertions.

node testing/01/lib.test.js
TAP version 13
getTime
ok 1 should be truthy
ok 2 should throw

1..2
tests 2
pass  2

ok

Dans ce cas précis, j’ai écrit deux assertions qui illustrent les différents cas de figure représentés dans le fichier 01/lib.js – quand l’erreur est provoquée et quand le résultat est retourné :

testing/01/lib.test.js
'use strict';

const test = require('tape');
const getTime = require('./lib.js');

test('getTime', t => {                          // (1)
  t.plan(2);                                    // (2)

  t.ok(getTime('Europe/Paris'));                // (3)
  t.throws(() => getTime(), /fuseau horaire/);  // (4)
});
  1. Création d’un test – le paramètre t contient les méthodes d’assertion.

  2. La méthode t.plan() spécifie le nombre d’assertions attendues – si ce nombre n’est pas atteint, tape considère qu’il y a un problème.

  3. Assertion qui teste que la fonction getTime() retourne bien un résultat.

  4. Assertion qui teste que la fonction getTime() provoque une erreur si aucun argument n’est passé.

Nous sommes parés à toute éventualité, mais en y réfléchissant, le module cli.js ne gère pas vraiment le cas où aucun argument n’est transmis.

node testing/01/cli.js
/…/chapter-08/examples/testing/01/lib.js:5
    throw Error('Merci d\'indiquer un fuseau horaire :-)');
    ^

Error: Merci d'indiquer un fuseau horaire :-)
    at module.exports (/…/examples/testing/01/lib.js:5:11)
    at Object. (/…/examples/testing/01/cli.js:7:13)
    at Module._compile (internal/modules/cjs/loader.js:702:30)
    at Object.Module._extensions..js (…/cjs/loader.js:713:10)
    …

Je trouve peu élégant d’être accueilli·e avec une trace d’erreur comme celle-ci. L’erreur mériterait d’être présentée en contexte, celui de notre utilisation et non celui des rouages internes de Node.

11.3. Présenter les messages en contexte

La présentation des messages de réussite et d’erreur nécessite d’être à l’écoute des signaux envoyés par notre code afin de le restituer de manière adaptée.

node testing/02/cli.js
Merci d'indiquer un fuseau horaire :-)

Afin de parvenir à ce résultat, j’ai opté pour l’utilisation de promesse (chapitre 3). Je trouve cette méthode plus élégante car nous gérons le résultat et le message d’erreur d’une manière visuellement similaire, mais séparée.

testing/02/cli.js
'use strict';

const getTime = require('./lib.js');
const args = require('minimist')(process.argv.slice(2));
const [timezone] = args._;

getTime(timezone)
  .then(time => console.log(time))  // (1)
  .catch(error => {
    console.error(error.message);   // (2)
    process.exit(1);                // (3)
  });
  1. Le résultat est affiché en cas de succès.

  2. En cas d’erreur produite dans la fonction getTime(), nous affichons le message en question.

  3. Nous arrêtons le programme avec un code d’erreur pour la signaler au niveau du système d’exploitation.

Ce changement n’implique pas de bouleversement dans notre code. L’appel à throw est remplacé par reject() et le return se transforme en resolve().

testing/02/lib.js
'use strict';

module.exports = (timezone) => {
  return new Promise((resolve, reject) => { // (1)
    if (!timezone) {
      reject(Error('Merci d\'indiquer un fuseau horaire :-)'));
    }

    const options = {
      timeZone: timezone,
      hour: 'numeric', minute: 'numeric', hour12: false
    };

    resolve(new Date().toLocaleDateString('fr-FR', options));
  });
}
  1. Nous englobons le contenu entier de la fonction dans une promesse – en cas de problème à un endroit imprévu, il sera remonté et pris en charge de la même manière que notre rejet explicite.

Nous n’avons plus à modifier le fichier cli.js pour gérer de nouveaux messages d’erreur. Nous pourrions mieux gérer certains cas de figure, par exemple quand un fuseau horaire inconnu est spécifié.

node testing/02/cli.js Brexit/London
Unsupported time zone specified Brexit/London

11.4. Tester l’exécutable

Tester l’exécutable est un moyen de vérifier que les câblages entre notre exécutable et notre mode Node sont bien faits. L’idée n’est pas de tester à nouveau les mêmes aspects du code, mais bel et bien de nous assurer que les conditions d’utilisation du programme sont remplies.

node testing/02/cli.test.js
TAP version 13
cli w/o arg
ok 1 exit code matched
cli w/ arg
ok 2 matched /\d{2}:\d{2}/
ok 3 exit code matched

1..3
tests 3
pass  3

ok

Nous avons à nouveau recours au module tape. Cette fois-ci, nous l’accompagnons d’un autre module, tape-spawn (npmjs.com/tape-spawn). Ce dernier intègre à tape le test de processus externes. En l’occurrence ici, cela concerne nos exécutables en ligne de commandes.

testing/02/cli.test.js
'use strict';

const test = require('tape');
const spawn = require('tape-spawn');
const opts = {cwd: __dirname};

test('cli w/o arg', t => {
  const proc = spawn(t, './cli.js', opts);  // (1)
  proc.exitCode(1);                         // (2)
  proc.end();
});

test('cli w/ arg', t => {
  const proc = spawn(t, './cli.js Europe/Paris', opts);
  proc.exitCode(0);                         
  proc.stdout.match(/\d{2}:\d{2}/);         // (3)
  proc.end();
});
  1. Nous démarrons un nouveau processus – ici, nous spécifions que le chemin d’accès est relatif au répertoire du script de tests.

  2. Nous testons le code de sortie du programme – c’est cohérent avec l’invocation de process.exit(1) dans cli.js.

  3. En cas de réussite, nous avons bien une heure qui s’affiche dans la sortie standard.

L’utilisation conjointe des tests unitaires et de ceux de l’exécutable nous permet de gagner en confiance dans la robustesse de notre code, de déceler de nouveaux cas à la marge et de nous rendre compte de certaines incohérences d’interface utilisateur.

11.5. Documenter notre programme

La documentation d’un logiciel est aussi importante que son code. C’est le premier élément qui donne une idée de la simplicité ou de la complexité d’utilisation d’un logiciel, de ce qu’il est possible de faire avec et des concepts qui s’y rapportent.

Le premier endroit où documenter son application est dans l’exécutable lui-même, avec l’option --help – ou son raccourci -h. C’est une convention pour afficher une aide synthétique, rapide d’accès et facile à comprendre.

node application/intro.js --help

Options:
  --help     Affiche de l'aide             [booléen]
  --version  Affiche le numéro de version  [booléen]
  --utc, -u                                [booléen]

L’aide proposée ici est minimale et n’indique que trop peu l’intention du programme. Nous n’avons aucune idée de l’effet de telle ou telle option, du résultat qui va se produire ou encore des valeurs acceptées par le programme.

Une aide qui me rassure et m’informe ressemble plutôt à ce qui suit :

node help/time.js --help
Affiche l'heure courante d'ici ou d'ailleurs.

Commandes:
  time.js timezones  Affiche les fuseaux horaires IANA.

Options:
  --version   Affiche le numéro de version            [booléen]
  --utc       Utilise le fuseau horaire universel.    [booléen]
  --timezone  Précise le fuseau horaire au format IANA.
  --help      Affiche de l'aide                       [booléen]

Exemples:
  time.js --utc                    Heure universelle.
  time.js --timezone=Europe/Lisbon Heure de Lisbonne.

Elle véhicule l'intention du programme, des exemples qui précisent un concept que je ne connais pas (fuseau horaire IANA) et j’y découvre même l’existence d’une commande qui liste ces fameux fuseaux horaires. Au premier coup d’œil, je ne décèle pas d’impasse et j’y vois plutôt une invitation à essayer sans appréhension.

Cette aide a été générée automatiquement en utilisant le framework yargs – nous en parlerons dans la section suivante. Ses méthodes .usage() et .example() ainsi que la propriété description de chaque option suffisent à constituer un affichage clair et informatif.

help/time.js
'use strict';

const args = require('yargs')
  .usage('Affiche l\'heure courante d\'ici ou d\'ailleurs.')
  .example('$0 --utc', 'Heure universelle.')            // (1)
  .example('$0 --timezone=Europe/Lisbon', 'Heure de Lisbonne.')
  .command('timezones', 'Affiche les fuseaux horaires IANA.')
  .option('utc', {
    type: 'boolean',                                    // (2)
    description: 'Utilise le fuseau horaire universel.' // (3)
  })
  .option('timezone', {
    description: 'Précise le fuseau horaire au format IANA.'
  })
  .locale('fr')
  .argv;

console.log(args);
  1. Documente un exemple d’utilisation et une description qui précisent l’intention.

  2. Le type de l’option est rendu explicite lors de l’affichage de la documentation.

  3. La description précise l’intention de l’option pour mieux comprendre son effet si elle est activée.

💬
Alternative module npm help-version

Le module help-version (npmjs.com/help-version) gère l’appel à l’option --help et il est indépendant de tout autre module.

Le seul risque est d’avoir une documentation qui est désynchronisée du fonctionnement du programme.

Un autre lieu courant pour documenter un projet est le README. C’est un fichier texte souvent affiché en premier sur la page d’accueil d’un projet logiciel. Sa mise en forme s’améliore avec l’utilisation d’une syntaxe de balisage léger comme Markdown.

Ce type de fichier est très largement suffisant pour documenter un projet. C’est l’équivalent de l’option --help, à l’échelle de l’application – son intention générale, sa compatibilité de version avec Node, comment l’installer, où poser des questions et peut-être même davantage d’exemples pour mieux comprendre ce qui ne tiendrait pas dans l’option --help.

Je trouve que le README est un excellent endroit pour reporter l’affichage de la commande --help.

documentation readme
Figure 6. Présentation de la documentation d’un exécutable dans un README

Nul besoin de faire compliqué pour véhiculer autant d’informations essentielles avec un minimum d’efforts. Démarrer un projet en écrivant cette documentation est un excellent moyen pour travailler à la clarification de ses idées et pour commencer à imaginer la forme que va prendre l'écriture des tests.

12. Pour aller plus loin

Dans les précédentes sections, nous avons vu des composantes essentielles pour créer un programme exécutable clair et fonctionnel. Cette dernière section va se focaliser sur des concepts qui structurent, simplifient et rapprochent un exécutable en ligne de commande d’une application web ou front-end.

12.1. Utilisation d’un framework d’application en ligne de commande

Une application en ligne de commande dont le nombre de lignes de code augmente devient de plus en plus complexe à maintenir. Potentiellement, l’expérience d’utilisation se dégrade aussi. Il faut continuer à prendre soin de la cohérence des arguments, des options et de valider que ce sont les valeurs attendues. Cette complexité appelle aussi à afficher une aide à la demande ou de manière contextuelle, par exemple au niveau de la sous-commande.

Des modules npm comme yargs (npmjs.com/yargs) aident à structurer la création d’applications en ligne de commande en intégrant la majorité des fonctionnalités vues dans la section “Du script au programme interactif”. La génération de l’aide, le parsing d’arguments et d’options et leur validation sont connectés ensemble, de manière transparente.

Ce genre de module est intéressant à utiliser quand l’assemblage d’autres modules indépendants rend le programme trop fragile et trop compliqué à tester.

node application/intro.js --help
Options:
  --version  Affiche le numéro de version      [booléen]
  --utc, -u                                    [booléen]
  --help     Affiche de l'aide                 [booléen]

La commande d’aide est générée automatiquement à partir de la définition d’arguments et d’options.

node application/intro.js
{ _: [],
  version: false,
  utc: false,
  u: false,
  help: false,
  '$0': 'application/intro.js' }
application/intro.js
'use strict';

const yargs = require('yargs');
const args = yargs
  .option('utc', {    // (1)
    alias: 'u',
    type: 'boolean',
  })
  .locale('fr')       // (2)
  .argv;              // (3)

console.log(args);
  1. Création de l’option --utc avec l’alias -u, de type booléen.

  2. Les messages générés par l’application seront en français – sans cette option, la langue s’adapte à celle du système d’exploitation.

  3. Applique les règles précédentes aux éléments contenus dans process.argv et retourne un résultat sous forme d’objet.

Le mécanisme de coercition définit la règle de transformation d’une option vers une autre représentation, plus pratique à utiliser. C’est le cas des dates par exemple : nous les recevons sous forme de chaîne de caractères alors qu’il serait plus facile de travailler avec des objets Date (chapitre 3).

node application/coerce.js --help
Options:
  --help     Affiche de l'aide               [booléen]
  --version  Affiche le numéro de version    [booléen]
  --date               [chaîne de caractère] [défaut: now]

Par défaut, la date est calée sur l’instant présent :

node application/coerce.js
2018-06-21T08:41:23.091Z

Une date peut même être incomplète :

node application/coerce.js --date 2018-03-24
2018-03-24T00:00:00.000Z
application/coerce.js
'use strict';

const yargs = require('yargs');

const args = yargs
  .locale('fr')
  .option('date', {
    type: 'string',
    default: new Date(),              // (1)
    defaultDescription: 'now',        // (2)
    coerce: input => new Date(input)  // (3)
  })
  .argv;

console.log(args.date.toISOString())
  1. La valeur par défaut de l’option --date est un objet Date.

  2. Ce réglage personnalise l’affichage de la valeur par défaut dans la zone d’aide – sans cela, la date complète serait affichée, ce qui est peu élégant et moins informatif.

  3. Cette fonction s’assure que toute valeur passée en option est transformée en objet Date.

Voici un autre exemple du mécanisme de coercition, cette fois-ci appliqué à un chemin d’accès de fichier :

node application/coerce-file.js --json-file ../package.json
{ name: 'nodebook.chapter-08',
  version: '1.0.0',
  ...
}

Le chemin d’accès passé en option est intercepté par une fonction. Elle le reçoit en argument et a la responsabilité d’en lire le contenu et de le transformer en objet ECMAScript grâce à la fonction JSON.parse() (chapitre 3).

application/coerce-file.js
'use strict';

const yargs = require('yargs');
const {readFileSync} = require('fs');

const parseJSON = (path) => JSON.parse(readFileSync(path));

const args = yargs
  .locale('fr')
  .option('json-file', {
    type: 'string',
    coerce: parseJSON,        // (1)
  })
  .argv;

console.log(args.jsonFile);   // (2)
  1. Notre fonction parseJSON() assure la transformation de l’argument --json-file.

  2. C’est bien le contenu du fichier qui s’affiche et non la valeur passée au programme.

⚠️
Performance Lire et écrire des fichiers

La lecture de fichiers dont la taille dépasse quelques centaines de kilo-octets devient problématique sur des machines avec peu de ressources, si vous souhaitez obtenir des résultats le plus tôt possible et si vous avez d’autres impératifs de performance.

Je vous invite à lire la section “Utiliser les flux de données” pour en savoir plus.

Cette pratique renforce une approche applicative modulaire. L'interface absorbe les spécificités de la ligne de commandes. L'application réagit aux paramètres sans avoir à se préoccuper du contexte, que ce soit sur le Web ou dans un terminal.

Les frameworks d’application en ligne de commande facilitent l’organisation et la définition de sous-commandes.

node application/random.js --help
random.js [command]

Commandes:
  random.js words [count]  Des mots
  random.js number         Un nombre

Options:
  --help     Affiche de l'aide                [booléen]
  --version  Affiche le numéro de version     [booléen]

C’est l’occasion d’adapter l’exemple utilisé dans la section “Utiliser des arguments et des options”. L’intention est de modifier le moins possible le code initial.

application/random.js
'use strict';

const {random} = require('faker/locale/fr');
const yargs = require('yargs');

const number = () => random.number();
const words = (count=5) => random.words(count);

yargs
  .locale('fr')
  .command('words [count]', 'Des mots', {}, (args) => { // (1)
    console.log(words(args.count));                     // (2)
  })
  .command('number', 'Un nombre', {}, () => {
    console.log(number());
  })
  .argv;
  1. La commande words accepte un argument optionnel count.

  2. Cette fonction est déclenchée quand la commande words est exécutée.

Je trouve qu’il est plus facile d’exécuter une fonction avec les bons arguments en utilisant ce mécanisme de sous-commandes. Nous n’avons même pas eu à modifier la signature des fonctions words et number.

💬
Syntaxe Annotation des arguments dans une commande

Certains frameworks comme yargs interprètent la commande que nous déclarons. Lorsque c’est le cas, les arguments sont rangés dans un objet d’arguments nommés. Les frameworks gèrent plusieurs cas de figure qui ont chacun leur notation.

Syntaxe Type d’argument Explication

cmd [arg1]

Optionnel

L’argument arg1 n’est pas obligatoire.

cmd <arg1>

Obligatoire

L’argument arg1 est obligatoire.

`cmd <arg1

arg2>`

Alias

L’argument est obligatoire et accepte deux informations différentes – un identifiant ou une adresse courriel par exemple.

cmd […​args]

Tableau

Un résultat identique s’obtient en organisant les sous-commandes dans des fichiers individuels et en indiquant à yargs dans quel répertoire les charger.

application/random-dir.js
'use strict';

const yargs = require('yargs');

yargs
  .locale('fr')
  .commandDir('./commands') // (1)
  .argv;
  1. Déclaration du répertoire dans lequel nous avons rangé les sous-commandes.

Nous ne déclarons plus les commandes sous forme d’un appel de fonction mais en retournant un module Node. Chacune de ses clés configure un aspect de la commande :

application/commands/words.js
'use strict';

const {random} = require('faker/locale/fr');

module.exports = {
  command: 'words [count]',                      // (1)
  desc: 'Génère des mots',                       // (2)
  handler: (args) => {                           // (3)
    console.log(random.words(args.count || 5));
  },
};
  1. Arguments acceptés par la commande.

  2. Description de la commande.

  3. Fonction déclenchée lorsque la commande est exécutée.

12.2. Stratégies pour gérer les chemins d’accès

Il y a des cas où passer des chemins d’accès à des fichiers en arguments d’un exécutable ne suffit pas. C’est le cas notamment quand on ne connaît pas la liste exacte des fichiers ou lorsqu’elle est susceptible de changer.

node files/intro.js ../package.json \
  "$(nodebook dir chapter-08 --root)/package-lock.json"

[ '…/chapter-08/package.json',
  '…/chapter-08/package-lock.json' ]

Cet exemple illustre l’utilisation d’un chemin relatif et d’un chemin absolu, tous deux normalisés en chemins absolus. En procédant ainsi, nous rendons notre code indépendant de son contexte d’exécution – ici, notre emplacement au sein du système de fichiers.

files/intro.js
'use strict';

const {resolve} = require('path');
const files = process.argv.slice(2);          // (1)

const resolveFile = (file) => resolve(file);  // (2)

console.log(files.map(resolveFile));          // (3)
  1. La liste des fichiers correspond à tous les arguments du script.

  2. Chaque chemin d’accès est transformé en chemin absolu.

  3. La liste homogénéisée est affichée à l’écran.

💬
Repère Chemin relatif, absolu ou __dirname ?

Les fichiers passés en argument peuvent être un mélange de chemins absolus et de chemins relatifs, qui se normalisent avec la fonction path.resolve(). La base à considérer est le répertoire courant, là où le programme est exécuté, c’est-à-dire la valeur de process.cwd() (chapitre 4).

__dirname est une base à utiliser lorsqu’un chemin est relatif au code source.

La saisie des chemins se simplifie en utilisant deux mécanismes : l'expansion et le globbing. Le module npm glob (npmjs.com/glob) fait très bien ce travail, mais nous allons nous baser sur globby (npmjs.com/globby) à la place. Il gère les promesses et je le trouve plus simple d’utilisation.

node files/glob.js '../package-*.json'
['…/chapter-08/package.json', '…/package-lock.json']

Dans cet exemple, nous partons à la recherche de tous les fichiers préfixés par package- et terminés par .json. Cette intention est exprimée par un seul argument qui contient le caractère de globbing (*).

files/glob.js
'use strict';

const glob = require('globby');
const {resolve} = require('path');
const patterns = process.argv.slice(2);

const resolveFiles = (files) => {
  return files.map(file => resolve(file));
};

glob(patterns)                                // (1)
  .then(files => resolveFiles(files))         // (2)
  .then(files => console.log(files));         // (3)
  1. Le module globby accepte un ou plusieurs motif(s) – ici nous n’en utilisons qu’un.

  2. Nous transformons les fichiers identifiés en chemins absolus.

  3. Pour ensuite les afficher dans le terminal.

L’expansion est caractérisée par l’utilisation des accolades. Les valeurs sont séparées par des virgules, qui signifient ou. Les deux syntaxes se combinent ici pour récupérer les fichiers suffixés par .adoc ou par .html :

node files/glob.js '../*.{adoc,html}'
['…/chapter-08/index.adoc']

Enfin, l’utilisation de la double-étoile (**) signifie dans tous les répertoires. Nous récupérons ainsi tous les fichiers .js contenus dans ce répertoire et les sous-répertoires :

node files/glob.js '**/*.js'
['…/chapter-08/examples/hello.js', …]

12.3. Utiliser les flux de données (stdin, stdout et stderr)

Cette section complète notre découverte du module stream (chapitre 4). Mon intention est de vous aiguiller dans la conception d’applications en ligne de commande qui acceptent des flux de données, en entrée comme en sortie. La ligne de commande se prête tout particulièrement au streaming et nos applications gagnent à fonctionner longtemps en consommant le moins de ressources possibles.

cat blah.txt
blah blah
cat blah.txt | node streaming/intro.js
BLAH BLAH
streaming/intro.js
'use strict';

const getStdin = require('get-stdin');                // (1)

const uppercase = (text) => text.toLocaleUpperCase();
const log = (text) => process.stdout.write(text);

getStdin().then(uppercase).then(log);                 // (2)
  1. Le module npm get-stdin (npmjs.com/get-stdin) est pratique pour un flux d’entrée à petit volume.

  2. Il retourne une promesse quand il a terminé de lire le flux d’entrée.

J’aime bien proposer une alternative à l’entrée standard, en utilisant les arguments lorsqu’il y a un nombre indéfini d’éléments à lire. Je trouve que cette proposition évite de renoncer à un outil dans un contexte où il est impossible de configurer un flux d’entrée.

cat blah.txt | node streaming/intro-fallback.js
BLAH BLAH
node streaming/intro-fallback.js $(< blah.txt)
BLAH BLAH

La stratégie consiste à utiliser l’entrée standard quand on détecte une absence d’arguments et, sinon, d’utiliser ces derniers.

streaming/intro-fallback.js
'use strict';

const getStdin = require('get-stdin');
const input = process.argv.slice(2);      // (1)

const uppercase = (text) => text.toLocaleUpperCase();
const log = (text) => process.stdout.write(text);

if (input.length === 0) {                 // (2)
  getStdin().then(uppercase).then(log);
}
else {                                    // (3)
  Promise.resolve(input.join(' ')).then(uppercase).then(log);
}
  1. Nous récupérons les arguments du script.

  2. Chaque mot (séparé par un espace) est considéré comme un argument – s’il n’y en a pas, nous pouvons utiliser l’entrée standard.

  3. Sinon nous rassemblons les mots en une seule chaîne de caractères.

Notons au passage l’utilisation de Promise.resolve() (chapitre 3) pour transformer deux sources de données de manière identique.

Les options sont adaptées pour indiquer l’emplacement d’une ressource. La méthode fs.createReadStream() (chapitre 4) lit les données d’une ressource de la même manière que nous consommons l’entrée standard.

node streaming/input.js --input blah.txt
BLAH BLAH
streaming/input.js
'use strict';

const minimist = require('minimist');
const getStream = require('get-stream');    // (1)
const {createReadStream} = require('fs');
const {input=''} = minimist(process.argv.slice(2));

const uppercase = (text) => text.toLocaleUpperCase();
const log = (text) => process.stdout.write(text);

if (input) {
  getStream(createReadStream(input))        // (2)
    .then(uppercase).then(log);
}
  1. Le module npm get-stream (npmjs.com/get-stream) est similaire à get-stdin et fonctionne avec tout flux de lecture.

  2. Il résout une promesse dès qu’il a terminé de consommer le flux de lecture.

Là aussi, je trouve intéressant de proposer une approche double, en acceptant un chemin de fichier en option et son contenu via l’entrée standard :

node streaming/input-fallback.js --input blah.txt
BLAH BLAH
cat blah.txt | node streaming/input-fallback.js
BLAH BLAH
streaming/input-fallback.js
'use strict';

const minimist = require('minimist');
const through = require('through2');
const {createReadStream} = require('fs');
const {input=''} = minimist(process.argv.slice(2));

const uppercase = (text) => text.toLocaleUpperCase();
const transform = through(function(data) {  // (1)
  this.push(uppercase(String(data)));       // (2)
});

if (!input.length) {
  process.stdin.pipe(transform)             // (3)
    .pipe(process.stdout);
}
else {
  createReadStream(input).pipe(transform)
    .pipe(process.stdout);
}
  1. Le module npm through2 (npmjs.com/through2) s’intègre à un flux en exécutant une fonction à chaque arrivée de données.

  2. Nous transformons la chaîne de caractères entrante dans le flux de sortie.

  3. Ce flux de sortie est lui-même redirigé vers la sortie standard du programme.

Ce qui est intéressant avec cette approche est qu’elle nous apprend à intervenir nous-même sur un flux sans avoir à en connaître les tenants et aboutissant.

💡
Alternative Spécifier l’entrée ou la sortie standard en tant qu’option

Certains programmes Linux utilisent le caractère - pour symboliser l’entrée ou la sortie standard, selon l’intention de l’option.

cat blah.txt | node streaming/input-fallback.js --input ##-##

Cette notation est utile quand vous voulez rendre l’entrée standard optionnelle et explicite.

Le précédent exemple nous permet aussi de nous libérer des modules get-stdin et get-stream. Ces derniers obligent quand même à charger tout le contenu du flux entrant en mémoire. En travaillant directement sur les flux avec le module through2, les transformations sont faites en temps réel, au fur et à mesure où les données sont lues, bloc par bloc.

Nous sommes désormais en mesure de travailler avec des fichiers au même titre qu’avec l’entrée et la sortie standards.

cat blah.txt | node streaming/pipe-in.js
BLAH BLAH
streaming/pipe-in.js
'use strict';

const minimist = require('minimist');
const through = require('through2');
const {createReadStream:read} = require('fs');
const {i:input} = minimist(process.argv.slice(2));

const source = input ? read(input) : process.stdin;   // (1)
const uppercase = (text) => text.toLocaleUpperCase();

const transform = through(function(data) {
  this.push(uppercase(String(data)));
});

source.pipe(transform).pipe(process.stdout);          // (2)
  1. La source de données provient soit du flux de lecture de fichier, soit de l’entrée standard.

  2. Le travail sur le contenu est ainsi factorisé, avec un fonctionnement identique peu importe la source de données.

L’utilisation d’un même concept pour lire et pour écrire des données simplifie notre code tout en offrant des performances optimales.

Il ne reste plus grand-chose à faire pour appliquer cet effort à la sortie du programme, pour écrire en continu dans un fichier ou bien vers la sortie standard.

node streaming/pipe-out.js -i blah.txt
BLAH BLAH
node streaming/pipe-out.js -i blah.txt -o debug.txt
cat blah.txt | node streaming/pipe-out.js -o debug.txt
streaming/pipe-out.js
'use strict';

const minimist = require('minimist');
const through = require('through2');
const {createReadStream:read} = require('fs');
const {createWriteStream:write} = require('fs');
const {i:input,o:output} = minimist(process.argv.slice(2));

const source = input ? read(input) : process.stdin;
const dest = output ? write(output) : process.stdout; // (1)
const uppercase = (text) => text.toLocaleUpperCase();

const transform = through(function(data) {
  this.push(uppercase(String(data)));
});

source.pipe(transform).pipe(dest);                    // (2)
  1. La destination des données est choisie par un mécanisme similaire à celui pour la source de données.

  2. Le processus de traitement est désormais indifférent à la source et à la destination des données.

En quelques exemples, nous sommes passé·e·s d’une utilisation potentiellement énergivore à un traitement en continu, plus doux pour les ressources système et intégrable à tous les outils en ligne de commande qui lisent depuis l’entrée standard ou écrivent vers la sortie standard.

12.4. Activer l’autocomplétion des commandes

Faire en sorte que les options possibles s’affichent à l’écran sur une simple pression de la touche Tab est la cerise sur le gâteau ! Cette technique facilite la découverte d’une application par tâtonnement. Elle accélère et réduit le risque d’erreurs durant la saisie des commandes.

⚠️
Compatibilité Incertitudes de fonctionnement sous Windows

Cette section n’a pas été testée avec les systèmes d’exploitation Windows. Les commandes référencées ci-après ne fonctionnent peut-être pas sous ses systèmes.

Prérequis pour installer les exécutables de ce chapitre
npm install --global $(nodebook dir chapter-08 --root)

Le mécanisme d’autocomplétion est fourni par l’environnement d’exécution de notre terminal, le shell. Sous Linux et macOS, il s’agit de bash, de zsh ou plus rarement, de fish. Il “suffit” donc d’interconnecter notre programme exécutable pour qu’il fournisse les résultats de complétion au mécanisme de notre shell.

eval $(nodebook.ch08.autocomplete --completion)  
nodebook.ch08.autocomplete Tab
--help coucou
  1. L’option --completion est une option spéciale comprise par le module npm utilisé dans cet exemple.

Le mécanisme de complétion est fourni par le module npm omelette (npmjs.com/omelette). Il est totalement indépendant de notre outillage d'arguments et d’options. Je le trouve léger et rapide à prendre en main.

autocomplete/intro.js
#!/usr/bin/env node

'use strict';

const omelette = require('omelette');
const args = process.argv.slice(2);
const options = ['--help','coucou'];                    // (2)

omelette`nodebook.ch08.autocomplete ${options}`.init(); // (1)

console.log(args);                                      // (3)
  1. Déclaration du nom de programme et des options et arguments à suggérer.

  2. Ces deux options et arguments sont utilisés pour la complétion du programme.

  3. Cette partie est atteinte seulement quand le programme est exécuté.

L’autocomplétion peut aller jusqu’à suggérer les valeurs associées aux options et arguments de notre programme.

eval $(nodebook.ch08.omelette --completion)
nodebook.ch08.omelette Tab
--timezone now

nodebook.ch08.omelette --timezoneTabTab
Africa/Abidjan                  Asia/Almaty
Africa/Accra                    Asia/Amman
Africa/Addis_Ababa              Asia/Anadyr
…

Pour cela, le module omelette accepte un arbre de déclarations afin de comprendre quoi suggérer en fonction de ce qui a été précédemment saisi :

autocomplete/omelette.js
#!/usr/bin/env node

'use strict';

const omelette = require('omelette');
const args = require('minimist')(process.argv.slice(2));
const timezones = require('tz-ids');

omelette('nodebook.ch08.omelette')
  .tree({                     // (1)
    '--timezone': timezones,  // (2)
    'now': []
  })
  .init();

console.log(args.timezone);
  1. L’arbre est un objet ECMAScript.

  2. L’option --timezone suggère la liste complète des fuseaux horaires.

C’est très pratique quand on ne sait pas par où commencer et quand on ne connaît pas la liste exhaustive des propositions.

Nous avons parlé du module npm yargs dans la section sur l'utilisation d’un framework. Il embarque un module d’autocomplétion. Si vous utilisez déjà ce module, l’autocomplétion revient à ajouter une ligne de code à notre programme. Elle est un peu plus basique et demande plus de travail pour arriver à la qualité des résultats du module omelette.

eval $(nodebook.ch08.yargs completion)
nodebook.ch08.yargs -Tab
--help      --timezone  --version
autocomplete/yargs.js
#!/usr/bin/env node

'use strict';

require('yargs')
  .option('timezone', {
    type: 'string',
  })
  .completion()           // (1)
  .argv;
  1. Cette méthode ajoute la compréhension de l’argument completion pour s’intégrer avec le shell de notre système d’exploitation.

💡
Pratique Rendre l’autocomplétion permanente

Les commandes eval $(…​) des précédents exemples sont à consigner dans le fichier de configuration de votre shell, c’est-à-dire dans le fichier ~/.bashrc pour bash, ~/.zshrc pour zsh et ~/.config/fish/config.fish pour fish.

12.5. Rendre le programme indépendant de Node

Nos programmes exécutables souffrent d’un défaut : ils imposent l’installation préalable de Node pour fonctionner. Ce n’est pas grave pour une machine de devéloppement ou sur une machine de production car nous sommes à même de contrôler l’environnement d’exécution.

La première solution consiste à empaqueter notre application avec le module npm pkg (npmjs.com/pkg). Cet exécutable embarque notre code, les dépendances npm du projet et la version de Node de notre choix sous la forme d’un unique fichier exécutable.

npm install --global pkg
pkg --targets latest-linux,latest-macos,latest-win ..
./nodebook
Il est 22h49.

Dans ce cas, nous avons empaqueté une même application à la fois pour les systèmes d’exploitation Windows, Linux et macOS. Nous pourrions faire de même pour des ordinateurs légers de type Raspberry Pi.

Une solution alternative consiste à utiliser le logiciel Docker (docker.com/community-edition). Ce système crée une base d’environnement réutilisable (le conteneur) à partir d’une recette d’installation (l’image). Une même image applicative se réplique à l’infini, sans avoir connaissance de notre système d’exploitation.

docker build -t nodebook/chapter-08 ..
docker run -ti --rm nodebook/chapter-08
Bienvenue dans le chapitre 8 😊

Une image se construit depuis un fichier Dockerfile avec la commande docker build. Elle s’exécute sous forme d’un conteneur jetable avec la commande docker run. La recette d’installation est une suite d’instructions jouées les unes à la suite des autres :

Dockerfile
FROM node:alpine

WORKDIR /nodebook
ADD ./package.json ./package-lock.json ./
RUN npm ci
ADD ./examples ./examples

CMD ["node", "examples/hello.js"]

Je recommande de doubler le fichier Dockerfile d’un fichier .dockerignore. Il suit les mêmes règles de fonctionnement qu’un fichier .gitignore. Il exclut une liste définie de fichiers du contexte d’exécution et d’une possible copie vers l’image Docker.

.dockerignore
node_modules

Une fois construite, une image se distribue sous forme de fichier transférable, sur le registre public hub.docker.com ou sur tout autre registre privé – dont ceux fournis par certains hébergeurs cloud (chapitre 6).

💬
Alternative Windows sous Docker

Microsoft met à disposition des images de Windows Server pour Docker à l’adresse suivante :

Nous pouvons tester une application Node dans un environnement Windows sans quitter notre système d’exploitation Linux ou macOS.

13. Conclusion

L’écriture d’un exécutable en ligne de commande est un exercice proche de celui d’une application web écrite avec Node.

Nous avons vu que l’enjeu majeur tient dans la modularisation du code applicatif et dans la création d’une interface cohérente, informative et documentée.

Les options et les arguments sont essentiels à maîtriser et à documenter car ils servent de pierre angulaire pour créer un pont avec les utilisateurs.

Ils deviennent encore plus puissants une fois combinés avec la gestion des chemins d’accès ainsi qu’avec les flux d’entrée et de sortie.