Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

CLI — piloter le framework en ligne de commande

stablenodefony (cœur)mis à jour 2026-07-20

Une application Nodefony se pilote par une seule porte : le binaire nodefony. Il démarre le serveur en développement, le construit, le lance en production, ouvre un cluster, échafaude un projet ou un module, et accueille les commandes que tes propres modules ajoutent. Sous le capot, chaque commande est une classe qui s’accroche au cycle de vie du kernel et décide jusqu’où le booter — de « rien du tout » (un simple status) à « serveurs prêts » (development).

📍 Documentation › Cœur — @nodefony/core › CLI

🧠 Le modèle mental — un binaire, un kernel, des commandes#

Trois idées suffisent à comprendre toute la CLI.

Le binaire délègue à l’application. Installé globalement, nodefony sert surtout à créer un projet hors de tout dépôt. Mais dans une application, c’est la version des node_modules du projet qui fait autorité — elle seule connaît ses modules et ses commandes. Le lanceur remonte donc au projet (findProjectRoot) et, s’il y trouve un autre paquet nodefony que lui-même, lui passe la main dans le même processus (bin/nodefony.ts). C’est le pattern du wrapper de projet (gradlew, mvnw) : le projet gagne.

Où tu tapes nodefony CLI qui s’exécute
hors de tout projet (create app) celui que tu as lancé — le global
dans un projet aux dépendances installées celui du projet (node_modules/nodefony) — délégation
dans un projet sans node_modules le global, faute d’autre

Pour voir la décision, pose NF_CLI_DEBUG=1 : le lanceur écrit [nodefony] cli → <chemin> sur la sortie d’erreur. Quand global et projet n’ont pas la même version, une ligne le dit d’office (versionMismatchNotice, resolveLocalCli.ts#versionMismatchNotice) — jamais sous --json. Et create app, qui tourne par le global, demande au registre si une version plus récente du même canal est publiée (startFreshnessCheck, cliFreshness.ts#startFreshnessCheck) : il l’annonce avec le geste npm i -g nodefony@<tag>, se tait hors ligne, en CI ou sous NF_NO_UPDATE_CHECK=1.

Une commande choisit son point d’arrêt. Chaque commande déclare un kernelEvent : la phase du boot à laquelle elle s’exécute et où le démarrage s’arrête. Une commande qui n’a besoin de rien (status, stop, create) ne boote aucun kernel — c’est un fast-path standalone. Une commande qui introspecte la config booste jusqu’à onReady. Une commande serveur (development, production) va jusqu’à onPostReady, où les serveurs écoutent, puis reste en vie.

Deux familles de commandes. Les intégrées (development, build, create…) sont posées par le cœur au démarrage (CliKernel.registerBuiltinCommands(), CliKernel.ts#CliKernel.registerBuiltinCommands). Les commandes de module (http:network, security:user:add…) sont ajoutées par chaque module dans son constructeur — elles suivent le namespace <module>:<action> et empruntent exactement le même chemin.

🚀 Démarrage rapide#

1. Piloter une application#

Toutes les commandes s’exécutent depuis la racine du projet (celle qui porte nodefony.config.ts) :

npx nodefony development     # serveur de dev : Vite/HMR + redémarrage auto (alias : dev)
npx nodefony debug           # idem, débogueur ouvert dans le serveur (127.0.0.1:9229)
npx nodefony build           # construit tous les paquets (alias : compile)
npx nodefony production -w 4 # runtime prod, 4 workers, au premier plan (cloud-native)
npx nodefony status          # les process en cours (dev/prod/cluster), sans rien démarrer
npx nodefony stop            # arrêt propre de tout runtime lancé depuis ce projet
npx nodefony --help          # la liste complète, commandes de module incluses

Un serveur qui écoute n’est pas un serveur qui sert. status porte une ligne disponibilité distincte des ports : un composant peut retenir la mise en service — un schéma de base en retard, un cache froid — pendant que les ports répondent parfaitement. Il NOMME alors ce qui retient, et pourquoi :

disponibilité : ⚠ RETENUE — drizzle:default (2 migrations en attente) ; /readyz rend 503

Le détail vient du serveur lui-même, par un fichier local qu’il tient à jour — jamais de la sonde publique, qui ne donne les noms qu’à un appelant authentifié. Quand ce détail manque ou ne concorde pas avec ce que le runtime vient de répondre, status affiche le compte sans nommer : un nom faux ferait chercher une cause déjà levée.

Le port servi n’est pas toujours le port demandé — et status le dit. En développement, un port déjà pris fait glisser l’écoute sur le suivant (servers.portPolicy: "auto"). Le démarrage annonce alors l’adresse RÉELLE, et status porte l’écart tant qu’il dure :

✓ serveur prêt en 3456ms — http://127.0.0.1:5153 · https://127.0.0.1:5154
⚠ PORTS DÉCALÉS — 5151, 5152 déjà pris par un autre processus → cette app sert
  http://127.0.0.1:5153 · https://127.0.0.1:5154. Viser CETTE adresse (nodefony status)

Sans cette annonce, on interroge le port de sa configuration, l’application VOISINE répond — même sonde de vie, même racine, et un refus d’authentification pour un compte qu’elle ne connaît pas — et l’on cherche une panne chez soi pendant qu’on parle à quelqu’un d’autre.

Le verdict sort aussi par le code de retour. status rend 0 quand un processus de CE projet tourne, et 69 (EX_UNAVAILABLE) quand il n’y en a aucun — même si un port de la configuration répond, puisque ce serait celui d’une autre application. C’est ce qui rend un démarrage en arrière-plan constatable sans lire son journal :

npx nodefony status || npx nodefony dev   # démarrer seulement si rien ne tourne ici

Plusieurs applications sur le même poste. status et stop sont scopés au projet du répertoire courant : ils ne comptent ni n’arrêtent jamais le runtime du voisin. Quand un autre projet tourne, status le NOMME dans une table (nom du package.json, ports tenus, racine), et ce nom est exactement ce que stop accepte — pas besoin de cd :

npx nodefony stop mon-autre-app   # par nom déclaré, ou par nom de dossier
npx nodefony stop /chemin/complet # par chemin, quand deux projets sont homonymes
npx nodefony stop --all           # tous les runtimes Nodefony du poste (explicite)

Une cible qui ne désigne pas exactement UN projet est refusée (code de sortie 1), avec la liste pour corriger : arrêter un serveur est irréversible, et deviner « le plus proche » ferait tomber le mauvais.

2. Ajouter sa propre commande#

Une commande maison vit dans un module : on l’ajoute dans son constructeur avec addCommand. Le nom suit la convention <module>:<action>, et kernelEvent dit à quelle phase elle s’exécute.

import { Module, Command } from "nodefony";
import type { Kernel, CliKernel } from "nodefony";

class GreetCommand extends Command {
  constructor(cli: CliKernel) {
    // Le kernel sera prêt (onReady) avant que generate() ne tourne.
    super("app:greet", "Saluer depuis la ligne de commande", cli, {
      kernelEvent: "onReady",
      // Écrits sous toute erreur d'usage et sous --help : la bonne forme, prête à copier.
      examples: ["nodefony app:greet Ada"],
    });
    this.addArgument("<name>", "qui saluer");
  }

  override async generate(name: string): Promise<this> {
    // `log` est hérité de Service : ni import, ni logger à injecter.
    this.log(`bonjour ${name}`, "INFO");
    return this;
  }
}

class GreetModule extends Module {
  constructor(kernel: Kernel) {
    super("greet", kernel, import.meta.url, {});
    // Posé au constructeur du module (exécuté quand le manifeste le charge).
    this.addCommand(GreetCommand);
  }
}

export default GreetModule;

Une fois le module déclaré dans le manifeste modules, la commande apparaît dans nodefony --help et s’invoque npx nodefony app:greet Ada.

📖 Lexique#

Terme En clair
Cli La façade au-dessus de Commander : enregistre les commandes, parse argv, porte les helpers d’affichage. Utilisable sans kernel (standalone).
CliKernel Un Cli relié au kernel : c’est lui qui boote l’application pour les commandes qui en ont besoin, et qui classe intégrées vs modules.
Command La classe de base d’une commande. On surcharge generate() (l’action) et, au besoin, les hooks de cycle de vie.
kernelEvent La phase du boot où la commande s’exécute — et le point où le démarrage s’arrête (onStart → onRegister → onBoot → onReady → onPostReady).
standalone (0 boot) Une commande qui ne démarre aucun kernel : status, stop, create, completion, --version. Réponse immédiate.
runtime / long-running Une commande serveur (development, production, cluster) qui, une fois la phase atteinte, reste en vie au lieu de rendre la main.
scaffold La génération de code à partir de gabarits (create app/module/entity…) — un moteur pur alimenté par une spec déclarative.
dispatch différé Les commandes de module ne sont connues qu’après leur enregistrement ; le kernel diffère le parse jusque-là, puis exécute la bonne.

🗂️ Les commandes intégrées#

Vingt-sept commandes posées par le cœur (CliKernel.registerBuiltinCommands(), CliKernel.ts#CliKernel.registerBuiltinCommands), plus l’alias start. La colonne arrêt indique jusqu’où le boot va — 0 boot = fast-path standalone.

Commande Alias Ce qu’elle fait Arrêt Classe
development dev Serveur de dev : Vite/HMR + redémarrage auto (--detach/--wait/--health/--log) onPostReady DevCommand.ts#Dev
debug — development + débogueur ouvert DANS le serveur, rouvert à chaque rechargement (--inspect=<port>, --inspect-brk) onPostReady DebugCommand.ts#Debug
production prod Runtime prod au premier plan ; topologie via -w, --workers onPostReady ProdCommand.ts#Prod
cluster — Cluster de N workers (cgroup-aware, respawn) — -w, --workers onPostReady ClusterCommand.ts#Cluster
inspect — L’état RÉEL de l’app : routes · modules · services · config · stores · entities · graph, --json — sans ouvrir de port onPostReady InspectCommand.ts#Inspect.constructor
build compile Construit tous les paquets (délègue à turbo run build) — -f/--force onRegister BuildCommand.ts#Build
install — install sur tous les modules — -f/--force onRegister InstallCommand.ts#Install
outdated — outdated sur tous les modules onRegister OutdatedCommand.ts
upgrade — Après une montée de version : remet le bloc framework d’AGENTS.md, les pointeurs de skills et les copies livrées au niveau des paquets — --check (sort en 1 si en retard), --dry-run, --json (0 boot) 0 boot UpgradeCommand.ts#Upgrade
start — Alias de prod (il n’y a plus de commande propre) onStart ProdCommand.ts#Prod.constructor
doctor — Diagnostic STATIQUE : paquets importés non déclarés, câblage (entité / controller / service jamais enregistrés, nom réservé, brique manquante), segment :id qui répondra 404 — --json, --cwd ; remonte à la racine de l’app, donc lançable depuis n’importe quel sous-dossier (0 boot) 0 boot DoctorCommand.ts#Check
env — Le .env lu, valeurs effectives et provenance de chacune (0 boot) 0 boot EnvCommand.ts#Env
status — Introspecte les process dev/prod/cluster (0 boot) 0 boot StatusCommand.ts#Status
stop — Arrête proprement les runtimes du projet (0 boot) — --all 0 boot StopCommand.ts#Stop
completion — Script de complétion shell (0 boot) 0 boot CompletionCommand.ts#Completion
create — Échafaude projet / module / entité (0 boot) 0 boot CreateCommand.ts#options
card devkit:card Carte de visite de l’app : identité, modules installés, où aller, quoi lancer — --json, --cwd (0 boot) 0 boot CardCommand.ts
symbols — Signature et TSDoc d’un symbole du framework, depuis le graphe publié — --module, --json (0 boot) 0 boot SymbolsCommand.ts
man — Ouvre la page de manuel du paquet du projet (man nodefony ne la trouve qu’en installation globale) — --path (0 boot) 0 boot ManCommand.ts#Man
ai:sync — Pose dans .agents/skills/ les pointeurs vers les skills livrés par les paquets installés — --dry-run, --json, --cwd (0 boot) 0 boot cli/aiSync.ts
scaffold:sync — Pose les fichiers que livrent les paquets installés (nodefony.contribute) ; --update rafraîchit les copies restées intactes — --dry-run, --json, --cwd (0 boot) 0 boot cli/contributions.ts
ai:mcp — Déclare le serveur MCP de l’app à ton agent (0 boot) 0 boot AiMcpCommand.ts
see — Ouvre une page dans un navigateur piloté et la mesure (0 boot) 0 boot SeeCommand.ts
image:check — Refuse une image de conteneur qui embarque un secret (0 boot) 0 boot ImageCommand.ts
git:hooks — Pose les hooks git : contrôles au commit et au push (0 boot) 0 boot GitHooksCommand.ts
licenses — Les licences des dépendances redistribuées onRegister LicensesCommand.ts
scripts — Ce que font les scripts npm du projet (descriptions déclarées dans nodefony.scripts) onRegister ScriptsCommand.ts
menu — Menu interactif : les commandes utiles ici onStart MenuCommand.ts

status et stop sont détournées vers leur exécution réelle avant tout boot (CliKernel.ts#CliKernel.start, via isStandaloneDevCommand) — leur generate() n’est qu’un filet.

doctor et env prennent le même raccourci, et pour une raison qui se retient (CliKernel.ts#cliOptions et CliKernel.ts#CliKernel.start) : on lance ces deux commandes précisément quand l’application ne démarre plus. Les faire booter les rendrait muettes au seul moment où elles servent — et le rapport se noierait sous le journal du Kernel. doctor prend donc ce fast-path : sans lui, il partirait en dispatch différé, c’est-à-dire en boot.

Deux verbes à retenir, et leur frontière : doctor est statique — il ne lit que des fichiers, donc il fonctionne sur une application cassée. inspect est runtime — il boote sans serveur et rend ce que l’application est VRAIMENT, pas ce que son code laisse croire.

ai:sync pose des POINTEURS, pas des copies. Les skills d’agent vivent dans les paquets (node_modules/@nodefony/*/skills/, et les modules locaux de l’app) ; la commande écrit dans .agents/skills/ un fichier court qui les DÉSIGNE. Le contenu suit donc npm update sans qu’aucun fichier du projet ne soit réécrit — une copie, elle, mentirait six mois plus tard sans casser le build. Le dossier visé est celui que tous les clients conformes lisent, pas celui d’un seul.

nodefony create app fait ce geste à la création, juste avant le premier commit : les pointeurs sont faits pour être versionnés, l’équipe et l’intégration continue disposent des mêmes skills. Aucun postinstall ne l’appelle — --ignore-scripts est courant, les scripts d’installation sont un vecteur d’attaque connu, et écrire dans un dossier versionné à chaque installation produirait des différences surprises. Un pointeur identique n’est jamais réécrit (l’horodatage ne bouge pas) ; un pointeur que plus aucun paquet ne livre est nommé, jamais supprimé — quelqu’un a pu en écrire un à la main sous le même nom.

scaffold:sync pose des COPIES, et c’est voulu. Certains fichiers doivent exister EN VRAI dans l’application : un thème que Keycloak monte depuis le compose (un lien vers node_modules se monte mal sous Windows, et yarn moderne n’a pas de node_modules), un realm d’import que l’application fera évoluer. Un paquet le déclare dans son package.json :

{ "nodefony": { "contribute": "./dist/nodefony/scaffold/contribute.js" } }

Ce module exporte contribute(context) (type TAppContributor). Le contexte (IAppContributionContext) distingue deux sortes de fichiers :

Geste Nature Fichier présent
write(chemin, contenu) semence : générée POUR l’application (un realm), qui la fait ensuite évoluer jamais remplacé
copyTree(dossier, chemin), write(…, { tracked: true }) copie suivie : ce que le paquet livre tel quel (un thème) rafraîchi par --update s’il est intact

Une copie suivie est inscrite avec son empreinte sha256 dans scaffold.lock.json, à la racine de l’application et versionné avec elle. C’est la preuve qui manquait pour suivre les versions du paquet sans écraser une retouche :

Les fins de ligne d’un texte sont ramenées à \n avant l’empreinte : un dépôt extrait sous Windows avec core.autocrlf ne fait pas passer chaque fichier intact pour retouché. Le registre n’est réécrit que s’il change, et une contribution en échec garde son entrée.

Le cœur ne connaît aucun contributeur : c’est ce qui permet à @nodefony/security de livrer son décor Keycloak sans que le cœur en sache rien. create app joue les contributions après l’installation, avant le premier commit (le registre entre donc dans ce commit) ; la commande les rejoue après un --no-install, et --update après un npm update. Elle sort en 1 si une contribution échoue. Le même geste existe dans le plan d’administration, en développement seulement : GET /nodefony/kernel/api/scaffold (le plan de --update, sans écrire) et POST /nodefony/kernel/api/scaffold/sync ({ "update": true }), superviseur suspendu le temps de l’écriture.

Les commandes de module#

Chaque module ajoute ses commandes dans son constructeur (Module.addCommand(), Module.ts#Module.addCommand), sous le namespace <module>:<action>. Elles apparaissent dans --help comme les intégrées :

Commande Ce qu’elle fait Classe
http:network Inventaire des interfaces réseau (-j pour du JSON) networkCommand.ts#Network
http:certificates Génère les certificats TLS de dev (-f/--force) certificatesCommand.ts#Certificates
proxy:generate Émet une config nginx/haproxy pour l’app proxyGenerateCommand.ts#ProxyGenerate
frontend:build Build de production des bundles Vite (-f/--force) frontend-build.ts#FrontendBuild
security:secrets Vérifie / écrit les secrets de sécurité (-w/--write) security-secrets.ts#SecuritySecrets.constructor
security:user:add Crée un utilisateur (-p, -r roles, -a admin) security-user-add.ts#SecurityUserAdd

Une commande introuvable rend le code EX_USAGE (64) — jamais un repli silencieux sur le serveur (CliKernel.ts#CliKernel.dispatchModuleCommand).

🏗️ Échafauder — create#

create prend un type en argument — sept (app | module | controller | service | front | entity | command, CREATE_TYPES, create.ts#CREATE_TYPES) — et route vers un moteur de scaffold unique (runCreateCommand(), create.ts#runCreateCommand) :

nodefony create app mon-app --preset complete --frontend react   # nouveau projet
nodefony create module blog --controller rest                    # module dans une app
nodefony create entity Article title:string body:text            # entité + service + controller + tests
nodefony create service Billing                                  # service @injectable + son interface
nodefony create command import --phase onReady                    # commande CLI <module>:import

service et command existent parce qu’une mesure les a réclamés, pas par symétrie. Sans générateur de service, un agent produit une classe à méthodes static : elle compile, elle marche, et elle reste invisible au conteneur. Sans générateur de commande, il n’a aucun modèle et invente. Un type de scaffold manquant ne se voit pas — il se paie en code inventé.

Le moteur est pur et piloté par une spec déclarative 100 % JSON (getScaffoldSpec(), spec.ts#getScaffoldSpec), partagée par trois fronts : le CLI rapide (flags), le CLI interactif (readline), et un futur formulaire Studio. Ajouter une question = une entrée dans la spec, aucun front à toucher.

Le détail de chaque scaffold (options, gabarits, presets) vit dans les skills dédiés : create app (preset complete/minimal, front React/Vue/Angular/Svelte), create module, create entity. En résumé — create app et create entity exécutent ce qu’ils génèrent au boot suivant (la table naît en CREATE TABLE IF NOT EXISTS). Ajouter ensuite un champ qui accepte le vide le pose au boot suivant ; un champ obligatoire ne l’est jamais — il faudrait inventer la valeur des lignes déjà présentes. La production, elle, se migre : nodefony orm:migrate.

Trois comportements de create entity qui surprennent si on ne les connaît pas :

🧩 Ajouter sa propre commande#

Le squelette est en Démarrage rapide ; voici les leviers.

generate() est l’action. On la surcharge (command/Command.ts#Command.generate) ; elle reçoit les arguments positionnels déclarés par addArgument(), et l’instance Commander en dernier paramètre. Les hooks de cycle de vie (onKernelStart(), onKernelReady()…) sont câblés à la demande par setEvents() (command/Command.ts#Command.setEvents), idempotent.

kernelEvent = jusqu’où booter. C’est le choix structurant :

examples = la bonne forme, montrée au bon moment. Des invocations complètes, telles qu’on les tape (command/Command.ts#OptionsCommandInterface). Sous une erreur d’usage — option inconnue, argument manquant —, la CLI écrit l’usage, les options et les deux premiers exemples, puis sort en 64 ; --help les montre tous. Une liste calculée (lue dans une page, un catalogue) se passe en fonction : le constructeur de chaque commande tourne à chaque lancement de la CLI, alors que les exemples ne servent que sous un refus ou un --help.

Enregistrer. Un module appelle this.addCommand(Ctor) dans son constructeur (Module.ts#Module.addCommand) — il exige que kernel.cli existe, sinon il lève Kernel not ready (Module.ts#Module.loadService). Hors module, un outil autonome construit un Cli et appelle cli.addCommand(Ctor) (Cli.ts#Cli.addCommand). Dans les deux cas, addCommand instancie la commande et l’enregistre sous le nom porté par son constructeur.

⚙️ La complétion shell#

nodefony completion <bash|zsh|fish> imprime un script à sourcer (renderCompletionScript(), completion.ts#renderCompletionScript ; shells supportés COMPLETION_SHELLS, completion.ts#COMPLETION_SHELLS) :

source <(nodefony completion zsh)     # essai immédiat (zsh)
nodefony completion install zsh       # installation gérée (bloc idempotent dans le rc)

Au TAB, le script appelle nodefony __complete (fast-path 0 boot, sort toujours OK). Les suggestions viennent d’un manifeste en cache écrit au boot de dev (commandes de module comprises, CliKernel.writeCompletionManifest(), CliKernel.ts#CliKernel.writeCompletionManifest) ; hors projet, le repli est la liste des intégrées en mémoire.

🩺 Codes de sortie#

La CLI suit la convention BSD sysexits (SysExit, sysexits.ts#SysExit) — utile pour scripter et lire un échec en CI :

Code Nom Quand
0 OK Succès.
64 USAGE Mauvais usage : commande/argument inconnu.
65 DATAERR Donnée d’entrée invalide.
66 NOINPUT Entrée attendue absente.
69 UNAVAILABLE Un service requis est indisponible.
70 SOFTWARE Erreur interne pendant l’exécution.
73 CANTCREAT Impossible de créer un fichier de sortie (scaffold).
78 CONFIG Configuration invalide.

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause Correction
Un « projet fantôme » démarre (1 module, 0 warning) La CLI résout l’application depuis process.cwd() Lancer depuis la racine du projet (celle du nodefony.config.ts).
Kernel not ready sur module.addCommand(...) addCommand appelé alors que kernel.cli n’existe pas encore L’appeler dans le constructeur du module, pas plus tôt.
La commande n’apparaît pas / n’est pas reconnue addCommand après le parse, ou module absent du manifeste modules Ordre add → parse ; déclarer le module dans modules.
generate() n’est jamais appelé Le kernelEvent déclaré n’est pas atteint (mauvaise phase) Choisir la phase réelle (voir § « jusqu’où booter »).
Commande inconnue → un serveur démarre (n’arrive pas) le dispatch rend EX_USAGE 64 Vérifier le nom exact ; --help liste tout.
create … refuse de s’exécuter hors d’un projet create module/controller/entity sont in-project Les lancer dans une app ; seul create app s’utilise hors projet.

🧪 Tests#

Le CLI est couvert par une suite dédiée du cœur, sans serveur pour la plupart :

Le décompte exact (cas comptés, par fichier) est rendu dans la carte de tests de cette page — jamais figé dans le texte, où il vieillirait. Pour le relancer : cd src/nodefony && npm run test (les boots réels : npm run test:boot).

🔗 Pour aller plus loin#