@nodefony/devkit 10.0.0-alpha.6 → 10.0.0-alpha.7

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 (24) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.149.0 → _@oxc-project_runtime@0.150.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.149.0 → _@oxc-project_runtime@0.150.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.149.0 → _@oxc-project_runtime@0.150.0}/helpers/esm/decorateParam.js +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/nodefony/controllers/DevkitController.js +2 -2
  6. package/dist/nodefony/controllers/McpController.js +3 -3
  7. package/dist/nodefony/service/DevkitService.js +2 -2
  8. package/package.json +8 -8
  9. package/skills/nodefony-add-crud/SKILL.md +9 -0
  10. package/skills/nodefony-add-realtime-channel/SKILL.md +9 -0
  11. package/skills/nodefony-add-service/SKILL.md +9 -0
  12. package/skills/nodefony-browser/SKILL.md +19 -16
  13. package/skills/nodefony-dev/SKILL.md +273 -0
  14. package/skills/nodefony-dev/scripts/docs.mjs +544 -0
  15. package/skills/nodefony-devops/SKILL.md +198 -0
  16. package/skills/nodefony-devops/references/compose.md +125 -0
  17. package/skills/nodefony-devops/references/frontal.md +119 -0
  18. package/skills/nodefony-devops/references/image.md +136 -0
  19. package/skills/nodefony-devops/references/kubernetes.md +189 -0
  20. package/skills/nodefony-devops/references/podman.md +103 -0
  21. package/skills/nodefony-devops/references/secrets.md +108 -0
  22. package/skills/nodefony-devops/references/variables.md +174 -0
  23. package/skills/nodefony-migrate-schema/SKILL.md +29 -17
  24. package/skills/nodefony-protect-route/SKILL.md +9 -0
@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cherche dans la documentation INSTALLÉE avec les paquets Nodefony.
4
+ *
5
+ * Elle est invisible à une recherche ordinaire : `rg "terme"` lancé à la racine
6
+ * d'un projet ne descend pas dans `node_modules` (git l'ignore, `rg` le suit).
7
+ * Le sujet paraît absent alors qu'il occupe des dizaines de pages — mesuré sur
8
+ * une application générée : 70 fichiers, plus de 38 000 lignes.
9
+ *
10
+ * Ce script les lit toutes et rend les passages qui répondent, avec leur chemin
11
+ * exact et leur ligne. Il ne remplace pas la lecture : il dit QUOI ouvrir.
12
+ *
13
+ * `@usage` node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs session cookie
14
+ * `@usage` node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --list
15
+ * `@usage` node node_modules/@nodefony/devkit/skills/nodefony-dev/scripts/docs.mjs --open firewall
16
+ * `@option` --list - énumère les pages installées (sujet, titre, chemin), sans chercher
17
+ * `@option` --open - rend le CHEMIN d'une page désignée par son sujet, son titre ou un fragment
18
+ * `@option` --json - la même réponse, sérialisée, pour rechaîner
19
+ * `@option` --limit - nombre de pages rendues (défaut 6)
20
+ * `@option` --root - racine du projet (défaut : le dossier courant)
21
+ * `@output` les pages classées, avec chemin, ligne et extrait — ou un refus qui DIT pourquoi
22
+ */
23
+
24
+ import { readFileSync, readdirSync, statSync } from "node:fs";
25
+ import path from "node:path";
26
+ import process from "node:process";
27
+ import { pathToFileURL } from "node:url";
28
+
29
+ /** Nombre de pages rendues par défaut — au-delà, personne ne lit. */
30
+ const DEFAULT_LIMIT = 6;
31
+
32
+ /** Extraits rendus par page. Deux suffisent à décider si l'on ouvre. */
33
+ const SNIPPETS_PER_PAGE = 2;
34
+
35
+ /** Les marques diacritiques Unicode, retirées avant toute comparaison. */
36
+ const COMBINING_MARKS = /[\u0300-\u036f]/gu;
37
+
38
+ const FLAGS = new Set([
39
+ "--list",
40
+ "--open",
41
+ "--json",
42
+ "--limit",
43
+ "--root",
44
+ "--help",
45
+ "-h",
46
+ ]);
47
+
48
+ /**
49
+ * Lit les arguments, et REFUSE ce qu'il ne comprend pas.
50
+ *
51
+ * Un drapeau inconnu qu'on ignore fait croire à une recherche vide plutôt qu'à
52
+ * une faute de frappe — le lecteur conclut alors « ce n'est pas documenté ».
53
+ *
54
+ * @param argv - les arguments, sans `node` ni le chemin du script.
55
+ * @returns les options lues.
56
+ * @throws Error quand un drapeau est inconnu ou qu'une valeur manque.
57
+ */
58
+ export function parseArgs(argv) {
59
+ const opts = {
60
+ terms: [],
61
+ list: false,
62
+ open: null,
63
+ json: false,
64
+ limit: DEFAULT_LIMIT,
65
+ root: process.cwd(),
66
+ help: false,
67
+ };
68
+ for (let i = 0; i < argv.length; i += 1) {
69
+ const arg = argv[i];
70
+ if (!arg.startsWith("-")) {
71
+ opts.terms.push(arg);
72
+ continue;
73
+ }
74
+ if (!FLAGS.has(arg)) throw new Error(`drapeau inconnu : ${arg}`);
75
+ if (arg === "--help" || arg === "-h") opts.help = true;
76
+ else if (arg === "--list") opts.list = true;
77
+ else if (arg === "--json") opts.json = true;
78
+ else if (arg === "--open") {
79
+ const value = argv[i + 1];
80
+ if (value === undefined || value.startsWith("-"))
81
+ throw new Error(
82
+ "--open attend un sujet, un titre ou un fragment de chemin",
83
+ );
84
+ opts.open = value;
85
+ i += 1;
86
+ } else if (arg === "--limit") {
87
+ const value = Number(argv[i + 1]);
88
+ if (!Number.isInteger(value) || value <= 0)
89
+ throw new Error("--limit attend un entier positif");
90
+ opts.limit = value;
91
+ i += 1;
92
+ } else if (arg === "--root") {
93
+ const value = argv[i + 1];
94
+ if (value === undefined || value.startsWith("-"))
95
+ throw new Error("--root attend un chemin");
96
+ opts.root = value;
97
+ i += 1;
98
+ }
99
+ }
100
+ return opts;
101
+ }
102
+
103
+ /**
104
+ * Les dossiers `docs/` des paquets Nodefony installés.
105
+ *
106
+ * On ne balaie PAS tout `node_modules` : un projet en porte des dizaines de
107
+ * milliers de fichiers, et la réponse mettrait une minute à venir pour y
108
+ * mélanger la documentation de tiers.
109
+ *
110
+ * @param root - la racine du projet.
111
+ * @returns les dossiers trouvés, `nodefony` d'abord puis les paquets triés.
112
+ */
113
+ export function docDirectories(root) {
114
+ const modules = path.join(root, "node_modules");
115
+ const found = [];
116
+ const push = (dir) => {
117
+ const docs = path.join(dir, "docs");
118
+ try {
119
+ if (statSync(docs).isDirectory()) found.push(docs);
120
+ } catch {
121
+ /* pas de docs dans ce paquet — le cas ordinaire */
122
+ }
123
+ };
124
+ push(path.join(modules, "nodefony"));
125
+ const scope = path.join(modules, "@nodefony");
126
+ let entries = [];
127
+ try {
128
+ entries = readdirSync(scope);
129
+ } catch {
130
+ return found;
131
+ }
132
+ for (const name of entries.sort()) push(path.join(scope, name));
133
+ return found;
134
+ }
135
+
136
+ /**
137
+ * Tous les fichiers `.md` d'un dossier, en descendant.
138
+ *
139
+ * @param dir - le dossier de départ.
140
+ * @returns les chemins, triés pour que deux exécutions se comparent.
141
+ */
142
+ function markdownFiles(dir) {
143
+ const out = [];
144
+ const walk = (current) => {
145
+ let entries = [];
146
+ try {
147
+ entries = readdirSync(current, { withFileTypes: true });
148
+ } catch {
149
+ return;
150
+ }
151
+ for (const entry of entries) {
152
+ const child = path.join(current, entry.name);
153
+ if (entry.isDirectory()) walk(child);
154
+ else if (entry.name.endsWith(".md")) out.push(child);
155
+ }
156
+ };
157
+ walk(dir);
158
+ return out.sort();
159
+ }
160
+
161
+ /**
162
+ * Le frontmatter d'une page, réduit aux champs qui servent au classement.
163
+ *
164
+ * Écrit à la main plutôt que par un analyseur YAML : ce script tourne chez
165
+ * l'utilisateur, depuis `node_modules`, et ne doit avoir AUCUNE dépendance. Une
166
+ * page non conforme est ignorée, jamais une recherche qui échoue.
167
+ *
168
+ * @param source - le contenu complet du fichier.
169
+ * @returns `{ title, module, topic, section, tags }`, champs manquants compris.
170
+ */
171
+ export function readHeader(source) {
172
+ const block = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(source);
173
+ const front = block?.[1] ?? "";
174
+ const field = (name) => {
175
+ const match = new RegExp(`^${name}:[ \\t]*(.+)$`, "mu").exec(front);
176
+ return (match?.[1] ?? "").trim().replace(/^["']|["']$/gu, "");
177
+ };
178
+ // Les tags s'écrivent en ligne (`[a, b]`) ou éclatés sur plusieurs lignes —
179
+ // prettier reformate les listes longues, les deux formes coexistent donc dans
180
+ // le même corpus. Un motif qui n'en lirait qu'une manquerait la moitié des
181
+ // pages sans le dire.
182
+ let tags = [];
183
+ const inline = /^tags:[ \t]*\[([\s\S]*?)\]/mu.exec(front);
184
+ if (inline) {
185
+ tags = inline[1]
186
+ .split(",")
187
+ .map((tag) => tag.trim().replace(/^["']|["']$/gu, ""))
188
+ .filter(Boolean);
189
+ }
190
+ return {
191
+ title: field("title"),
192
+ module: field("module"),
193
+ topic: field("topic"),
194
+ section: field("section"),
195
+ tags,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Le corps d'une page, frontmatter retiré, avec le décalage de lignes.
201
+ *
202
+ * Sans ce décalage, toute ligne citée serait fausse de la hauteur du
203
+ * frontmatter — et une ancre plausible mais fausse a l'air d'une preuve.
204
+ *
205
+ * @param source - le contenu complet.
206
+ * @returns `{ body, offset }` — `offset` = lignes consommées par l'en-tête.
207
+ */
208
+ export function splitBody(source) {
209
+ const block = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/u.exec(source);
210
+ if (!block) return { body: source, offset: 0 };
211
+ return {
212
+ body: source.slice(block[0].length),
213
+ offset: block[0].split("\n").length - 1,
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Normalise pour comparer : minuscules, diacritiques retirés.
219
+ *
220
+ * Le corpus est en français et les demandes arrivent sans accents aussi souvent
221
+ * qu'avec — « securite » doit trouver « sécurité », sinon la recherche paraît
222
+ * vide sur le mot le plus courant du corpus.
223
+ *
224
+ * @param value - la chaîne à normaliser.
225
+ * @returns la chaîne comparable.
226
+ */
227
+ export function normalize(value) {
228
+ return value.toLowerCase().normalize("NFD").replace(COMBINING_MARKS, "");
229
+ }
230
+
231
+ /**
232
+ * Classe une page pour une demande.
233
+ *
234
+ * Les poids ne sont pas arbitraires : un terme dans le TITRE désigne la page
235
+ * entière, un terme dans un `topic` ou un `tag` désigne son sujet, un terme dans
236
+ * un titre de section désigne un passage, et un terme dans le corps ne désigne
237
+ * qu'une mention — qui peut n'être qu'une note de bas de page.
238
+ *
239
+ * @param page - la page indexée.
240
+ * @param terms - les termes déjà normalisés.
241
+ * @returns `{ score, hits }` — `hits` porte les lignes retenues.
242
+ */
243
+ export function score(page, terms) {
244
+ let total = 0;
245
+ const hits = [];
246
+ const title = normalize(page.title);
247
+ const topic = normalize(page.topic);
248
+ const tags = page.tags.map(normalize);
249
+
250
+ for (const term of terms) {
251
+ if (title.includes(term)) total += 12;
252
+ if (topic === term) total += 10;
253
+ else if (topic !== "" && topic.includes(term)) total += 5;
254
+ if (tags.some((tag) => tag === term)) total += 8;
255
+ else if (tags.some((tag) => tag.includes(term))) total += 3;
256
+ }
257
+
258
+ // Le corps : une ligne qui porte TOUS les termes vaut bien plus que deux
259
+ // lignes qui en portent un chacune — c'est ce qui fait remonter le passage
260
+ // traitant vraiment de la conjonction demandée.
261
+ page.lines.forEach((text, lineIndex) => {
262
+ const haystack = normalize(text);
263
+ const present = terms.filter((term) => haystack.includes(term));
264
+ if (present.length === 0) return;
265
+ const all = present.length === terms.length;
266
+ const heading = /^#{1,6}\s/u.test(text);
267
+ total += all ? 4 : 1;
268
+ if (heading) total += all ? 6 : 2;
269
+ hits.push({
270
+ line: lineIndex + 1 + page.offset,
271
+ text: text.trim(),
272
+ all,
273
+ heading,
274
+ });
275
+ });
276
+
277
+ return { score: total, hits };
278
+ }
279
+
280
+ /**
281
+ * Indexe toutes les pages installées.
282
+ *
283
+ * @param root - la racine du projet.
284
+ * @returns les pages, ou un tableau vide si rien n'est installé.
285
+ */
286
+ export function index(root) {
287
+ const pages = [];
288
+ for (const dir of docDirectories(root)) {
289
+ for (const file of markdownFiles(dir)) {
290
+ let source = "";
291
+ try {
292
+ source = readFileSync(file, "utf8");
293
+ } catch {
294
+ continue;
295
+ }
296
+ const header = readHeader(source);
297
+ const { body, offset } = splitBody(source);
298
+ pages.push({
299
+ ...header,
300
+ // Le chemin VOYAGE — il s'affiche et se recopie dans une commande : il
301
+ // s'écrit donc en `/` sur les trois plateformes. Ce qu'on OUVRE, plus
302
+ // haut, est resté natif.
303
+ file: path.relative(root, file).split(path.sep).join("/"),
304
+ lines: body.split("\n"),
305
+ offset,
306
+ });
307
+ }
308
+ }
309
+ return pages;
310
+ }
311
+
312
+ /**
313
+ * Le refus quand aucune documentation n'est installée.
314
+ *
315
+ * ⚠️ À ne PAS confondre avec « ce n'est pas documenté ». Un projet dont les
316
+ * dépendances ne sont pas installées n'a pas de documentation à lire — le dire
317
+ * est une information ; s'en taire envoie réécrire à la main ce qu'on n'a pas
318
+ * pu lire.
319
+ *
320
+ * @param root - la racine examinée.
321
+ * @returns le texte du refus, qui nomme la cause ET le geste.
322
+ */
323
+ export function missingDocsMessage(root) {
324
+ const modules = path.join(root, "node_modules");
325
+ let installed = false;
326
+ try {
327
+ installed = statSync(modules).isDirectory();
328
+ } catch {
329
+ /* absent */
330
+ }
331
+ return installed
332
+ ? `Aucun paquet Nodefony avec un dossier docs/ sous ${path.join(modules, "@nodefony")}.\n` +
333
+ "Ce projet n'est peut-être pas une application Nodefony — ou ses paquets ne sont pas installés."
334
+ : `node_modules/ n'existe pas sous ${root} : la documentation n'est pas là.\n` +
335
+ "Ce n'est PAS « le sujet n'est pas documenté ». Lance « npm install », puis rejoue cette commande.";
336
+ }
337
+
338
+ /**
339
+ * Les extraits d'une page, les plus informatifs d'abord.
340
+ *
341
+ * @param hits - les lignes retenues par le classement.
342
+ * @returns au plus {@link SNIPPETS_PER_PAGE} extraits.
343
+ */
344
+ function bestSnippets(hits) {
345
+ return [...hits]
346
+ .sort(
347
+ (a, b) =>
348
+ Number(b.all) - Number(a.all) ||
349
+ Number(b.heading) - Number(a.heading) ||
350
+ a.line - b.line,
351
+ )
352
+ .slice(0, SNIPPETS_PER_PAGE);
353
+ }
354
+
355
+ /**
356
+ * Cherche, et rend le résultat prêt à afficher.
357
+ *
358
+ * @param opts - les options lues par {@link parseArgs}.
359
+ * @returns `{ pages, total, indexed }` — `pages` déjà tronqué à la limite.
360
+ */
361
+ export function search(opts) {
362
+ const pages = index(opts.root);
363
+ const terms = opts.terms.map(normalize).filter(Boolean);
364
+ const ranked = pages
365
+ .map((page) => ({ page, ...score(page, terms) }))
366
+ .filter((entry) => entry.score > 0)
367
+ .sort(
368
+ (a, b) => b.score - a.score || a.page.file.localeCompare(b.page.file),
369
+ );
370
+ return {
371
+ pages: ranked.slice(0, opts.limit),
372
+ total: ranked.length,
373
+ indexed: pages.length,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Le mode `--open` : désigner une page sans la chercher.
379
+ *
380
+ * @param opts - les options lues.
381
+ * @returns les pages dont le sujet, le titre ou le chemin correspond.
382
+ */
383
+ export function resolvePage(opts) {
384
+ const target = normalize(opts.open);
385
+ return index(opts.root).filter(
386
+ (page) =>
387
+ normalize(page.topic) === target ||
388
+ normalize(page.file).includes(target) ||
389
+ (page.title !== "" && normalize(page.title).includes(target)),
390
+ );
391
+ }
392
+
393
+ /** Retire du rendu les champs de travail, qui ne servent qu'au classement. */
394
+ const publicShape = ({ lines: _lines, offset: _offset, ...rest }) => rest;
395
+
396
+ const USAGE = `
397
+ Cherche dans la documentation INSTALLÉE des paquets Nodefony — celle qu'une
398
+ recherche ordinaire ne voit pas, parce qu'elle vit sous node_modules/.
399
+
400
+ docs.mjs <termes...> les pages qui répondent, avec chemin et ligne
401
+ docs.mjs --list les pages installées, par module
402
+ docs.mjs --open <sujet> le chemin d'une page désignée
403
+ docs.mjs --json la même réponse, sérialisée
404
+ docs.mjs --limit <n> nombre de pages rendues (défaut ${DEFAULT_LIMIT})
405
+ docs.mjs --root <chemin> racine du projet (défaut : dossier courant)
406
+
407
+ Codes de sortie : 0 trouvé · 1 rien trouvé · 64 mauvais usage · 78 rien d'installé
408
+ `.trim();
409
+
410
+ /**
411
+ * Point d'entrée.
412
+ *
413
+ * @returns le code de sortie du processus.
414
+ */
415
+ export function main() {
416
+ let opts;
417
+ try {
418
+ opts = parseArgs(process.argv.slice(2));
419
+ } catch (error) {
420
+ process.stderr.write(`${error.message}\n\n${USAGE}\n`);
421
+ return 64;
422
+ }
423
+ if (opts.help) {
424
+ process.stdout.write(`${USAGE}\n`);
425
+ return 0;
426
+ }
427
+
428
+ const pages = index(opts.root);
429
+ if (pages.length === 0) {
430
+ process.stderr.write(`${missingDocsMessage(opts.root)}\n`);
431
+ return 78;
432
+ }
433
+
434
+ if (opts.list) {
435
+ if (opts.json) {
436
+ process.stdout.write(
437
+ `${JSON.stringify(pages.map(publicShape), null, 2)}\n`,
438
+ );
439
+ return 0;
440
+ }
441
+ const byModule = new Map();
442
+ for (const page of pages) {
443
+ const key = page.module || "(sans module)";
444
+ if (!byModule.has(key)) byModule.set(key, []);
445
+ byModule.get(key).push(page);
446
+ }
447
+ process.stdout.write(`${pages.length} pages installées\n\n`);
448
+ for (const [module, group] of [...byModule].sort((a, b) =>
449
+ a[0].localeCompare(b[0]),
450
+ )) {
451
+ process.stdout.write(`${module}\n`);
452
+ for (const page of group)
453
+ process.stdout.write(
454
+ ` ${(page.topic || "—").padEnd(22)} ${page.title}\n ${page.file}\n`,
455
+ );
456
+ process.stdout.write("\n");
457
+ }
458
+ return 0;
459
+ }
460
+
461
+ if (opts.open !== null) {
462
+ const matches = resolvePage(opts);
463
+ if (matches.length === 0) {
464
+ process.stderr.write(
465
+ `Aucune page ne correspond à « ${opts.open} ». « --list » les énumère.\n`,
466
+ );
467
+ return 1;
468
+ }
469
+ if (opts.json) {
470
+ process.stdout.write(
471
+ `${JSON.stringify(matches.map(publicShape), null, 2)}\n`,
472
+ );
473
+ return 0;
474
+ }
475
+ for (const page of matches) process.stdout.write(`${page.file}\n`);
476
+ return 0;
477
+ }
478
+
479
+ if (opts.terms.length === 0) {
480
+ process.stderr.write(`Aucun terme à chercher.\n\n${USAGE}\n`);
481
+ return 64;
482
+ }
483
+
484
+ const result = search(opts);
485
+ if (opts.json) {
486
+ process.stdout.write(
487
+ `${JSON.stringify(
488
+ {
489
+ terms: opts.terms,
490
+ indexed: result.indexed,
491
+ total: result.total,
492
+ pages: result.pages.map((entry) => ({
493
+ file: entry.page.file,
494
+ title: entry.page.title,
495
+ module: entry.page.module,
496
+ topic: entry.page.topic,
497
+ score: entry.score,
498
+ snippets: bestSnippets(entry.hits),
499
+ })),
500
+ },
501
+ null,
502
+ 2,
503
+ )}\n`,
504
+ );
505
+ return result.pages.length === 0 ? 1 : 0;
506
+ }
507
+
508
+ if (result.pages.length === 0) {
509
+ process.stdout.write(
510
+ `Rien sur « ${opts.terms.join(" ")} » dans les ${result.indexed} pages installées.\n` +
511
+ "Essaie un seul mot, ou « --list » pour voir les sujets couverts.\n",
512
+ );
513
+ return 1;
514
+ }
515
+
516
+ const shown = result.pages.length;
517
+ process.stdout.write(
518
+ `${result.total} page${result.total > 1 ? "s" : ""} sur « ${opts.terms.join(" ")} »` +
519
+ `${result.total > shown ? ` — les ${shown} premières` : ""}\n\n`,
520
+ );
521
+ for (const entry of result.pages) {
522
+ process.stdout.write(
523
+ `${entry.page.title || entry.page.file} [${entry.page.module || "—"}]\n`,
524
+ );
525
+ process.stdout.write(` ${entry.page.file}\n`);
526
+ for (const snippet of bestSnippets(entry.hits))
527
+ process.stdout.write(
528
+ ` ${String(snippet.line).padStart(5)}: ${snippet.text.slice(0, 120)}\n`,
529
+ );
530
+ process.stdout.write("\n");
531
+ }
532
+ return 0;
533
+ }
534
+
535
+ // Exécuté directement (et non importé par son auto-contrôle) : rendre le code.
536
+ // La comparaison passe par une URL, jamais par un chemin : sous Windows, `D:\…`
537
+ // verrait son `d:` lu comme un protocole, et un `endsWith` sur le nom de base
538
+ // confondrait deux scripts homonymes de deux skills.
539
+ if (
540
+ process.argv[1] !== undefined &&
541
+ pathToFileURL(process.argv[1]).href === import.meta.url
542
+ ) {
543
+ process.exitCode = main();
544
+ }