@nodefony/devkit 10.0.0-beta.1 → 10.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +27 -12
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateParam.js +1 -1
  5. package/dist/index.js +2 -2
  6. package/dist/nodefony/controllers/DevkitController.js +2 -2
  7. package/dist/nodefony/controllers/McpController.js +71 -6
  8. package/dist/nodefony/service/DevkitService.js +2 -2
  9. package/dist/nodefony/src/card.js +1 -2
  10. package/dist/types/nodefony/controllers/McpController.d.ts +12 -1
  11. package/docs/index.md +63 -15
  12. package/package.json +9 -9
  13. package/skills/nodefony-add-realtime-channel/SKILL.md +66 -9
  14. package/skills/nodefony-add-service/SKILL.md +11 -0
  15. package/skills/nodefony-authentication/SKILL.md +234 -0
  16. package/skills/nodefony-browser/SKILL.md +45 -21
  17. package/skills/nodefony-browser/references/sondes.md +13 -0
  18. package/skills/nodefony-browser/scripts/audit.mjs +59 -10
  19. package/skills/nodefony-browser/scripts/inspect.mjs +122 -4
  20. package/skills/nodefony-browser/scripts/lib/browser.mjs +112 -6
  21. package/skills/nodefony-browser/scripts/lib/probes.mjs +166 -7
  22. package/skills/nodefony-dev/SKILL.md +56 -81
  23. package/skills/nodefony-dev/scripts/docs.mjs +21 -5
  24. package/skills/nodefony-devops/SKILL.md +16 -13
  25. package/skills/nodefony-devops/references/compose.md +8 -0
  26. package/skills/nodefony-devops/references/frontal.md +5 -1
  27. package/skills/nodefony-devops/references/image.md +1 -1
  28. package/skills/nodefony-devops/references/secrets.md +18 -4
  29. package/skills/nodefony-devops/references/variables.md +52 -19
  30. package/skills/nodefony-migrate-schema/SKILL.md +5 -2
  31. package/skills/nodefony-migrate-schema/references/verdicts.md +20 -19
  32. package/skills/nodefony-protect-route/SKILL.md +118 -17
  33. package/skills/nodefony-security-review/SKILL.md +127 -0
  34. package/skills/nodefony-security-review/scripts/review-diff.mjs +1090 -0
  35. package/skills/nodefony-troubleshoot/SKILL.md +151 -0
  36. package/skills/nodefony-troubleshoot/scripts/log-errors.mjs +465 -0
  37. package/skills/nodefony-upgrade/SKILL.md +130 -0
@@ -0,0 +1,151 @@
1
+ ---
2
+ name: nodefony-troubleshoot
3
+ description: >
4
+ Dépanne une application Nodefony en partant du SYMPTÔME constaté — démarrage qui échoue,
5
+ route en 404, refus 401 ou 403 inattendu, erreur au journal, colonne absente, tests rouges
6
+ d'un coup — et donne pour chacun le premier geste, la commande qui le constate et le skill
7
+ qui porte le détail. Extrait les erreurs du journal par un script (regroupées, datées, avec
8
+ leur piste) au lieu de le lire en entier. À charger DÈS qu'une chose qui marchait ne marche
9
+ plus, AVANT de lire les sources ou de contourner la panne. Coder une fonctionnalité →
10
+ `nodefony-dev`.
11
+ Déclencheurs : "l'application ne démarre plus", "le serveur plante au démarrage", "ma route
12
+ répond 404", "ça marchait hier", "j'ai une erreur dans le journal", "erreur dans les logs",
13
+ "plus rien ne répond", "tous mes tests sont rouges", "pourquoi ça plante", "dépanner",
14
+ "diagnostiquer une panne", "stack trace", "EADDRINUSE", "ECONNREFUSED".
15
+ metadata:
16
+ version: 1.0.0
17
+ ---
18
+
19
+ # nodefony-troubleshoot — du symptôme au geste, sans contourner
20
+
21
+ > **Maintenance** : vérité courante, jamais un journal. Éditer en place ; l'historique vit dans
22
+ > `git log`. Pas de `references/`, comme `nodefony-dev` et pour la même raison mesurée : une page
23
+ > de `references/` n'est presque jamais ouverte par un agent, un script cité comme commande l'est.
24
+ > Ce qui doit atteindre un agent s'EXÉCUTE ou vit dans ce corps.
25
+
26
+ ## Passer la main
27
+
28
+ | Ce que tu fais en réalité | Le skill |
29
+ | -------------------------------------------------------------------- | ------------------------- |
30
+ | Tu codes une fonctionnalité, rien n'est cassé | `nodefony-dev` |
31
+ | Le refus 401/403 vient d'une route que tu protèges ou ouvres | `nodefony-protect-route` |
32
+ | La connexion elle-même échoue : mot de passe, second facteur, retour | `nodefony-authentication` |
33
+ | La base n'a pas la colonne ou la table que l'entité déclare | `nodefony-migrate-schema` |
34
+ | Le symptôme est À L'ÉCRAN : rien ne s'affiche, erreur de console | `nodefony-browser` |
35
+ | La panne n'apparaît qu'en conteneur, en production, au déploiement | `nodefony-devops` |
36
+
37
+ ## 1. La règle — constater, puis corriger la cause
38
+
39
+ **Une panne se CONSTATE avant de se corriger.** L'application sait dire ce qui ne va pas : son
40
+ diagnostic, ses routes montées, son journal. Lire les sources pour deviner, c'est reconstruire à la
41
+ main ce qu'une commande rend en une seconde — et se tromper sur ce qui est réellement chargé.
42
+
43
+ **Ne contourne jamais une panne.** Élargir une zone pour faire taire un 401, retirer une garde,
44
+ sérialiser une suite pour faire passer un rouge, relâcher une assertion, effacer la base : chacun
45
+ fait disparaître le symptôme et garde la cause, qui revient ailleurs — souvent en production. Si la
46
+ cause t'échappe, dis-le, avec ce que tu as constaté.
47
+
48
+ ## 2. Le symptôme → le premier geste
49
+
50
+ | Symptôme | Premier geste | Puis |
51
+ | --------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
52
+ | L'application ne démarre pas, ou démarre puis s'arrête | `npx nodefony doctor` | le journal (§3) ; sortie `78` = clé de configuration refusée, elle est NOMMÉE |
53
+ | Elle démarre, mais quelque chose manque (module, base, fournisseur) | `npx nodefony doctor --live` | `var/last-boot.json` : le bilan du dernier démarrage, daté |
54
+ | Le superviseur avale le message de plantage | lance l'enfant seul : `NF_DEV_CHILD=1` puis `npx nodefony development` | le plantage brut s'affiche |
55
+ | Une route répond `404` | `npx nodefony inspect routes --json` | absente → `npm run build` ; présente → le chemin MONTÉ diffère (§4) |
56
+ | TOUT répond `404`, même les routes du gabarit | `npx nodefony status` | un autre serveur tient les ports, ou le tien a glissé (§4) |
57
+ | Refus `401` ou `403` inattendu | lire le corps et l'en-tête `WWW-Authenticate` de la réponse | `nodefony-protect-route` (zone, rôle, jeton anti-falsification) |
58
+ | `401` avec `error="insufficient_user_authentication"` | la route exige une connexion récente ou un second facteur | `nodefony-authentication` |
59
+ | `403` « Credentials must be sent over HTTPS » | un mot de passe envoyé en HTTP clair, refusé en production | servir en HTTPS, ou déclarer le proxy TLS (`trustProxy`) |
60
+ | Une erreur dans le journal | le script du §3 | la piste qu'il donne, sinon le message lui-même |
61
+ | « colonne inexistante », « no such table », migrations à appliquer | `npx nodefony doctor --live` | `nodefony-migrate-schema` |
62
+ | Des dizaines de tests d'intégration rouges d'un coup (`ECONNREFUSED`) | `npx nodefony status` | ils FRAPPENT un serveur, ils ne le lancent pas : il est éteint. En e2e, laisse la commande gérer le cycle |
63
+ | `nodefony <commande>` échoue là où `npm run dev` réussit | `npm ls nodefony` | deux copies du framework dans le processus (§4) |
64
+
65
+ **Lis toute la réponse d'une commande de diagnostic.** Ne la tronque jamais (`| head`, une
66
+ recherche dans sa sortie) : la ligne qui nomme la cause est presque toujours dans la partie coupée.
67
+
68
+ ## 3. Le journal — extraire, pas lire
69
+
70
+ Un journal de développement fait des milliers de lignes, presque toutes saines. Le script lit tout,
71
+ garde les erreurs, recolle chaque pile d'appels à la ligne qui l'a levée, fusionne les répétitions,
72
+ date ce qu'il a lu, et nomme le premier geste quand la cause est établie :
73
+
74
+ ```bash
75
+ node node_modules/@nodefony/devkit/skills/nodefony-troubleshoot/scripts/log-errors.mjs
76
+ node node_modules/@nodefony/devkit/skills/nodefony-troubleshoot/scripts/log-errors.mjs logs/dev.log
77
+ node node_modules/@nodefony/devkit/skills/nodefony-troubleshoot/scripts/log-errors.mjs --warnings --last 40
78
+ node node_modules/@nodefony/devkit/skills/nodefony-troubleshoot/scripts/log-errors.mjs --json
79
+ ```
80
+
81
+ Sans argument, il lit `logs/*.log` et `logs/*.jsonl`. Sorties : `0` aucune erreur · `1` au moins
82
+ une · `64` usage · `66` aucun journal lisible.
83
+
84
+ ⚠️ **En développement, le journal part sur la sortie du TERMINAL, pas dans un fichier.** Le script
85
+ répond alors `66` et dit comment l'obtenir : lancer le serveur avec `npx nodefony development
86
+ --detach` (journal dans `logs/nodefony-detached.log`), ou rediriger la sortie dans un fichier qu'on
87
+ lui passe. **Lis l'âge** qu'il affiche pour chaque fichier : un journal d'hier ne parle pas de la
88
+ panne d'aujourd'hui.
89
+
90
+ ⚠️ **Une erreur du journal n'est pas toujours une panne.** Un port pris par l'outil du front se
91
+ rattrape seul sur le suivant (la ligne « retrying » qui suit le dit) ; un `CRITIC SIGTERM` est un
92
+ arrêt demandé. Le script le signale quand il le reconnaît — sinon, relie l'erreur au symptôme avant
93
+ de la corriger.
94
+
95
+ ## 4. Les pannes qui trompent
96
+
97
+ Chacune a déjà coûté au moins une heure à quelqu'un.
98
+
99
+ - **Ta route répond `404` alors qu'elle existe dans les sources.** Le runtime charge `dist/`, pas
100
+ le source : `npm run build`. C'est la cause n°1.
101
+ - **Une classe que rien ne déclare compile, passe ses tests, et casse au démarrage suivant** —
102
+ entité hors `@entities([…])`, controller hors `@controllers([…])`. Table jamais créée, route en
103
+ `404`. C'est le mode d'échec de la COPIE : on recopie le voisin au lieu d'appeler le générateur,
104
+ qui, lui, déclare. `npx nodefony doctor` la NOMME.
105
+ - **Ta route NEUVE répond `404` et le `dist/` est à jour** : elle n'est pas montée où tu crois. Le
106
+ chemin réel est le PRÉFIXE de son controller suivi du `path` de la route — une action
107
+ `path: "/widget"` dans un controller `@controller("/api")` répond sur `/api/widget`.
108
+ `npx nodefony inspect routes --json` donne le chemin MONTÉ.
109
+ - **TOUT répond `404`** : un AUTRE serveur tient les ports — ou LE TIEN a glissé, le port voulu
110
+ étant pris. `npx nodefony status` montre les ports RÉELS, pas ceux que tu as configurés, et
111
+ NOMME le projet voisin.
112
+ - **Une clé de configuration mal orthographiée ARRÊTE le démarrage** (sortie `78`), en la nommant.
113
+ Une clé retirée par une version du framework produit le même arrêt après un `npm update` : lis
114
+ le message, il nomme la clé.
115
+ - 🔴 **`nodefony <commande>` échoue là où `npm run dev` réussit, sur la MÊME application** : DEUX
116
+ paquets `nodefony` tournent dans le processus — un binaire lié ailleurs (installation globale,
117
+ `npm link`) exécute un noyau pendant que l'application importe le sien. Chaque copie a ses
118
+ propres classes, son contexte de requête et ses registres d'injection : un module construit à la
119
+ frontière perd son container SANS la moindre erreur — il figure dans la liste des modules et ne
120
+ fait rien. ⚠️ Les deux copies peuvent être la MÊME version ; deux fichiers distincts suffisent,
121
+ il n'y a aucune incohérence à repérer. `npm ls nodefony` liste les copies (« deduped » partout = une seule) ;
122
+ `NF_CLI_DEBUG=1 nodefony --version` dit quel CLI s'exécute.
123
+ - **`localhost` et `127.0.0.1` te jouent des tours** : ce sont deux ORIGINES distinctes — cookies,
124
+ cache et passkeys ne les partagent pas. Une session ouverte sur l'une n'existe pas sur l'autre :
125
+ `401` assuré. Une seule origine en développement, partout, URL ouverte comme callbacks.
126
+ - **Ton application a démarré AMPUTÉE et tout a l'air sain** — base injoignable, module écarté par
127
+ sa politique. Seul `npx nodefony doctor` le dit, en relisant `var/last-boot.json`. ⚠️ Ce bilan
128
+ a un ÂGE : il décrit le **dernier démarrage**, et une commande console ne l'écrase pas — lis la
129
+ date avant d'en conclure.
130
+ - **Une suite lancée contre un serveur en `production` reçoit `404` partout** : les modules
131
+ `policy:"dev"` n'y sont pas. `NF_WITH_DEV_MODULES=1` déroge pour 30 minutes
132
+ (`NF_WITH_DEV_MODULES_TTL_MIN`, 4 h au plus), et le `CRITIC « arrêt automatique … dérogation »`
133
+ du journal est cette garde qui se referme — pas une panne.
134
+ - **Un test vert seul et rouge en suite accuse une RESSOURCE PARTAGÉE**, pas ton code : base ou
135
+ index réutilisé, port, store jamais purgé, assertion `count === N` qui suppose une table vierge.
136
+ Lance deux fichiers ENSEMBLE pour isoler la paire, puis cloisonne. **Ne sérialise jamais la
137
+ suite** pour faire passer le rouge : tu masques la cause et tu paies la lenteur à chaque
138
+ exécution.
139
+ - **Les routes authentifiées plafonnent quand le reste tient la charge** : le stockage de session
140
+ par défaut est SYNCHRONE — chaque reprise bloque la boucle d'événements. Compare une route
141
+ anonyme et une route authentifiée AVANT d'accuser TLS ou le pare-feu ; passe le stockage sur
142
+ redis pour la charge.
143
+
144
+ ## 5. Avant de dire « réparé »
145
+
146
+ 1. **Rejoue le symptôme d'origine** — la même requête, la même commande — et constate qu'il a
147
+ disparu. Un correctif qu'on n'a pas vu agir sur le symptôme est une hypothèse.
148
+ 2. **`npx nodefony doctor`** ne signale plus rien de neuf.
149
+ 3. **`npm run verify`** : le correctif n'a rien cassé ailleurs.
150
+ 4. **Dis la cause**, pas seulement le geste : « le `dist/` était en retard » apprend quelque chose
151
+ au suivant ; « j'ai relancé le build » ne lui apprend rien.
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Extrait les erreurs du journal d'une application Nodefony — regroupées, datées, avec le geste qui suit.
4
+ *
5
+ * Un journal de développement fait vite des milliers de lignes, dont l'immense
6
+ * majorité dit que tout va bien. Le lire en entier noie l'erreur ; le couper
7
+ * (`tail`) la coupe aussi, sans le dire. Ce script lit TOUT, ne garde que les
8
+ * sévérités qui comptent, recolle une pile d'appels à la ligne qui l'a levée,
9
+ * fusionne les répétitions, et nomme le premier geste quand il le connaît.
10
+ *
11
+ * Il lit les deux formes que le framework écrit dans `logs/` : le texte
12
+ * (`nodefony-detached.log` d'un serveur lancé avec `--detach`, `nodefony-<pid>.log`
13
+ * du pilote fichier, ou tout fichier où tu as redirigé la sortie) et le JSON par
14
+ * ligne (`nodefony-<pid>.jsonl`). Un plantage brut de Node — `SyntaxError: …`
15
+ * hors du format du journal — est reconnu aussi.
16
+ *
17
+ * `@usage` node node_modules/@nodefony/devkit/skills/nodefony-troubleshoot/scripts/log-errors.mjs
18
+ * `@usage` node node_modules/@nodefony/devkit/skills/nodefony-troubleshoot/scripts/log-errors.mjs logs/dev.log
19
+ * `@usage` node node_modules/@nodefony/devkit/skills/nodefony-troubleshoot/scripts/log-errors.mjs --warnings --last 40
20
+ * `@option` --warnings - garde aussi les avertissements (WARNING)
21
+ * `@option` --last - nombre d'erreurs distinctes rendues, les plus récentes (défaut 15)
22
+ * `@option` --json - la même réponse, sérialisée, pour rechaîner
23
+ * `@option` --root - racine du projet (défaut : le dossier courant)
24
+ * `@output` les erreurs distinctes, de la plus ancienne à la plus récente, avec fichier, heure, nombre et piste
25
+ *
26
+ * Sorties : `0` aucune erreur · `1` au moins une erreur · `64` usage · `66` aucun journal lisible.
27
+ */
28
+
29
+ import {
30
+ readFileSync,
31
+ readdirSync,
32
+ statSync,
33
+ openSync,
34
+ fstatSync,
35
+ readSync,
36
+ closeSync,
37
+ realpathSync,
38
+ } from "node:fs";
39
+ import path from "node:path";
40
+ import process from "node:process";
41
+ import { fileURLToPath } from "node:url";
42
+ import { findProjectRoot } from "nodefony/project-root";
43
+
44
+ /**
45
+ * Racine de l'APPLICATION vue depuis `cwd` — le dossier qui porte
46
+ * `nodefony.config.ts` —, ou `cwd` lui-même hors application. Un script lancé
47
+ * depuis `frontend/` ou `modules/blog/` travaille ainsi sur l'app entière.
48
+ * `nodefony/project-root` est le sous-chemin LÉGER du cœur (`node:` seul) :
49
+ * importer `nodefony` entier coûtait jusqu'à plusieurs secondes à froid.
50
+ *
51
+ * @param {string} cwd - le dossier de lancement.
52
+ * @returns {string} la racine retenue.
53
+ */
54
+ const appRoot = (cwd) => findProjectRoot(cwd) ?? cwd;
55
+
56
+ /** Erreurs distinctes rendues par défaut — au-delà, personne ne lit. */
57
+ const DEFAULT_LAST = 15;
58
+
59
+ /** Octets relus depuis la FIN d'un journal : un journal de plusieurs Go ne doit pas saturer la mémoire. */
60
+ const MAX_SCAN_BYTES = 8 * 1024 * 1024;
61
+
62
+ /** Sévérités du journal (RFC 5424), de la plus grave à la plus anodine. */
63
+ const SEVERITY = {
64
+ EMERGENCY: 0,
65
+ ALERT: 1,
66
+ CRITIC: 2,
67
+ ERROR: 3,
68
+ WARNING: 4,
69
+ NOTICE: 5,
70
+ INFO: 6,
71
+ DEBUG: 7,
72
+ };
73
+
74
+ /** Codes ANSI de couleur : le journal texte en porte quand il est écrit pour un terminal. */
75
+ const ANSI = /\u001b\[[0-9;]*m/gu;
76
+
77
+ /** Une ligne du journal texte : `HH:MM:SS.mmm SÉVÉRITÉ MSGID : message`. */
78
+ const PDU_LINE =
79
+ /^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(EMERGENCY|ALERT|CRITIC|ERROR|WARNING|NOTICE|INFO|DEBUG)\s+(.+?)\s+:\s?(.*)$/u;
80
+
81
+ /** Une erreur levée HORS du journal — le plantage brut que Node écrit lui-même. */
82
+ const RAW_ERROR =
83
+ /^(?:Uncaught )?((?:[A-Z][A-Za-z]*)?Error)(?: \[[A-Z_]+\])?: (.+)$/u;
84
+
85
+ /** Une ligne de pile d'appels, rattachée à l'erreur qui la précède. */
86
+ const STACK_LINE = /^\s+at\s/u;
87
+
88
+ /**
89
+ * Ce que le message dit, et le premier geste qui en découle.
90
+ *
91
+ * Seulement des causes ÉTABLIES : une piste fausse coûte plus qu'aucune piste,
92
+ * parce qu'on la suit. Ce qui n'est pas ici se lit dans le message lui-même.
93
+ */
94
+ const HINTS = [
95
+ {
96
+ when: /does not provide an export named/u,
97
+ hint: "un `dist/` en retard sur les sources, ou deux copies de `nodefony` : `npm run build`, puis `npm ls nodefony`",
98
+ },
99
+ {
100
+ when: /Cannot find (?:module|package)|ERR_MODULE_NOT_FOUND/u,
101
+ hint: "dépendance ou build manquant : `npm install`, puis `npm run build`",
102
+ },
103
+ {
104
+ when: /BootConfigurationError/u,
105
+ hint: "configuration refusée au démarrage (sortie 78) : le message NOMME la clé — corrige-la, ne la retire pas",
106
+ },
107
+ {
108
+ when: /EADDRINUSE|already in use/u,
109
+ hint: "port déjà tenu : `npx nodefony status` nomme qui le tient (une ligne « retrying » juste après = rattrapé seul)",
110
+ },
111
+ {
112
+ when: /no such table|no such column|does not exist|doesn't exist|Unknown column|migrations? à appliquer/u,
113
+ hint: "schéma de base en retard sur les entités : skill `nodefony-migrate-schema`",
114
+ },
115
+ {
116
+ when: /ECONNREFUSED|ENOTFOUND|getaddrinfo/u,
117
+ hint: "un service extérieur (base, cache, fournisseur) ne répond pas : `npx nodefony doctor --live`",
118
+ },
119
+ {
120
+ when: /^SIG(?:TERM|INT|HUP)$/u,
121
+ hint: "arrêt DEMANDÉ au processus — pas une panne",
122
+ },
123
+ ];
124
+
125
+ const FLAGS = new Set([
126
+ "--warnings",
127
+ "--last",
128
+ "--json",
129
+ "--root",
130
+ "--help",
131
+ "-h",
132
+ ]);
133
+
134
+ /**
135
+ * Lit les arguments, et REFUSE ce qu'il ne comprend pas.
136
+ *
137
+ * Un drapeau mal tapé qu'on ignorerait rendrait « aucune erreur » — la pire
138
+ * réponse possible d'un outil de dépannage.
139
+ *
140
+ * @param argv - les arguments, sans `node` ni le chemin du script.
141
+ * @returns les options lues.
142
+ * @throws Error quand un drapeau est inconnu ou qu'une valeur manque.
143
+ */
144
+ export function parseArgs(argv, cwd = process.cwd()) {
145
+ const opts = {
146
+ files: [],
147
+ warnings: false,
148
+ last: DEFAULT_LAST,
149
+ json: false,
150
+ root: appRoot(cwd),
151
+ help: false,
152
+ };
153
+ for (let i = 0; i < argv.length; i++) {
154
+ const a = argv[i];
155
+ if (!a.startsWith("-")) {
156
+ opts.files.push(a);
157
+ continue;
158
+ }
159
+ if (!FLAGS.has(a)) throw new Error(`option inconnue : ${a}`);
160
+ if (a === "--help" || a === "-h") opts.help = true;
161
+ else if (a === "--warnings") opts.warnings = true;
162
+ else if (a === "--json") opts.json = true;
163
+ else {
164
+ const v = argv[++i];
165
+ if (v === undefined || v.startsWith("-"))
166
+ throw new Error(`${a} attend une valeur`);
167
+ if (a === "--root") opts.root = path.resolve(v);
168
+ else {
169
+ const n = Number(v);
170
+ if (!Number.isInteger(n) || n < 1)
171
+ throw new Error(`--last attend un entier positif, reçu « ${v} »`);
172
+ opts.last = n;
173
+ }
174
+ }
175
+ }
176
+ return opts;
177
+ }
178
+
179
+ /**
180
+ * Heure locale `HH:MM:SS.mmm` d'un horodatage — la forme du journal texte, pour
181
+ * qu'un même événement lu dans ses deux formes se reconnaisse.
182
+ *
183
+ * @param ms - horodatage en millisecondes.
184
+ * @returns l'heure formatée.
185
+ */
186
+ function timeOfDay(ms) {
187
+ const d = new Date(ms);
188
+ const p = (n, w = 2) => String(n).padStart(w, "0");
189
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`;
190
+ }
191
+
192
+ /**
193
+ * Découpe un journal en événements, quelle que soit sa forme (texte ou JSON par ligne).
194
+ *
195
+ * Les lignes d'une même erreur — même heure, même émetteur, même sévérité, ou
196
+ * pile d'appels qui suit — forment UN événement : une pile de sept lignes est
197
+ * une erreur, pas sept.
198
+ *
199
+ * @param text - le contenu du journal.
200
+ * @param source - le nom du fichier, rendu avec chaque événement.
201
+ * @returns les événements, dans l'ordre du journal.
202
+ */
203
+ export function extractEvents(text, source) {
204
+ const events = [];
205
+ let last = null;
206
+ for (const rawLine of text.split(/\r?\n/u)) {
207
+ const line = rawLine.replace(ANSI, "");
208
+ if (line.trim() === "") continue;
209
+ let entry = null;
210
+ if (line.startsWith("{")) {
211
+ try {
212
+ const o = JSON.parse(line);
213
+ if (typeof o.severityName === "string" && o.severityName in SEVERITY) {
214
+ entry = {
215
+ time:
216
+ typeof o.timeStamp === "number" ? timeOfDay(o.timeStamp) : null,
217
+ severity: o.severityName,
218
+ msgid: String(o.msgid ?? ""),
219
+ message:
220
+ typeof o.payload === "string"
221
+ ? o.payload
222
+ : JSON.stringify(o.payload),
223
+ };
224
+ }
225
+ } catch {
226
+ // Une ligne JSON tronquée (écriture en cours) n'est pas une erreur du journal.
227
+ }
228
+ } else {
229
+ const m = PDU_LINE.exec(line);
230
+ if (m)
231
+ entry = {
232
+ time: m[1],
233
+ severity: m[2],
234
+ msgid: m[3].trim(),
235
+ message: m[4],
236
+ };
237
+ else {
238
+ const r = RAW_ERROR.exec(line.trim());
239
+ if (r)
240
+ entry = {
241
+ time: null,
242
+ severity: "ERROR",
243
+ msgid: "node",
244
+ message: line.trim(),
245
+ };
246
+ else if (last && STACK_LINE.test(line)) {
247
+ last.detail.push(line.trim());
248
+ continue;
249
+ }
250
+ }
251
+ }
252
+ if (entry === null) {
253
+ last = null;
254
+ continue;
255
+ }
256
+ const sameError =
257
+ last !== null &&
258
+ entry.time !== null &&
259
+ last.time === entry.time &&
260
+ last.msgid === entry.msgid &&
261
+ last.severity === entry.severity;
262
+ if (sameError) {
263
+ last.detail.push(entry.message);
264
+ continue;
265
+ }
266
+ last = { ...entry, source, detail: [] };
267
+ events.push(last);
268
+ }
269
+ return events;
270
+ }
271
+
272
+ /**
273
+ * Garde ce qui compte et fusionne les répétitions.
274
+ *
275
+ * Deux événements identiques (même sévérité, émetteur et message) n'en font
276
+ * qu'un, compté — qu'ils viennent d'un même fichier ou des deux formes du même
277
+ * journal. L'ordre rendu est chronologique, par dernière occurrence.
278
+ *
279
+ * @param events - les événements extraits.
280
+ * @param options - `warnings` garde aussi les avertissements.
281
+ * @returns les erreurs distinctes, chacune avec son nombre, sa première et sa dernière heure, et sa piste.
282
+ */
283
+ export function summarize(events, { warnings = false } = {}) {
284
+ const ceiling = warnings ? SEVERITY.WARNING : SEVERITY.ERROR;
285
+ const byKey = new Map();
286
+ const seen = new Set();
287
+ for (const e of events) {
288
+ if (SEVERITY[e.severity] > ceiling) continue;
289
+ // Le même événement lu dans ses deux formes (texte ET JSON) ne compte qu'une fois.
290
+ const occurrence = `${e.time}|${e.severity}|${e.msgid}|${e.message}`;
291
+ if (e.time !== null && seen.has(occurrence)) continue;
292
+ seen.add(occurrence);
293
+ const key = `${e.severity}|${e.msgid}|${e.message}`;
294
+ const known = byKey.get(key);
295
+ if (known) {
296
+ known.count++;
297
+ known.lastTime = e.time ?? known.lastTime;
298
+ byKey.delete(key);
299
+ byKey.set(key, known);
300
+ continue;
301
+ }
302
+ const text = [e.message, ...e.detail].join("\n");
303
+ byKey.set(key, {
304
+ severity: e.severity,
305
+ msgid: e.msgid,
306
+ message: e.message,
307
+ detail: e.detail,
308
+ source: e.source,
309
+ count: 1,
310
+ firstTime: e.time,
311
+ lastTime: e.time,
312
+ hint: HINTS.find((h) => h.when.test(text))?.hint ?? null,
313
+ });
314
+ }
315
+ return [...byKey.values()];
316
+ }
317
+
318
+ /**
319
+ * Lit la fin d'un fichier — tout, s'il est sous le plafond.
320
+ *
321
+ * @param file - chemin du fichier.
322
+ * @returns le texte relu.
323
+ */
324
+ function readTail(file) {
325
+ // Une seule ouverture : taille et lecture sur le MÊME descripteur — le chemin
326
+ // peut changer entre un `stat` et un `read` (js/file-system-race).
327
+ const fd = openSync(file, "r");
328
+ try {
329
+ const size = fstatSync(fd).size;
330
+ if (size <= MAX_SCAN_BYTES) return readFileSync(fd, "utf8");
331
+ const buf = Buffer.alloc(MAX_SCAN_BYTES);
332
+ readSync(fd, buf, 0, MAX_SCAN_BYTES, size - MAX_SCAN_BYTES);
333
+ // La première ligne est coupée en plein milieu : on la jette.
334
+ return buf.toString("utf8").replace(/^[^\n]*\n/u, "");
335
+ } finally {
336
+ closeSync(fd);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Les journaux à lire : ceux donnés, sinon ceux du dossier `logs/` du projet.
342
+ *
343
+ * @param opts - les options lues.
344
+ * @returns les fichiers lisibles, du plus ancien au plus récent.
345
+ */
346
+ export function journalFiles(opts) {
347
+ if (opts.files.length > 0)
348
+ return opts.files.map((f) => path.resolve(opts.root, f));
349
+ const dir = path.join(opts.root, "logs");
350
+ let names;
351
+ try {
352
+ names = readdirSync(dir);
353
+ } catch {
354
+ return [];
355
+ }
356
+ return names
357
+ .filter((n) => n.endsWith(".log") || n.endsWith(".jsonl"))
358
+ .map((n) => path.join(dir, n))
359
+ .sort((a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs);
360
+ }
361
+
362
+ /** Âge lisible d'un fichier : un journal d'hier ne parle pas de la panne d'aujourd'hui. */
363
+ function age(ms) {
364
+ const min = Math.round((Date.now() - ms) / 60_000);
365
+ if (min < 1) return "moins d'une minute";
366
+ if (min < 120) return `${min} min`;
367
+ return `${Math.round(min / 60)} h`;
368
+ }
369
+
370
+ const USAGE = `Usage : node log-errors.mjs [journal…] [--warnings] [--last N] [--json] [--root DIR]
371
+
372
+ Sans fichier : lit logs/*.log et logs/*.jsonl du projet.
373
+ Sorties : 0 aucune erreur · 1 au moins une erreur · 64 usage · 66 aucun journal lisible.`;
374
+
375
+ /**
376
+ * Point d'entrée.
377
+ *
378
+ * @returns le code de sortie.
379
+ */
380
+ export function main(argv = process.argv.slice(2)) {
381
+ let opts;
382
+ try {
383
+ opts = parseArgs(argv);
384
+ } catch (e) {
385
+ process.stderr.write(`${e.message}\n\n${USAGE}\n`);
386
+ return 64;
387
+ }
388
+ if (opts.help) {
389
+ process.stdout.write(`${USAGE}\n`);
390
+ return 0;
391
+ }
392
+ const files = journalFiles(opts);
393
+ const read = [];
394
+ const events = [];
395
+ for (const file of files) {
396
+ let text;
397
+ try {
398
+ text = readTail(file);
399
+ } catch (e) {
400
+ process.stderr.write(`illisible : ${file} — ${e.message}\n`);
401
+ continue;
402
+ }
403
+ const rel = path.relative(opts.root, file) || file;
404
+ read.push({ file: rel, age: age(statSync(file).mtimeMs) });
405
+ events.push(...extractEvents(text, rel));
406
+ }
407
+ if (read.length === 0) {
408
+ process.stderr.write(
409
+ "Aucun journal lisible.\n" +
410
+ "En développement, le journal part sur la SORTIE du terminal qui a lancé le serveur, pas dans un fichier.\n" +
411
+ "Pour l'avoir ici : `npx nodefony development --detach` (journal dans logs/nodefony-detached.log),\n" +
412
+ "ou redirige la sortie dans un fichier, puis passe-le en argument.\n",
413
+ );
414
+ return 66;
415
+ }
416
+ const all = summarize(events, opts);
417
+ const shown = all.slice(-opts.last);
418
+ if (opts.json) {
419
+ process.stdout.write(
420
+ `${JSON.stringify({ read, total: all.length, errors: shown }, null, 2)}\n`,
421
+ );
422
+ return all.length > 0 ? 1 : 0;
423
+ }
424
+ process.stdout.write(
425
+ `Journaux lus : ${read.map((r) => `${r.file} (écrit il y a ${r.age})`).join(", ")}\n\n`,
426
+ );
427
+ if (all.length === 0) {
428
+ process.stdout.write(
429
+ `Aucune erreur${opts.warnings ? " ni avertissement" : ""}.\n`,
430
+ );
431
+ return 0;
432
+ }
433
+ process.stdout.write(
434
+ `${all.length} erreur${all.length > 1 ? "s" : ""} distincte${all.length > 1 ? "s" : ""}` +
435
+ `${all.length > shown.length ? ` — les ${shown.length} plus récentes` : ""}\n\n`,
436
+ );
437
+ for (const e of shown) {
438
+ const when =
439
+ e.count > 1
440
+ ? `${e.firstTime ?? "?"} → ${e.lastTime ?? "?"}, ×${e.count}`
441
+ : (e.firstTime ?? "heure inconnue");
442
+ process.stdout.write(
443
+ `${e.severity} ${e.msgid} [${e.source}, ${when}]\n ${e.message}\n`,
444
+ );
445
+ for (const d of e.detail.slice(0, 4)) process.stdout.write(` ${d}\n`);
446
+ if (e.detail.length > 4)
447
+ process.stdout.write(` … ${e.detail.length - 4} ligne(s) de plus\n`);
448
+ if (e.hint) process.stdout.write(` ➜ ${e.hint}\n`);
449
+ process.stdout.write("\n");
450
+ }
451
+ return 1;
452
+ }
453
+
454
+ // Exécuté directement (et non importé par son test) : rendre le code.
455
+ // La comparaison passe par une URL, jamais par un chemin : sous Windows, `D:\…`
456
+ // verrait son `d:` lu comme un protocole.
457
+ if (
458
+ process.argv[1] !== undefined &&
459
+ // Chemins RÉELS des deux côtés : Node résout `import.meta.url` en suivant
460
+ // les liens, pas `argv[1]` — lancé par `/var/…` (macOS) ou un dossier lié,
461
+ // la comparaison brute échouait et le script ne faisait RIEN.
462
+ realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
463
+ ) {
464
+ process.exitCode = main();
465
+ }