@nodefony/devkit 10.0.0-alpha.1
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.
- package/LICENSE +544 -0
- package/README.md +318 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +47 -0
- package/dist/nodefony/command/CardCommand.js +70 -0
- package/dist/nodefony/config/config.js +200 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controllers/DevkitController.js +60 -0
- package/dist/nodefony/controllers/McpController.js +223 -0
- package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
- package/dist/nodefony/interfaces/IDevkitService.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/DevkitService.js +198 -0
- package/dist/nodefony/src/card.js +2 -0
- package/dist/nodefony/src/errors/DevkitError.js +21 -0
- package/dist/nodefony/src/mcp/guard.js +51 -0
- package/dist/nodefony/src/mcp/protocol.js +127 -0
- package/dist/nodefony/src/mcp/server.js +133 -0
- package/dist/nodefony/src/mcp/tools.js +163 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
- package/dist/types/nodefony/config/config.d.ts +25 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
- package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
- package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
- package/dist/types/nodefony/src/card.d.ts +18 -0
- package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
- package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
- package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
- package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
- package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
- package/docs/index.md +358 -0
- package/package.json +77 -0
- package/skills/nodefony-add-crud/SKILL.md +199 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
- package/skills/nodefony-add-service/SKILL.md +90 -0
- package/skills/nodefony-browser/SKILL.md +416 -0
- package/skills/nodefony-browser/references/socket.md +115 -0
- package/skills/nodefony-browser/references/sondes.md +175 -0
- package/skills/nodefony-browser/scripts/audit.mjs +169 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
- package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
- package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
- package/skills/nodefony-browser/scripts/socket.mjs +354 -0
- package/skills/nodefony-browser/scripts/watch.mjs +125 -0
- package/skills/nodefony-migrate-schema/SKILL.md +359 -0
- package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
- package/skills/nodefony-protect-route/SKILL.md +195 -0
package/docs/index.md
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "@nodefony/devkit — l'outillage de développement d'une application"
|
|
3
|
+
navTitle: devkit
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/devkit"
|
|
6
|
+
topic: overview
|
|
7
|
+
audience: [human, ai]
|
|
8
|
+
tags: [module, developpement, agent]
|
|
9
|
+
status: stable
|
|
10
|
+
updated: 2026-09-01
|
|
11
|
+
source: "src/packages/@nodefony/devkit/docs/index.md"
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# devkit
|
|
15
|
+
|
|
16
|
+
> L'outillage de développement d'une application : sa carte de visite, les
|
|
17
|
+
> skills qui disent à un agent comment faire les tâches courantes, et les portes
|
|
18
|
+
> qui mènent au reste.
|
|
19
|
+
|
|
20
|
+
Cette page est **surfacée dans Studio** (onglet Docs du module).
|
|
21
|
+
|
|
22
|
+
📍 [Documentation](../../../../../docs/index.md) › **@nodefony/devkit**
|
|
23
|
+
|
|
24
|
+
## Par où commencer
|
|
25
|
+
|
|
26
|
+
```nodefony-cards
|
|
27
|
+
[
|
|
28
|
+
{ "icon": "🪪", "title": "La carte de visite", "href": "#le-problème-quil-résout",
|
|
29
|
+
"desc": "`npx nodefony card` : qui répond ici, quels modules, où lire, quoi lancer. Servie par le cœur — elle marche même sans application construite." },
|
|
30
|
+
{ "icon": "🔌", "title": "Le serveur MCP", "href": "#le-serveur-mcp--les-mêmes-réponses-en-outils",
|
|
31
|
+
"desc": "Les mêmes réponses, mais en outils qu'un agent appelle — dont l'autorisation OAuth 2.1 et vos propres outils." },
|
|
32
|
+
{ "icon": "🎓", "title": "Les skills d'agent", "href": "#les-skills-dagent--répondre-à--comment-fait-on-ça-ici--",
|
|
33
|
+
"desc": "Ce qui répond à « comment fait-on ça, ici ? » sans que l'agent invente une convention." },
|
|
34
|
+
{ "icon": "🚫", "title": "Ce qu'il ne fait pas", "href": "#ce-quil-ne-fait-pas",
|
|
35
|
+
"desc": "La frontière du module — et pourquoi il n'existe pas en production." }
|
|
36
|
+
]
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Le problème qu'il résout
|
|
40
|
+
|
|
41
|
+
Une application Nodefony sait beaucoup de choses sur elle-même — ses modules, ses
|
|
42
|
+
routes, sa configuration, la documentation installée avec chaque paquet. Mais
|
|
43
|
+
elle ne le **dit** à personne. Celui qui arrive — un développeur qui reprend le
|
|
44
|
+
projet, un agent qui code — n'a d'autre choix que de deviner : lire les sources,
|
|
45
|
+
supposer une convention, inventer une route.
|
|
46
|
+
|
|
47
|
+
Le devkit répond à la question d'ouverture, et à elle seule : **qui répond ici,
|
|
48
|
+
et où faut-il aller ensuite ?**
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx nodefony card # -j pour du JSON (| jq)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
La réponse tient en trois blocs : l'identité (nom, version, environnement, cœur),
|
|
55
|
+
les modules, puis **où lire** et **quoi lancer**.
|
|
56
|
+
|
|
57
|
+
Cette commande-là est servie par le **cœur**, pas par ce module : une carte de
|
|
58
|
+
visite qui exigerait une application déjà construite, ou une variable
|
|
59
|
+
d'environnement posée, serait fermée au moment exact où l'on en a besoin. Elle ne
|
|
60
|
+
lit que des fichiers — et quand rien n'a démarré, elle annonce des modules
|
|
61
|
+
**installés** plutôt que chargés, en renvoyant à `npx nodefony inspect modules`.
|
|
62
|
+
|
|
63
|
+
## Pourquoi il n'existe pas en production
|
|
64
|
+
|
|
65
|
+
Ce que la carte expose — modules chargés, chemins de documentation, commandes —
|
|
66
|
+
aide pendant le développement. En production, c'est une description de votre
|
|
67
|
+
architecture offerte à qui la demande : une divulgation, pas une fonctionnalité.
|
|
68
|
+
|
|
69
|
+
D'où la double protection, et les deux moitiés comptent :
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// nodefony.config.ts — posé par `nodefony create app`
|
|
73
|
+
use("@nodefony/devkit", {}, { policy: "dev" }),
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **`devDependencies`** : `npm ci --omit=dev` ne l'installe pas ;
|
|
77
|
+
- **`policy: "dev"`** : un déploiement qui installerait tout ne le charge pas
|
|
78
|
+
quand même. Un module non chargé n'est **même pas importé** — le coût en
|
|
79
|
+
production est nul, pas « faible ».
|
|
80
|
+
|
|
81
|
+
Corollaire à connaître : hors développement, c'est **la route** qui n'existe pas.
|
|
82
|
+
La commande, elle, répond — elle ne passe pas par ce module.
|
|
83
|
+
|
|
84
|
+
## Quatre portes, une seule source
|
|
85
|
+
|
|
86
|
+
| Porte | Pour qui |
|
|
87
|
+
| ------------------------------- | ------------------------------------------------------------ |
|
|
88
|
+
| `npx nodefony card` | un agent, un humain au terminal — servie par le cœur |
|
|
89
|
+
| `GET /nodefony/devkit/api/card` | Studio, un script authentifié — modules réellement CHARGÉS |
|
|
90
|
+
| `POST /nodefony/mcp` | un agent qui appelle des **outils** (Model Context Protocol) |
|
|
91
|
+
| `buildCard()` (export du cœur) | une porte de plus, à écrire — rien à réimplémenter |
|
|
92
|
+
|
|
93
|
+
**Ajouter une porte n'ajoute jamais une vérité** : toutes lisent le même
|
|
94
|
+
service, qui dérive le même Kernel. La construction elle-même vit dans une
|
|
95
|
+
fonction pure (`buildCard`) qui reçoit son état au lieu de le lire — c'est ce qui
|
|
96
|
+
la rend éprouvable sans serveur, et ce qui l'empêche d'inventer quoi que ce soit.
|
|
97
|
+
|
|
98
|
+
> La route de la carte vit sous `/nodefony/<module>/api`, que le pare-feu d'une
|
|
99
|
+
> application réelle couvre : un agent qui code ne s'authentifie pas et n'a pas
|
|
100
|
+
> de navigateur. La porte qui compte pour lui est la commande — ou le serveur
|
|
101
|
+
> MCP ci-dessous.
|
|
102
|
+
|
|
103
|
+
## Le serveur MCP — les mêmes réponses, en outils
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npx nodefony ai:mcp # écrit .mcp.json ; --dry-run pour voir sans écrire
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Quatre outils, qui sont les commandes que vous connaissez déjà :
|
|
110
|
+
`nodefony_inspect` (ce qui est monté), `nodefony_check` (ce qui manque),
|
|
111
|
+
`nodefony_symbols` (ce qu'une API signifie), `nodefony_card` (par où commencer).
|
|
112
|
+
|
|
113
|
+
**Il n'y a pas de process à lancer.** Depuis la révision `2026-07-28` du
|
|
114
|
+
transport, un serveur MCP est un endpoint `POST` sans session : c'est donc une
|
|
115
|
+
route de votre application. Elle vit tant qu'elle tourne, suit chaque
|
|
116
|
+
rechargement du serveur de développement, et n'a aucun cache à invalider.
|
|
117
|
+
|
|
118
|
+
**La déclaration reste dans VOTRE projet.** `ai:mcp` écrit `.mcp.json` à la
|
|
119
|
+
racine du projet — jamais dans une configuration globale. Ce n'est pas un détail
|
|
120
|
+
de rangement : l'URL porte un **port**, et deux applications Nodefony ouvertes en
|
|
121
|
+
même temps n'écoutent pas sur le même. Une déclaration globale en désignerait
|
|
122
|
+
forcément une, au hasard. (Pour Mistral Vibe, l'équivalent par projet est
|
|
123
|
+
`.vibe/config.toml`, section `[[mcp_servers]]`.)
|
|
124
|
+
|
|
125
|
+
**Cinq révisions servies** — `2026-07-28`, `2025-11-25`, `2025-06-18`,
|
|
126
|
+
`2025-03-26`, `2024-11-05` — et le serveur **répond celle que le client
|
|
127
|
+
demande**. Annoncer la plus récente à tout le monde rend la porte injoignable :
|
|
128
|
+
un SDK qui ne connaît pas encore cette révision raccroche, et le serveur le plus
|
|
129
|
+
conforme du monde ne parle alors à personne.
|
|
130
|
+
|
|
131
|
+
Trois choses à savoir avant de s'étonner d'un refus :
|
|
132
|
+
|
|
133
|
+
- une **adresse non locale** reçoit `403` (`mcp.allowRemote`) ;
|
|
134
|
+
- une **origine de navigateur** non déclarée reçoit `403` (`mcp.allowedOrigins`).
|
|
135
|
+
Un client MCP natif n'envoie pas d'`Origin` ; une page web en envoie toujours
|
|
136
|
+
un — c'est ce qui ferme le détournement DNS, seul vecteur réel contre un
|
|
137
|
+
serveur local ;
|
|
138
|
+
- le module étant `policy: "dev"`, **la route n'existe pas en production**.
|
|
139
|
+
|
|
140
|
+
Les outils intégrés sont en lecture seule, et `mcp.tools` est une allowlist.
|
|
141
|
+
|
|
142
|
+
### Obtenir le porteur — `security:token`
|
|
143
|
+
|
|
144
|
+
Dès que la porte demande un jeton, il faut en produire un. L'application **le signe elle-même**, en
|
|
145
|
+
ligne de commande, sans qu'aucun serveur n'écoute :
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npx nodefony security:token --write # pose NF_MCP_TOKEN chez les agents présents
|
|
149
|
+
npx nodefony ai:mcp --auth # l'en-tête porte ${NF_MCP_TOKEN}, jamais le jeton
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
`.mcp.json` ne contient donc **que le nom de la variable**. Le flux complet — audience à déclarer,
|
|
153
|
+
durée, rotation, et le refus qu'on rencontre en premier — vit dans
|
|
154
|
+
[obtenir un jeton](../../security/docs/obtenir-un-jeton.md).
|
|
155
|
+
|
|
156
|
+
### Passer la porte sous autorisation OAuth 2.1
|
|
157
|
+
|
|
158
|
+
Le périmètre ci-dessus suffit à un poste de développement. Dès que la porte doit
|
|
159
|
+
répondre à quelqu'un d'autre, déclarez un serveur d'autorisation : elle devient
|
|
160
|
+
alors un **resource server** au sens de la spécification — elle publie ses
|
|
161
|
+
métadonnées ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)), valide
|
|
162
|
+
le porteur, refuse en `401`/`WWW-Authenticate`
|
|
163
|
+
([RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750)) et vérifie
|
|
164
|
+
l'audience ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html)).
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
use("@nodefony/devkit", {
|
|
168
|
+
mcp: {
|
|
169
|
+
authorization: {
|
|
170
|
+
authorizationServers: ["https://auth.example"],
|
|
171
|
+
resource: "https://mon-app.example/nodefony/mcp",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| Ce qui se passe alors | Où |
|
|
178
|
+
| ------------------------------------------------------------ | -------------------------------------------------------- |
|
|
179
|
+
| Le document de métadonnées est publié | `GET /.well-known/oauth-protected-resource/nodefony/mcp` |
|
|
180
|
+
| Une requête sans jeton — ou sans jeton LISIBLE — est refusée | `401` + `WWW-Authenticate: Bearer resource_metadata="…"` |
|
|
181
|
+
| Un jeton d'une autre audience n'ouvre rien | `401` — ou servi en **anonyme** si `anonymous: true` |
|
|
182
|
+
| Un outil déclarant des `scopes` devient atteignable | pour qui présente **tous** ces scopes |
|
|
183
|
+
| Les scopes publiés sont l'**union** de ceux des outils | `scopes_supported` du document, et `scope` du défi |
|
|
184
|
+
|
|
185
|
+
Le serveur d'**autorisation** n'est jamais à écrire : la spécification le place
|
|
186
|
+
« beyond the scope […] or a separate entity ». N'importe quel émetteur OAuth 2.1
|
|
187
|
+
convient.
|
|
188
|
+
|
|
189
|
+
> 🔴 **Ce module ne valide pas les jetons lui-même** — il est `policy: "dev"` et
|
|
190
|
+
> ne porte aucune cryptographie. Il cherche un service `accessTokenVerifier` dans le
|
|
191
|
+
> conteneur (contrat `IAccessTokenVerifier`, exporté par `nodefony`). Sans lui,
|
|
192
|
+
> une porte déclarée protégée répond `503` et le journal le dit en `CRITIC` :
|
|
193
|
+
> accepter des porteurs sans les lire serait pire que rester anonyme.
|
|
194
|
+
|
|
195
|
+
### Ajouter VOS outils — ce que l'agent ne peut pas deviner
|
|
196
|
+
|
|
197
|
+
Les quatre outils ci-dessus décrivent le framework. Ils ne savent rien de votre
|
|
198
|
+
métier — et c'est précisément ce qu'un agent invente le plus mal. N'importe quel
|
|
199
|
+
module de votre application peut donc en publier :
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
import { Module, mcpText, type IMcpTool } from "nodefony";
|
|
203
|
+
|
|
204
|
+
class Shop extends Module {
|
|
205
|
+
getMcpTools(): IMcpTool[] {
|
|
206
|
+
return [
|
|
207
|
+
{
|
|
208
|
+
name: "shop_stock",
|
|
209
|
+
// ⭐ La description est ce qui DÉCLENCHE l'outil. Dire ce qu'il rend
|
|
210
|
+
// ET quand s'en servir — un modèle n'appelle pas ce qu'il ne
|
|
211
|
+
// comprend pas.
|
|
212
|
+
description:
|
|
213
|
+
"Stock réel d'une référence produit. À utiliser avant de proposer " +
|
|
214
|
+
"une commande — la réponse vient de la base, pas d'un cache.",
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: "object",
|
|
217
|
+
properties: { sku: { type: "string", description: "Référence" } },
|
|
218
|
+
required: ["sku"],
|
|
219
|
+
},
|
|
220
|
+
handler: async (args) => mcpText(await this.stock(String(args.sku))),
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Trois choses à savoir :
|
|
228
|
+
|
|
229
|
+
- **rien ne s'enregistre au démarrage** : la liste est relue à chaque requête,
|
|
230
|
+
donc un module ajouté apparaît sans redémarrer quoi que ce soit ;
|
|
231
|
+
- **`mcp.tools` ne filtre que les outils intégrés** — le vôtre est publié dès
|
|
232
|
+
qu'il est déclaré, sans ligne de configuration supplémentaire ;
|
|
233
|
+
- un outil **écarté** (nom hors `[a-zA-Z0-9_-]{1,64}`, nom déjà pris, handler
|
|
234
|
+
absent, déclaration qui lève) le dit en `WARNING` dans les journaux du
|
|
235
|
+
serveur — il ne disparaît jamais en silence. Les outils intégrés gagnent
|
|
236
|
+
toute collision : personne ne peut répondre à la place de `nodefony_inspect`.
|
|
237
|
+
|
|
238
|
+
### Réserver un outil à qui est autorisé
|
|
239
|
+
|
|
240
|
+
Un outil peut exiger des **scopes** (tous, pas au moins un) et/ou une identité
|
|
241
|
+
prouvée. Son handler reçoit alors l'appelant en second paramètre, pour borner ce
|
|
242
|
+
qu'il **rend** et pas seulement décider s'il répond :
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
{
|
|
246
|
+
name: "shop_invoice",
|
|
247
|
+
description: "Facture d'une commande.",
|
|
248
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } } },
|
|
249
|
+
scopes: ["shop:read", "shop:billing"], // ou : requiresAuth: true
|
|
250
|
+
handler: async (args, caller) =>
|
|
251
|
+
mcpText(await this.invoice(String(args.id), caller.subject)),
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
La spec le prévoit explicitement : le jeu d'outils « MAY vary by the
|
|
256
|
+
authorization presented on the request — for example, returning only the tools
|
|
257
|
+
the caller's granted scopes permit », précisément parce que les identifiants
|
|
258
|
+
sont une **entrée de requête, pas un état de connexion**. C'est pourquoi la
|
|
259
|
+
liste est recollectée à chaque appel.
|
|
260
|
+
|
|
261
|
+
Le filtre s'applique **à la collecte**, donc en un seul point : un outil retenu
|
|
262
|
+
est absent de `tools/list` **et** inappelable en le nommant, et le refus dit
|
|
263
|
+
« outil inconnu » plutôt qu'« interdit » — son existence même n'est pas révélée.
|
|
264
|
+
|
|
265
|
+
> 🔴 **Tant que la porte n'authentifie personne, un outil à scopes ne sortira
|
|
266
|
+
> jamais.** C'est le comportement voulu — fermé par défaut — mais il faut le
|
|
267
|
+
> savoir avant de chercher une panne : `caller` vaut `{ authenticated: false,
|
|
268
|
+
scopes: [] }` tant que le rôle _resource server_ décrit plus haut n'est pas
|
|
269
|
+
> branché. Le jour où il le sera, ces déclarations prendront effet sans qu'une
|
|
270
|
+
> ligne d'outil change.
|
|
271
|
+
|
|
272
|
+
> ⚠️ Cette porte n'est pas authentifiée (voir l'écart ci-dessus) et le module est
|
|
273
|
+
> `policy: "dev"`. Avant d'exposer une donnée par un outil, se demander si elle
|
|
274
|
+
> supporterait d'être lue **sans identification**, par qui a accès à la machine.
|
|
275
|
+
|
|
276
|
+
## Les skills d'agent — répondre à « comment fait-on ça, ici ? »
|
|
277
|
+
|
|
278
|
+
La carte dit **où aller**. Elle ne dit pas **comment faire**. Or c'est là qu'un
|
|
279
|
+
agent invente : faute d'une marche à suivre, il écrit un CRUD à la main, un
|
|
280
|
+
service à méthodes `static` que le conteneur ne voit pas, un contrôle de droits
|
|
281
|
+
dans le corps de l'action.
|
|
282
|
+
|
|
283
|
+
Le paquet livre donc cinq **skills** au format [Agent Skills](https://agentskills.io),
|
|
284
|
+
un par tâche où l'invention coûte cher :
|
|
285
|
+
|
|
286
|
+
| Skill | Le besoin qu'il couvre |
|
|
287
|
+
| ------------------------------- | ----------------------------------------------------------- |
|
|
288
|
+
| `nodefony-add-crud` | exposer une ressource REST complète, entité comprise |
|
|
289
|
+
| `nodefony-add-service` | ajouter de la logique métier réutilisable, vue du conteneur |
|
|
290
|
+
| `nodefony-protect-route` | réserver une route à qui est habilité |
|
|
291
|
+
| `nodefony-add-realtime-channel` | ouvrir un canal temps réel où le serveur pousse |
|
|
292
|
+
| `nodefony-browser` | voir **et mesurer** un écran, sans navigateur sur le poste |
|
|
293
|
+
|
|
294
|
+
Tous portent le préfixe `nodefony-` : leurs pointeurs arrivent dans le dossier où
|
|
295
|
+
vous écrivez aussi les vôtres, et sans namespace un skill maison du même nom
|
|
296
|
+
serait écrasé à la synchronisation suivante.
|
|
297
|
+
|
|
298
|
+
`nodefony create app` les met à disposition à la création ; après une montée de
|
|
299
|
+
version, `npx nodefony ai:sync` les remet à jour (`--dry-run` montre sans
|
|
300
|
+
écrire).
|
|
301
|
+
|
|
302
|
+
**Ce qui est écrit dans le projet est un pointeur, jamais une copie.** Le contenu
|
|
303
|
+
reste dans le paquet installé et suit `npm update` : une recette recopiée dans un
|
|
304
|
+
projet décrit, six mois plus tard, un framework qui a changé — et comme rien ne
|
|
305
|
+
casse, personne ne s'en aperçoit. C'est le même principe que le reste du devkit :
|
|
306
|
+
**rien de figé n'est copié chez l'utilisateur.**
|
|
307
|
+
|
|
308
|
+
Le dossier visé — `.agents/skills/` — est celui que tous les clients conformes
|
|
309
|
+
lisent, plutôt que le dossier propriétaire d'un seul d'entre eux. Ces fichiers
|
|
310
|
+
sont faits pour être commités : l'équipe entière et l'intégration continue
|
|
311
|
+
travaillent alors avec les mêmes recettes.
|
|
312
|
+
|
|
313
|
+
> Aucun `postinstall` ne les pose : `--ignore-scripts` est courant, les scripts
|
|
314
|
+
> d'installation sont un vecteur d'attaque connu de l'écosystème npm, et écrire
|
|
315
|
+
> dans un dossier versionné à chaque installation produirait des différences
|
|
316
|
+
> surprises.
|
|
317
|
+
|
|
318
|
+
## Ce qu'il ne fait pas
|
|
319
|
+
|
|
320
|
+
Le scaffold (`nodefony create …`), le diagnostic (`nodefony doctor`) et
|
|
321
|
+
l'introspection (`nodefony inspect`) **ne sont pas ici** : ils vivent dans le
|
|
322
|
+
cœur, parce qu'ils doivent répondre sans qu'aucun module soit installé — et
|
|
323
|
+
surtout quand l'application est cassée. Un outil de diagnostic qui exige que
|
|
324
|
+
l'application démarre ne sert pas au moment où on en a besoin.
|
|
325
|
+
|
|
326
|
+
Même partage pour les skills, et il se retient en une phrase : **le VERBE vit
|
|
327
|
+
dans le cœur, le CONTENU dans ce paquet.** `ai:sync` doit répondre dans un
|
|
328
|
+
terminal qui n'a rien posé (portée par un module `policy: "dev"`, elle serait
|
|
329
|
+
absente sans `NODE_ENV`) ; les skills, eux, doivent se mettre à jour par npm.
|
|
330
|
+
|
|
331
|
+
## Configuration
|
|
332
|
+
|
|
333
|
+
| Clé | Type | Défaut | Rôle |
|
|
334
|
+
| -------------------- | ---------- | -------------------------------------- | ---------------------------------------------- |
|
|
335
|
+
| `enabled` | `boolean` | `true` | Interrupteur du module |
|
|
336
|
+
| `mcp.enabled` | `boolean` | `true` | Répond-on aux requêtes MCP ? Coupé → `404` |
|
|
337
|
+
| `mcp.allowedOrigins` | `string[]` | `[]` | Origines de navigateur admises ; vide = aucune |
|
|
338
|
+
| `mcp.allowRemote` | `boolean` | `false` | Accepter un appel d'une adresse non locale |
|
|
339
|
+
| `mcp.tools` | `string[]` | `["inspect","check","symbols","card"]` | Allowlist des outils — lecture seule |
|
|
340
|
+
|
|
341
|
+
Les clés, leurs types et leurs défauts viennent du schéma Zod
|
|
342
|
+
(`nodefony/config/config.ts`) — **source unique** dont dérivent la
|
|
343
|
+
documentation, la validation au boot et le formulaire d'édition de Studio.
|
|
344
|
+
|
|
345
|
+
## Pour aller plus loin
|
|
346
|
+
|
|
347
|
+
- ⬆️ **Retour au hub** : [documentation Nodefony](../../../../../docs/index.md) — et
|
|
348
|
+
[par où commencer](../../../../../docs/demarrer.md) si vous arrivez sur le framework.
|
|
349
|
+
- 🏗️ **Générer du code plutôt que l'écrire** :
|
|
350
|
+
[`nodefony create`](../../../../../docs/guides/generer-du-code.md) — voir ce qui va changer
|
|
351
|
+
avant que ça change, et l'appeler depuis un agent.
|
|
352
|
+
- 🧪 **Ce que vaut cet outillage, mesuré** :
|
|
353
|
+
[éprouver un framework avec un agent](../../../../../docs/guides/eprouver-loutillage-agent.md).
|
|
354
|
+
- 🖥️ **Les commandes du cœur** : [la CLI](../../../../nodefony/docs/cli.md) — `card` et `inspect`
|
|
355
|
+
y sont servies, pas ici.
|
|
356
|
+
- 📚 **La documentation installée avec les paquets** :
|
|
357
|
+
[`@nodefony/documentation`](../../documentation/docs/index.md).
|
|
358
|
+
- 📖 [Lexique général](../../../../../docs/lexique.md) du framework.
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nodefony/devkit",
|
|
3
|
+
"version": "10.0.0-alpha.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Outillage de développement d'une application Nodefony : carte de visite du projet et portes de découverte pour un agent de développement",
|
|
6
|
+
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/types/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/types/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"docs",
|
|
19
|
+
"skills"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "rolldown -c rolldown.config.ts && tsgo -p tsconfig.declarations.json",
|
|
23
|
+
"typecheck": "tsgo --noEmit -p tsconfig.json && tsgo --noEmit -p tsconfig.tests.json",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"coverage": "vitest run --coverage"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@nodefony/framework": "*",
|
|
29
|
+
"@nodefony/http": "*",
|
|
30
|
+
"nodefony": "*",
|
|
31
|
+
"zod": "^4.4.3",
|
|
32
|
+
"playwright": "^1.50.0",
|
|
33
|
+
"lighthouse": "^13.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@nodefony/framework": "*",
|
|
37
|
+
"@nodefony/http": "*",
|
|
38
|
+
"nodefony": "*"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"axe-core": "4.13.0",
|
|
42
|
+
"tslib": "2.8.1"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"playwright": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"lighthouse": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/nodefony/nodefony-core.git",
|
|
55
|
+
"directory": "src/packages/@nodefony/devkit"
|
|
56
|
+
},
|
|
57
|
+
"homepage": "https://nodefony.github.io/nodefony-core/",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/nodefony/nodefony-core/issues"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"license": "CECILL-B",
|
|
65
|
+
"keywords": [
|
|
66
|
+
"nodefony",
|
|
67
|
+
"devkit",
|
|
68
|
+
"developer-tools",
|
|
69
|
+
"agent",
|
|
70
|
+
"scaffolding",
|
|
71
|
+
"typescript",
|
|
72
|
+
"esm"
|
|
73
|
+
],
|
|
74
|
+
"engines": {
|
|
75
|
+
"node": ">=24.0.0"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nodefony-add-crud
|
|
3
|
+
description: >
|
|
4
|
+
Crée une ressource complète dans une application Nodefony — table, schémas de validation,
|
|
5
|
+
service CRUD, controller REST+WebSocket et tests — par le générateur `nodefony create entity`,
|
|
6
|
+
au lieu de l'écrire à la main. Porte la grammaire de champs (types, relations, index simples et
|
|
7
|
+
composites), les réglages pour épouser une table SQL existante, et les trois vérités qu'on
|
|
8
|
+
découvre autrement en production : la table naît au démarrage, un champ ajouté n'est rattrapé que
|
|
9
|
+
s'il accepte le vide, et la production s'applique par des migrations — dont le cycle complet vit
|
|
10
|
+
dans le skill `nodefony-migrate-schema`. À charger AVANT d'écrire une entité, un repository ou un
|
|
11
|
+
controller de ressource.
|
|
12
|
+
Déclencheurs : "ajoute une entité", "crée un CRUD", "nouvelle table", "modèle de données",
|
|
13
|
+
"ressource REST", "endpoint CRUD", "je veux stocker des articles/commandes/utilisateurs",
|
|
14
|
+
"comment définir un champ", "une relation entre deux entités", "index composite",
|
|
15
|
+
"épouser une table existante", "renommer les colonnes en snake_case".
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# add-crud — une ressource complète, générée
|
|
19
|
+
|
|
20
|
+
> ⚖️ **La confiance n'exclut pas le contrôle.** Ce que le générateur produit se relit ;
|
|
21
|
+
> ce que tu écris à la main se prouve par un test.
|
|
22
|
+
|
|
23
|
+
## Le geste
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx nodefony create entity Article title:string body:text? published:bool
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Une seule commande produit la chaîne entière : la table, son interface de ligne, les schémas de
|
|
30
|
+
validation d'entrée, le service CRUD, le controller (REST **et** WebSocket dans la même méthode)
|
|
31
|
+
et les tests. **N'écris aucun de ces fichiers à la main** — non par principe, mais parce que le
|
|
32
|
+
gabarit porte des détails qui ne se devinent pas : le repository résolu au premier usage (l'ORM ne
|
|
33
|
+
se connecte qu'au démarrage), la pagination bornée **et son tri déclaré**, les codes
|
|
34
|
+
201/204/404/409/422, et l'en-tête `Location`.
|
|
35
|
+
|
|
36
|
+
## La grammaire de champs
|
|
37
|
+
|
|
38
|
+
`nom:type[?|!][:index]` — **non-null par défaut**, `?` rend facultatif, `!` pose une contrainte
|
|
39
|
+
d'unicité, `:index` un index simple.
|
|
40
|
+
|
|
41
|
+
| Type | Ce que ça produit |
|
|
42
|
+
| -------- | ----------------------- |
|
|
43
|
+
| `string` | texte court (une ligne) |
|
|
44
|
+
| `text` | texte long |
|
|
45
|
+
| `int` | entier |
|
|
46
|
+
| `float` | décimal |
|
|
47
|
+
| `bool` | booléen |
|
|
48
|
+
| `json` | document |
|
|
49
|
+
| `date` | horodatage |
|
|
50
|
+
| `uuid` | identifiant |
|
|
51
|
+
|
|
52
|
+
**Une relation** s'écrit `ref:<Entité>` :
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx nodefony create entity Comment body:text ref:Article
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
La colonne de jointure est **indexée d'office** — c'est elle que traverse un `?include=`.
|
|
59
|
+
Les clés étrangères ne sont pas émises : une contrainte déclarée dans le `CREATE TABLE`
|
|
60
|
+
n'atteindrait jamais une base déjà en place. C'est le domaine des migrations.
|
|
61
|
+
|
|
62
|
+
**Un index de table** porte plusieurs colonnes, et c'est le seul à le pouvoir :
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx nodefony create entity Visit siteId:uuid path:string at:date --index "siteId,at" --unique "siteId,path"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Les deux options sont **répétables** — un couple par index. Sur un schéma réel, la majorité des
|
|
69
|
+
index utiles sont composites : c'est ainsi qu'une table est réellement interrogée.
|
|
70
|
+
|
|
71
|
+
## Épouser une table qui existe déjà
|
|
72
|
+
|
|
73
|
+
Trois réglages, et ils ne touchent **que** le SQL — la propriété TypeScript reste `id`, `siteId` :
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx nodefony create entity Session token:string! --table user_sessions --column-case snake --id-name session_id
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Faire suivre le TypeScript aurait transformé un réglage de nommage en refonte : le service, le
|
|
80
|
+
controller, le tri par défaut et les tests générés nomment tous la propriété, pas la colonne.
|
|
81
|
+
|
|
82
|
+
## Toute lecture de liste se BORNE
|
|
83
|
+
|
|
84
|
+
Avant le format, la règle qui décide si l'application tient en production : **un `find` sans
|
|
85
|
+
borne matérialise la table ENTIÈRE.** Indolore sur les quelques lignes du poste de développement,
|
|
86
|
+
fatal sur les dizaines de milliers de la production — et le code est identique dans les deux cas,
|
|
87
|
+
donc rien ne prévient.
|
|
88
|
+
|
|
89
|
+
Le service d'une entité hérite `findPage({ limit: 25 })` : il ne charge que **`limit + 1`** lignes
|
|
90
|
+
et rend `{ items, hasNext }` — la ligne excédentaire est ce qui répond « il en reste », sans
|
|
91
|
+
compter la table. Sinon `find(criteria, { limit })`.
|
|
92
|
+
|
|
93
|
+
Il te faut une projection de colonnes, une CTE, une agrégation ? Descends au natif **avec son
|
|
94
|
+
type** :
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import type { DrizzleDb } from "@nodefony/drizzle";
|
|
98
|
+
const db = orm.getNativeConnection<DrizzleDb>();
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Sans le paramètre de type tu reçois `unknown`, et il ne te reste qu'un `as any` — que le contrôle
|
|
102
|
+
refuse.
|
|
103
|
+
|
|
104
|
+
## La liste rend une PAGE — et il n'y a qu'un dialecte
|
|
105
|
+
|
|
106
|
+
La route de liste ne rend pas un tableau : elle rend
|
|
107
|
+
`{ items, limit, offset, hasNext, total? }`. Un tableau ne dit pas s'il en reste — le client qui
|
|
108
|
+
reçoit 25 lignes ne peut pas distinguer « c'est tout » de « demande la suite ».
|
|
109
|
+
|
|
110
|
+
Quatre paramètres, les mêmes **partout** dans Nodefony (tes routes, celles du framework, la console
|
|
111
|
+
d'administration) :
|
|
112
|
+
|
|
113
|
+
| Paramètre | Exemple | Effet |
|
|
114
|
+
| ----------------- | ------------------------------ | ------------------------------------------------------------------ |
|
|
115
|
+
| `limit` | `?limit=50` | taille de page, bornée par le plafond de la route |
|
|
116
|
+
| `offset` | `?offset=100` | décalage |
|
|
117
|
+
| **`order`** | `?order=createdAt:DESC,id:ASC` | tri, plusieurs champs, sens explicite |
|
|
118
|
+
| `withTotal=false` | `?withTotal=false` | économise le `COUNT(*)` quand on n'affiche pas les numéros de page |
|
|
119
|
+
|
|
120
|
+
**Un champ non triable est refusé par un 400**, jamais accepté puis ignoré : une page rendue dans
|
|
121
|
+
un ordre qui n'est pas celui demandé, sans un mot, est un mensonge que personne ne voit. Les champs
|
|
122
|
+
acceptés sont la constante `SORTABLE` en tête du controller généré — c'est là qu'on en ajoute ou
|
|
123
|
+
qu'on en retire un.
|
|
124
|
+
|
|
125
|
+
> 🔴 **N'écris JAMAIS ton propre lecteur de `limit`/`offset`/`sort`.** `parsePageQuery` (exporté par
|
|
126
|
+
> `nodefony`) est LE traducteur : il lit tout d'un coup et applique l'allowlist. Deux dialectes dans
|
|
127
|
+
> une même application divergent, et c'est le client qui l'apprend. Pire, **deux appels dans le
|
|
128
|
+
> MÊME handler** dont un seul connaît l'allowlist font refuser en 400 ce que l'autre vient
|
|
129
|
+
> d'accepter — aucun test unitaire ne le voit, chaque appel étant correct isolément.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
const page = parsePageQuery(query, {
|
|
133
|
+
defaultLimit: 25,
|
|
134
|
+
maxLimit: 100,
|
|
135
|
+
sortable: SORTABLE,
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Ce contrat vaut aussi quand tu écris une liste **à la main** (un endpoint d'administration, un
|
|
140
|
+
listing filtré) : le côté serveur déclare ce qu'il sait trier, le point d'entrée le demande, et le
|
|
141
|
+
refus tombe tout seul.
|
|
142
|
+
|
|
143
|
+
## Les trois vérités à savoir avant de livrer
|
|
144
|
+
|
|
145
|
+
1. **La table naît au prochain démarrage en développement** (`CREATE TABLE IF NOT EXISTS`).
|
|
146
|
+
2. **La modifier n'altère rien** — aucun `ALTER` n'est émis. Une colonne ajoutée à une entité déjà
|
|
147
|
+
créée n'apparaîtra pas dans une base existante.
|
|
148
|
+
3. **La production ne fabrique JAMAIS le schéma.** Elle l'applique par des migrations, écrites par
|
|
149
|
+
`npx nodefony orm:generate` et posées par `npx nodefony orm:migrate` — c'est un geste à part,
|
|
150
|
+
avec ses refus et ses interdits : skill **`nodefony-migrate-schema`**.
|
|
151
|
+
|
|
152
|
+
## Ce qui refuse AVANT d'écrire
|
|
153
|
+
|
|
154
|
+
Le générateur s'arrête plutôt que de produire un fichier bancal — lis le message, il nomme le
|
|
155
|
+
geste :
|
|
156
|
+
|
|
157
|
+
- **hors projet** (aucun `nodefony.config.ts` au-dessus) ;
|
|
158
|
+
- **`@nodefony/drizzle` absent** de la cible ;
|
|
159
|
+
- **entité déjà déclarée** ;
|
|
160
|
+
- **nom réservé par un module du framework** (`session`, `access_token`, `audit_event`…) — un
|
|
161
|
+
homonyme dépossède le module, et l'application ne démarre plus sur un message parlant d'une
|
|
162
|
+
colonne inconnue. **`User` fait exception** : l'identité appartient à l'application, et
|
|
163
|
+
`create entity User firstName:string(100)?` écrit l'entité avec les colonnes du contrat plus
|
|
164
|
+
les tiennes. Trois refus s'y appliquent alors — renommer la table ou changer la casse des
|
|
165
|
+
colonnes (des requêtes les écrivent en dur), la poser ailleurs que dans l'application racine
|
|
166
|
+
(l'ordre de chargement n'y est pas garanti), et déclarer un champ obligatoire **sans valeur par
|
|
167
|
+
défaut** (le framework crée des utilisateurs sans le connaître : le semis d'administrateur
|
|
168
|
+
échouerait, et sans code d'erreur). Ni service ni contrôleur générique ne sont produits — une
|
|
169
|
+
ressource REST publique sur l'annuaire serait une faille, et `UserService` existe déjà ;
|
|
170
|
+
- **colonne inconnue, répétée, ou implicite absente** (`createdAt` sans horodatages).
|
|
171
|
+
|
|
172
|
+
## La suppression naît gardée — vérifie-le
|
|
173
|
+
|
|
174
|
+
Si `@nodefony/security` est dans les dépendances, l'action de suppression porte
|
|
175
|
+
`@IsGranted("ROLE_ADMIN")`. **Sans le module, elle n'est protégée par rien**, et le commentaire du
|
|
176
|
+
fichier généré le dit. Mesuré sur une application réelle avant correction : le CRUD répondait
|
|
177
|
+
**204 à un DELETE anonyme**.
|
|
178
|
+
|
|
179
|
+
Pour la protéger : → skill `nodefony-protect-route`.
|
|
180
|
+
|
|
181
|
+
## Prouver
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npm run build # le code généré compile-t-il ?
|
|
185
|
+
npm test # les tests générés couvrent la couche donnée
|
|
186
|
+
npx nodefony doctor # câblage : entité orpheline, service non listé, route en :param
|
|
187
|
+
npx nodefony inspect entities # ce que l'application enregistre VRAIMENT
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`npm test` est le premier diagnostic, jamais le dernier geste.
|
|
191
|
+
|
|
192
|
+
## Voisins
|
|
193
|
+
|
|
194
|
+
| Besoin | Skill |
|
|
195
|
+
| ---------------------------------------- | ------------------------------- |
|
|
196
|
+
| Un service métier injectable | `nodefony-add-service` |
|
|
197
|
+
| Faire suivre une base DÉJÀ en place | `nodefony-migrate-schema` |
|
|
198
|
+
| Réserver une route à certaines personnes | `nodefony-protect-route` |
|
|
199
|
+
| Un flux temps réel | `nodefony-add-realtime-channel` |
|