@vevedh/create-feathers-app 0.0.0-stage → 0.1.0-rc.10

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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -2
  3. package/bin/create-feathers-app.mjs +52 -0
  4. package/lib/auth-lab/AUTHENTICATION.md +31 -0
  5. package/lib/auth-lab/RBAC.md +16 -0
  6. package/lib/auth-lab/create-secret.mjs +19 -0
  7. package/lib/auth-lab/rbac-policy.mjs +39 -0
  8. package/lib/auth-lab/rbac.mjs +16 -0
  9. package/lib/auth-lab/schema.mjs +42 -0
  10. package/lib/auth-lab/secrets.mjs +12 -0
  11. package/lib/auth-lab/setup.mjs +121 -0
  12. package/lib/auth-lab/store.mjs +52 -0
  13. package/lib/auth-lab.mjs +91 -0
  14. package/lib/compose.mjs +252 -0
  15. package/lib/persistence-lab/create-secret.mjs +17 -0
  16. package/lib/persistence-lab/notes-service.mjs +68 -0
  17. package/lib/persistence-lab/open.mjs +30 -0
  18. package/lib/persistence-lab/prepare.mjs +27 -0
  19. package/lib/persistence-lab/storage.mjs +27 -0
  20. package/lib/persistence-lab.mjs +135 -0
  21. package/lib/planner/options.mjs +74 -0
  22. package/lib/planner/plan.mjs +107 -0
  23. package/lib/scaffold.mjs +350 -0
  24. package/lib/secure-runtime/connect.mjs +28 -0
  25. package/lib/secure-runtime/policy.mjs +85 -0
  26. package/lib/secure-runtime/prepare-storage.mjs +14 -0
  27. package/lib/secure-runtime/providers.mjs +74 -0
  28. package/lib/secure-runtime/register.mjs +80 -0
  29. package/lib/secure-runtime/storage.mjs +31 -0
  30. package/lib/services.mjs +227 -0
  31. package/lib/single-server/spa-static.mjs +60 -0
  32. package/lib/single-server.mjs +182 -0
  33. package/lib/tenant-lab/notes.mjs +99 -0
  34. package/lib/tenant-lab/schema.mjs +65 -0
  35. package/lib/tenant-lab/scope.mjs +34 -0
  36. package/lib/tenant-lab/store.mjs +53 -0
  37. package/lib/tenant-lab.mjs +81 -0
  38. package/lib/typescript.mjs +148 -0
  39. package/lib/universal/integrations.mjs +23 -0
  40. package/lib/universal/lab.mjs +132 -0
  41. package/package.json +38 -4
  42. package/templates/common/dev-all-quasar-ssr.mjs +37 -0
  43. package/templates/common/dev-all.mjs +46 -0
  44. package/templates/daisyui-ssg/.env.example +6 -0
  45. package/templates/daisyui-ssg/README.md +21 -0
  46. package/templates/daisyui-ssg/api/server.mjs +48 -0
  47. package/templates/daisyui-ssg/api/services/health/health.class.mjs +3 -0
  48. package/templates/daisyui-ssg/api/services/health/health.service.mjs +4 -0
  49. package/templates/daisyui-ssg/api/services/notes/notes.class.mjs +25 -0
  50. package/templates/daisyui-ssg/api/services/notes/notes.service.mjs +10 -0
  51. package/templates/daisyui-ssg/gitignore.template +36 -0
  52. package/templates/daisyui-ssg/index.html +2 -0
  53. package/templates/daisyui-ssg/package.json +39 -0
  54. package/templates/daisyui-ssg/scripts/prerender.mjs +14 -0
  55. package/templates/daisyui-ssg/scripts/serve-static.mjs +32 -0
  56. package/templates/daisyui-ssg/src/App.vue +14 -0
  57. package/templates/daisyui-ssg/src/components/NotesPanel.vue +22 -0
  58. package/templates/daisyui-ssg/src/entry-client.ts +9 -0
  59. package/templates/daisyui-ssg/src/entry-server.ts +9 -0
  60. package/templates/daisyui-ssg/src/stores/notes.ts +46 -0
  61. package/templates/daisyui-ssg/src/style.css +5 -0
  62. package/templates/daisyui-ssg/src/vite-env.d.ts +1 -0
  63. package/templates/daisyui-ssg/tsconfig.json +4 -0
  64. package/templates/daisyui-ssg/vite.config.ts +6 -0
  65. package/templates/daisyui-ssr/.env.example +6 -0
  66. package/templates/daisyui-ssr/README.md +21 -0
  67. package/templates/daisyui-ssr/api/server.mjs +48 -0
  68. package/templates/daisyui-ssr/api/services/health/health.class.mjs +3 -0
  69. package/templates/daisyui-ssr/api/services/health/health.service.mjs +4 -0
  70. package/templates/daisyui-ssr/api/services/notes/notes.class.mjs +25 -0
  71. package/templates/daisyui-ssr/api/services/notes/notes.service.mjs +10 -0
  72. package/templates/daisyui-ssr/gitignore.template +36 -0
  73. package/templates/daisyui-ssr/index.html +2 -0
  74. package/templates/daisyui-ssr/package.json +39 -0
  75. package/templates/daisyui-ssr/scripts/serve-ssr.mjs +47 -0
  76. package/templates/daisyui-ssr/src/App.vue +14 -0
  77. package/templates/daisyui-ssr/src/components/NotesPanel.vue +22 -0
  78. package/templates/daisyui-ssr/src/entry-client.ts +9 -0
  79. package/templates/daisyui-ssr/src/entry-server.ts +9 -0
  80. package/templates/daisyui-ssr/src/stores/notes.ts +46 -0
  81. package/templates/daisyui-ssr/src/style.css +5 -0
  82. package/templates/daisyui-ssr/src/vite-env.d.ts +1 -0
  83. package/templates/daisyui-ssr/tsconfig.json +4 -0
  84. package/templates/daisyui-ssr/vite.config.ts +7 -0
  85. package/templates/quasar-ssr/.env.example +11 -0
  86. package/templates/quasar-ssr/api/server.mjs +48 -0
  87. package/templates/quasar-ssr/api/services/health/health.class.mjs +3 -0
  88. package/templates/quasar-ssr/api/services/health/health.service.mjs +4 -0
  89. package/templates/quasar-ssr/api/services/notes/notes.class.mjs +25 -0
  90. package/templates/quasar-ssr/api/services/notes/notes.service.mjs +10 -0
  91. package/templates/quasar-ssr/gitignore.template +36 -0
  92. package/templates/quasar-ssr/index.html +12 -0
  93. package/templates/quasar-ssr/quasar.config.ts +27 -0
  94. package/templates/quasar-ssr/scripts/edge-proxy.mjs +30 -0
  95. package/templates/quasar-ssr/scripts/edge-routing.mjs +14 -0
  96. package/templates/quasar-ssr/src/App.vue +1 -0
  97. package/templates/quasar-ssr/src/css/app.css +20 -0
  98. package/templates/quasar-ssr/src/pages/IndexPage.vue +146 -0
  99. package/templates/quasar-ssr/src/pages/NotFoundPage.vue +11 -0
  100. package/templates/quasar-ssr/src/router/index.ts +8 -0
  101. package/templates/quasar-ssr/src/router/routes.ts +6 -0
  102. package/templates/quasar-ssr/src/stores/index.ts +4 -0
  103. package/templates/quasar-ssr/src/stores/notes.ts +73 -0
  104. package/templates/quasar-ssr/src-ssr/middlewares/render.ts +15 -0
  105. package/templates/quasar-ssr/src-ssr/server.ts +42 -0
  106. package/templates/quasar-ssr/tsconfig.json +4 -0
  107. package/templates/spa/common/.env.example +8 -0
  108. package/templates/spa/common/api/server.mjs +46 -0
  109. package/templates/spa/common/api/services/health/health.class.mjs +3 -0
  110. package/templates/spa/common/api/services/health/health.service.mjs +4 -0
  111. package/templates/spa/common/api/services/notes/notes.class.mjs +25 -0
  112. package/templates/spa/common/api/services/notes/notes.service.mjs +10 -0
  113. package/templates/spa/common/gitignore.template +39 -0
  114. package/templates/spa/common/index.html +3 -0
  115. package/templates/spa/common/src/components/NotesPanel.vue +25 -0
  116. package/templates/spa/common/src/stores/notes.ts +105 -0
  117. package/templates/spa/common/src/vite-env.d.ts +1 -0
  118. package/templates/spa/common/tsconfig.json +4 -0
  119. package/templates/spa/daisyui/src/App.vue +2 -0
  120. package/templates/spa/daisyui/src/components/NotesPanel.vue +39 -0
  121. package/templates/spa/daisyui/src/main.ts +5 -0
  122. package/templates/spa/daisyui/src/style.css +5 -0
  123. package/templates/spa/daisyui/vite.config.ts +8 -0
  124. package/templates/spa/quasar2/src/App.vue +3 -0
  125. package/templates/spa/quasar2/src/components/NotesPanel.vue +41 -0
  126. package/templates/spa/quasar2/src/main.ts +7 -0
  127. package/templates/spa/quasar2/src/style.css +1 -0
  128. package/templates/spa/quasar2/vite.config.ts +8 -0
@@ -0,0 +1,252 @@
1
+ /** Opt-in, loopback-only Docker Compose profiles for Vite-first SPA applications.
2
+ * Authentication and database capability guards from scaffold.mjs stay active.
3
+ */
4
+ const composeModes = new Set(['dev', 'prod', 'both'])
5
+
6
+ function requireExact(files, path, before, after) {
7
+ const source = files.get(path)
8
+ if (typeof source !== 'string' || source.split(before).length !== 2) {
9
+ throw new Error(`Unexpected ${path} template; refusing Docker generation`)
10
+ }
11
+ files.set(path, source.replace(before, after))
12
+ }
13
+
14
+ function composeReadme(options) {
15
+ const dev = options.compose === 'dev' || options.compose === 'both'
16
+ const prod = options.compose === 'prod' || options.compose === 'both'
17
+ return `# Docker Compose — ${options.name}
18
+
19
+ Ces fichiers déploient le squelette Vite-first, **pas une application déjà sécurisée pour Internet**.
20
+ Le service de démonstration \`notes\` est en mémoire et sans authentification. Les ports HTTP sont publiés sur \`127.0.0.1\` uniquement.
21
+ Aucun port de base de données n'est exposé. Le générateur **n'active pas** les profils JWT, OAuth ou bases externes : leurs garde-fous existants continuent de refuser le démarrage tant que leur intégration n'est pas certifiée.
22
+
23
+ ## Prérequis
24
+
25
+ - Docker Desktop avec Docker Compose v2.
26
+ - Node.js compatible et Corepack/pnpm pour produire \`pnpm-lock.yaml\`.
27
+ - Vérifier les dépendances, leurs licences et vulnérabilités avant tout déploiement.
28
+
29
+ ${dev ? `## Développement
30
+
31
+ \`docker compose -f compose.dev.yaml up --build\`
32
+
33
+ Ouvrir http://127.0.0.1:5173. Le serveur Vite monte Feathers via le plugin Vite, sans lancer un deuxième processus d'API. Aucun autre port n'est publié.
34
+
35
+ Arrêt : \`docker compose -f compose.dev.yaml down\` (le volume de dépendances est conservé).
36
+
37
+ ` : ''}${prod ? `## Image de déploiement (prototype isolé)
38
+
39
+ Créer et réviser le lockfile à la racine du projet :
40
+
41
+ \`corepack pnpm install --lockfile-only\`
42
+
43
+ \`docker compose -f compose.prod.yaml up --build -d\`
44
+
45
+ Ouvrir http://127.0.0.1:8080. Nginx sert la SPA et relaie \`/api/\` à Feathers et ${options.transport === 'both' ? '\`/socket.io/\` au serveur temps réel.' : 'aucun Socket.IO (REST uniquement).'} Le backend n'a pas de port publié sur l'hôte.
46
+
47
+ Arrêt : \`docker compose -f compose.prod.yaml down\`.
48
+
49
+ **Avant une réelle mise en production :** remplacer le service notes non protégé, activer JWT/RBAC et une persistance durable, vérifier les migrations, isoler les secrets, mettre en place le reverse proxy HTTPS et la politique d'origines, vérifier les journaux et sauvegardes et exécuter les E2E REST/Socket.IO de la cible. Ne pas changer l'interface hôte de \`127.0.0.1\` vers \`0.0.0.0\` sans ces contrôles.
50
+
51
+ ` : ''}## Particularités
52
+
53
+ - Choix générés : ${options.ui} SPA, ${options.adapter}, ${options.transport}, PWA ${options.pwa}.
54
+ - Le serveur API autonome peut écouter sur toutes les interfaces **du conteneur** uniquement via \`FEATHERS_BIND_HOST=0.0.0.0\` ; le réseau \`backend\` est interne et non publié.
55
+ - Le mode PWA, lorsqu'il est sélectionné, ne doit jamais mettre \`/api/\` ou \`/socket.io/\` en cache.
56
+ - Les images de production sont construites depuis un lockfile audité ; ne jamais copier de \`.env\` réel dans les images.
57
+ - Les ports peuvent être personnalisés uniquement côté loopback via \`P4_DEV_PORT\` et \`P4_HTTP_PORT\`.
58
+ `
59
+ }
60
+
61
+ function devCompose(options) {
62
+ return `name: ${options.name}-dev
63
+ services:
64
+ app:
65
+ image: node:24-bookworm-slim
66
+ working_dir: /workspace
67
+ command: ["sh", "-c", "corepack pnpm install --no-frozen-lockfile && corepack pnpm exec vite --host 0.0.0.0 --port 5173 --strictPort"]
68
+ environment:
69
+ NODE_ENV: development
70
+ VITE_FEATHERS_REALTIME: "${options.transport === 'both' ? '1' : '0'}"
71
+ P4_DEV_PORT: "\${P4_DEV_PORT:-5173}"
72
+ volumes:
73
+ - .:/workspace
74
+ - dependencies:/workspace/node_modules
75
+ ports:
76
+ - "127.0.0.1:\${P4_DEV_PORT:-5173}:5173"
77
+ security_opt:
78
+ - no-new-privileges:true
79
+ volumes:
80
+ dependencies:
81
+ `
82
+ }
83
+
84
+ function prodCompose(options) {
85
+ return `name: ${options.name}-prod
86
+ services:
87
+ api:
88
+ build:
89
+ context: .
90
+ dockerfile: infra/compose/Dockerfile.api
91
+ environment:
92
+ NODE_ENV: production
93
+ FEATHERS_BIND_HOST: "0.0.0.0"
94
+ FEATHERS_ADAPTER: "${options.adapter}"
95
+ FEATHERS_SOCKETIO: "${options.transport === 'both' ? '1' : '0'}"
96
+ FEATHERS_API_PORT: "3030"
97
+ FEATHERS_PUBLIC_ORIGIN: "http://127.0.0.1:\${P4_HTTP_PORT:-8080}"
98
+ FEATHERS_PREVIEW_ORIGIN: "http://127.0.0.1:\${P4_HTTP_PORT:-8080}"
99
+ expose:
100
+ - "3030"
101
+ networks:
102
+ - backend
103
+ security_opt:
104
+ - no-new-privileges:true
105
+ read_only: true
106
+ tmpfs:
107
+ - /tmp
108
+ healthcheck:
109
+ test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3030/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
110
+ interval: 10s
111
+ timeout: 5s
112
+ retries: 8
113
+ start_period: 20s
114
+ web:
115
+ build:
116
+ context: .
117
+ dockerfile: infra/compose/Dockerfile.web
118
+ depends_on:
119
+ api:
120
+ condition: service_healthy
121
+ ports:
122
+ - "127.0.0.1:\${P4_HTTP_PORT:-8080}:8080"
123
+ networks:
124
+ - frontend
125
+ - backend
126
+ security_opt:
127
+ - no-new-privileges:true
128
+ networks:
129
+ frontend:
130
+ backend:
131
+ internal: true
132
+ `
133
+ }
134
+
135
+ const apiDockerfile = `FROM node:24-bookworm-slim
136
+ WORKDIR /app
137
+ RUN corepack enable
138
+ COPY package.json pnpm-lock.yaml ./
139
+ RUN corepack pnpm install --frozen-lockfile --prod
140
+ COPY api ./api
141
+ USER node
142
+ EXPOSE 3030
143
+ CMD ["node", "api/server.mjs"]
144
+ `
145
+
146
+ const webDockerfile = `FROM node:24-bookworm-slim AS builder
147
+ WORKDIR /app
148
+ RUN corepack enable
149
+ COPY package.json pnpm-lock.yaml ./
150
+ RUN corepack pnpm install --frozen-lockfile
151
+ COPY . .
152
+ RUN corepack pnpm run build
153
+
154
+ FROM nginx:stable-alpine
155
+ COPY infra/compose/nginx.conf /etc/nginx/conf.d/default.conf
156
+ COPY --from=builder /app/dist /usr/share/nginx/html
157
+ EXPOSE 8080
158
+ `
159
+
160
+ function nginx(options) {
161
+ return `server {
162
+ listen 8080;
163
+ server_name _;
164
+ root /usr/share/nginx/html;
165
+ index index.html;
166
+ autoindex off;
167
+ server_tokens off;
168
+ add_header X-Content-Type-Options nosniff always;
169
+ add_header Referrer-Policy no-referrer always;
170
+ add_header X-Frame-Options DENY always;
171
+ location = /api { return 404; }
172
+ location /api/ {
173
+ proxy_pass http://api:3030/api/;
174
+ proxy_http_version 1.1;
175
+ proxy_set_header Host $host;
176
+ proxy_set_header X-Forwarded-Proto $scheme;
177
+ proxy_set_header X-Forwarded-For $remote_addr;
178
+ proxy_read_timeout 30s;
179
+ }
180
+ ${options.transport === 'both' ? ` location /socket.io/ {
181
+ proxy_pass http://api:3030;
182
+ proxy_http_version 1.1;
183
+ proxy_set_header Upgrade $http_upgrade;
184
+ proxy_set_header Connection "upgrade";
185
+ proxy_set_header Host $host;
186
+ proxy_read_timeout 65s;
187
+ }
188
+ ` : ` location /socket.io/ { return 404; }
189
+ `} location / {
190
+ try_files $uri $uri/ /index.html;
191
+ }
192
+ }
193
+ `
194
+ }
195
+
196
+ const dockerIgnore = `node_modules
197
+ .git
198
+ .agents
199
+ .ai
200
+ .codex
201
+ .cursor
202
+ .claude
203
+ .gemini
204
+ .windsurf
205
+ .mcp.json
206
+ .env
207
+ .env.*
208
+ !.env.example
209
+ database.env
210
+ *.zip
211
+ *.log
212
+ coverage
213
+ dist
214
+ .vite
215
+ .quasar
216
+ docs-private
217
+ test-results
218
+ playwright-report
219
+ `
220
+
221
+ export function enableCompose(files, options) {
222
+ if (!composeModes.has(options.compose) || options.render !== 'spa') {
223
+ throw new Error('Compose generation supports only SPA and compose dev|prod|both')
224
+ }
225
+ const dev = options.compose === 'dev' || options.compose === 'both'
226
+ const prod = options.compose === 'prod' || options.compose === 'both'
227
+ if (dev && options.devApi !== 'vite') {
228
+ throw new Error('Compose development requires --dev-api vite for a single Feathers/Vite listener')
229
+ }
230
+ const created = new Map([['COMPOSE.md', composeReadme(options)]])
231
+ if (dev) created.set('compose.dev.yaml', devCompose(options))
232
+ if (prod) {
233
+ created.set('compose.prod.yaml', prodCompose(options))
234
+ created.set('infra/compose/Dockerfile.api', apiDockerfile)
235
+ created.set('infra/compose/Dockerfile.web', webDockerfile)
236
+ created.set('infra/compose/nginx.conf', nginx(options))
237
+ // The only Docker interface bound to all container addresses is the private API listener.
238
+ requireExact(files, 'api/server.mjs', "const realtime = process.env.FEATHERS_SOCKETIO === '1'", "const bindHost = process.env.FEATHERS_BIND_HOST === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1'\nconst realtime = process.env.FEATHERS_SOCKETIO === '1'")
239
+ requireExact(files, 'api/server.mjs', "const wsPort = Number(process.env.FEATHERS_SOCKET_PORT || 3031)\n", '')
240
+ requireExact(files, 'api/server.mjs', ' || !Number.isInteger(wsPort) || wsPort === port', '')
241
+ requireExact(files, 'api/server.mjs', "socketio: realtime ? { port: wsPort, origins: [...new Set([origin, previewOrigin])], host: '127.0.0.1' } : false,", "socketio: realtime ? { mode: 'external', origins: [...new Set([origin, previewOrigin])] } : false,")
242
+ requireExact(files, 'api/server.mjs', 'await runtime.start()\nconst httpServer = createServer', 'const httpServer = createServer')
243
+ requireExact(files, 'api/server.mjs', "httpServer.listen(port, '127.0.0.1', resolve)", 'httpServer.listen(port, bindHost, resolve)')
244
+ requireExact(files, 'api/server.mjs', ' httpServer.listen(port, bindHost, resolve)\n})', ' httpServer.listen(port, bindHost, resolve)\n})\nif (realtime) await runtime.start(httpServer)\nelse await runtime.start()')
245
+ requireExact(files, 'api/server.mjs', ' await new Promise(resolve => httpServer.close(resolve))\n await runtime.close()', ' await runtime.close()\n if (httpServer.listening) await new Promise(resolve => httpServer.close(resolve))')
246
+ }
247
+ created.set('.dockerignore', dockerIgnore)
248
+ for (const [key, content] of created) {
249
+ if (files.has(key)) throw new Error(`Compose file conflicts with a template: ${key}`)
250
+ files.set(key, content)
251
+ }
252
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ import { randomBytes } from 'node:crypto'
3
+ import { mkdir, open, lstat } from 'node:fs/promises'
4
+ import { resolve, join } from 'node:path'
5
+ const dir = resolve('secrets')
6
+ const file = join(dir, 'db_password')
7
+ try {
8
+ if (process.argv.length !== 2) throw Error('ARGUMENTS_REFUSED')
9
+ await mkdir(dir, { recursive: true, mode: 0o700 })
10
+ if ((await lstat(dir)).isSymbolicLink()) throw Error('SECRET_PATH_REFUSED')
11
+ const handle = await open(file, 'wx', 0o600)
12
+ try { await handle.writeFile(randomBytes(36).toString('base64url') + '\n') } finally { await handle.close() }
13
+ console.log('P5_SECRET=CREATED | PATH=secrets/db_password | CONTENT=REDACTED')
14
+ } catch {
15
+ console.error('P5_SECRET=REFUSED | EXISTING_FILE_OR_UNSAFE_PATH')
16
+ process.exitCode = 2
17
+ }
@@ -0,0 +1,68 @@
1
+ import { randomInt } from 'node:crypto'
2
+ import { BadRequest, NotFound } from '@feathersjs/errors'
3
+
4
+ // Laboratory-only, unauthenticated Feathers service using a real database adapter.
5
+ // Never expose it beyond the local reverse proxy; P5-R6-B will add JWT/RBAC.
6
+ function idOf(value) {
7
+ const n = typeof value === 'string' && /^[1-9][0-9]{0,9}$/.test(value) ? Number(value) : value
8
+ if (!Number.isSafeInteger(n) || n < 1 || n > 2147483647) throw new BadRequest('Invalid note ID')
9
+ return n
10
+ }
11
+ function titleOf(value) {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).join(',') !== 'title') {
13
+ throw new BadRequest('Invalid note data')
14
+ }
15
+ const title = value.title
16
+ if (typeof title !== 'string' || title.trim().length < 1 || title.length > 120 || /[\u0000-\u001f\u007f]/.test(title)) {
17
+ throw new BadRequest('Invalid note title')
18
+ }
19
+ return title.trim()
20
+ }
21
+ function noQuery(params) {
22
+ const query = params?.query
23
+ if (query !== undefined && (query === null || typeof query !== 'object' || Array.isArray(query) || Object.keys(query).length !== 0)) {
24
+ throw new BadRequest('Query operators are not allowed in this laboratory service')
25
+ }
26
+ }
27
+ function present(value) {
28
+ if (!value || typeof value !== 'object') throw new Error('DB_RECORD_INVALID')
29
+ const id = idOf(value.id)
30
+ if (typeof value.title !== 'string') throw new Error('DB_RECORD_INVALID')
31
+ return { id, title: value.title }
32
+ }
33
+ export class PersistentNotesService {
34
+ constructor(databaseAdapter) {
35
+ if (!databaseAdapter || ['find','get','create','patch','remove'].some(name => typeof databaseAdapter[name] !== 'function')) {
36
+ throw new Error('DB_ADAPTER_REQUIRED')
37
+ }
38
+ this.adapter = databaseAdapter
39
+ for (const method of ['find','get','create','patch','remove']) this[method] = this[method].bind(this)
40
+ }
41
+ async find(params) {
42
+ noQuery(params)
43
+ const rows = await this.adapter.find({ query: { $limit: 100 }, paginate: false })
44
+ if (!Array.isArray(rows)) throw new Error('DB_LIST_INVALID')
45
+ return rows.map(present)
46
+ }
47
+ async get(id, params) {
48
+ noQuery(params)
49
+ try { return present(await this.adapter.get(idOf(id))) }
50
+ catch (error) { if (error?.code === 404) throw new NotFound('Note not found'); throw error }
51
+ }
52
+ async create(data, params) {
53
+ noQuery(params)
54
+ // Numeric IDs retain compatibility with the published Vue/Pinia Note interface.
55
+ // No in-memory counter; id uniqueness is enforced by the DB primary key/index.
56
+ return present(await this.adapter.create({ id: randomInt(1, 2147483647), title: titleOf(data) }))
57
+ }
58
+ async patch(id, data, params) {
59
+ noQuery(params)
60
+ try { return present(await this.adapter.patch(idOf(id), { title: titleOf(data) })) }
61
+ catch (error) { if (error?.code === 404) throw new NotFound('Note not found'); throw error }
62
+ }
63
+ async remove(id, params) {
64
+ noQuery(params)
65
+ try { return present(await this.adapter.remove(idOf(id))) }
66
+ catch (error) { if (error?.code === 404) throw new NotFound('Note not found'); throw error }
67
+ }
68
+ }
@@ -0,0 +1,30 @@
1
+ import { readFile, lstat } from 'node:fs/promises'
2
+ import { openNamedDatabaseProviders } from './database-providers.generated.mjs'
3
+ import { databaseDeclarations } from '../config/database-connections.generated.mjs'
4
+ import { checkNotesStorage } from './storage.mjs'
5
+
6
+ // Docker secrets are read only at runtime, not copied into images or exposed via VITE_*.
7
+ export async function openNotesDatabase() {
8
+ if (process.env.NODE_ENV !== 'production' || process.env.P5_PERSISTENCE_MODE !== 'notes-lab') {
9
+ throw new Error('PERSISTENCE_PROFILE_NOT_ENABLED')
10
+ }
11
+ if (databaseDeclarations.length !== 1) throw new Error('DB_SINGLE_PROVIDER_REQUIRED')
12
+ const declaration = databaseDeclarations[0]
13
+ const secretFile = process.env.P5_DATABASE_PASSWORD_FILE
14
+ if (secretFile !== '/run/secrets/p5_database_password') throw new Error('DB_SECRET_MISSING')
15
+ const stat = await lstat(secretFile)
16
+ if (!stat.isFile() || stat.size < 16 || stat.size > 256) throw new Error('DB_SECRET_INVALID')
17
+ const password = (await readFile(secretFile, 'utf8')).replace(/\r?\n$/, '')
18
+ if (password.length < 16 || password.length > 256 || /[\u0000-\u001f\u007f]/.test(password)) throw new Error('DB_SECRET_INVALID')
19
+ const providers = await openNamedDatabaseProviders({
20
+ ...process.env,
21
+ [`${declaration.envPrefix}_PASSWORD`]: password,
22
+ })
23
+ try {
24
+ const adapter = await checkNotesStorage(providers.get(declaration.alias))
25
+ return { adapter, close: () => providers.close() }
26
+ } catch {
27
+ await providers.close()
28
+ throw new Error('DB_SCHEMA_NOT_READY')
29
+ }
30
+ }
@@ -0,0 +1,27 @@
1
+ import { openNamedDatabaseProviders } from '../api/runtime/database-providers.generated.mjs'
2
+ import { databaseDeclarations } from '../api/config/database-connections.generated.mjs'
3
+ import { prepareNotesStorage } from '../api/runtime/storage.mjs'
4
+ import { readFile, lstat } from 'node:fs/promises'
5
+
6
+ if (process.env.NODE_ENV !== 'production' || process.env.P5_PERSISTENCE_MODE !== 'notes-lab' || databaseDeclarations.length !== 1) {
7
+ console.error('P5_DB_INIT=REFUSED'); process.exitCode = 2
8
+ } else {
9
+ let providers
10
+ try {
11
+ const declaration = databaseDeclarations[0]
12
+ const file = process.env.P5_DATABASE_PASSWORD_FILE
13
+ if (file !== '/run/secrets/p5_database_password') throw new Error('DB_SECRET_MISSING')
14
+ const stat = await lstat(file)
15
+ if (!stat.isFile() || stat.size < 16 || stat.size > 256) throw new Error('DB_SECRET_INVALID')
16
+ const password = (await readFile(file, 'utf8')).replace(/\r?\n$/, '')
17
+ if (password.length < 16 || password.length > 256 || /[\u0000-\u001f\u007f]/.test(password)) throw new Error('DB_SECRET_INVALID')
18
+ providers = await openNamedDatabaseProviders({ ...process.env, [`${declaration.envPrefix}_PASSWORD`]: password })
19
+ await prepareNotesStorage(providers.get(declaration.alias))
20
+ console.log('P5_DB_INIT=PASS | NOTES_SCHEMA=READY | NO_SEED=YES')
21
+ } catch {
22
+ console.error('P5_DB_INIT=FAIL | NO_SCHEMA_DOWNGRADE')
23
+ process.exitCode = 2
24
+ } finally {
25
+ try { await providers?.close() } catch { process.exitCode = 2 }
26
+ }
27
+ }
@@ -0,0 +1,27 @@
1
+ // Explicit additive schema provisioning; never drop a table, document, index or volume.
2
+ const table = 'app_notes'
3
+ export async function prepareNotesStorage(provider) {
4
+ if (!provider || !['postgresql','mariadb','mongodb'].includes(provider.adapter)) throw new Error('DB_UNSUPPORTED')
5
+ if (provider.adapter === 'mongodb') {
6
+ try { await provider.db.createCollection(table) }
7
+ catch (error) { if (error?.code !== 48) throw error }
8
+ await provider.db.collection(table).createIndex({ id: 1 }, { unique: true })
9
+ } else {
10
+ if (!await provider.db.schema.hasTable(table)) {
11
+ await provider.db.schema.createTable(table, t => {
12
+ t.integer('id').primary()
13
+ t.string('title', 120).notNullable()
14
+ })
15
+ }
16
+ for (const column of ['id', 'title']) {
17
+ if (!await provider.db.schema.hasColumn(table, column)) throw new Error('DB_SCHEMA_MIGRATION_REQUIRED')
18
+ }
19
+ }
20
+ }
21
+ export async function checkNotesStorage(provider) {
22
+ if (!provider || !['postgresql','mariadb','mongodb'].includes(provider.adapter)) throw new Error('DB_UNSUPPORTED')
23
+ if (provider.adapter === 'mongodb') {
24
+ if (!await provider.db.listCollections({ name: table }).hasNext()) throw new Error('DB_SCHEMA_NOT_READY')
25
+ } else if (!await provider.db.schema.hasTable(table)) throw new Error('DB_SCHEMA_NOT_READY')
26
+ return provider.service(table)
27
+ }
@@ -0,0 +1,135 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { enableUniversalLab } from './universal/lab.mjs'
3
+ import { enableJwtSessionLab } from './auth-lab.mjs'
4
+
5
+ const adapters = Object.freeze({
6
+ postgresql: { image: 'postgres:16-alpine', port: 5432, volume: '/var/lib/postgresql/data',
7
+ environment: ` POSTGRES_USER: featherslab\n POSTGRES_DB: feathers_lab\n POSTGRES_PASSWORD_FILE: /run/secrets/p5_database_password\n`,
8
+ health: ` test: ["CMD-SHELL", "pg_isready -U featherslab -d feathers_lab"]\n` },
9
+ mariadb: { image: 'mariadb:11.4', port: 3306, volume: '/var/lib/mysql',
10
+ environment: ` MARIADB_USER: featherslab\n MARIADB_DATABASE: feathers_lab\n MARIADB_PASSWORD_FILE: /run/secrets/p5_database_password\n MARIADB_RANDOM_ROOT_PASSWORD: "1"\n`,
11
+ health: ` test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]\n` },
12
+ mongodb: { image: 'mongo:7', port: 27017, volume: '/data/db',
13
+ environment: ` MONGO_INITDB_ROOT_USERNAME: featherslab\n MONGO_INITDB_ROOT_PASSWORD_FILE: /run/secrets/p5_database_password\n`,
14
+ health: ` test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand({ping:1}).ok"]\n` },
15
+ })
16
+ function change(files, path, before, after) {
17
+ const old = files.get(path)
18
+ if (typeof old !== 'string' || old.split(before).length !== 2) {
19
+ throw new Error(`Persistence template drift: ${path}`)
20
+ }
21
+ files.set(path, old.replace(before, after))
22
+ }
23
+ const source = async name => readFile(new URL(`./persistence-lab/${name}`, import.meta.url), 'utf8')
24
+
25
+ /** Opt-in, single-provider, unauthenticated *local laboratory* only.
26
+ * No implicit weakening of the existing auth/DB capability guard.
27
+ */
28
+ export async function enablePersistentLab(files, options) {
29
+ if (options.persistence !== 'lab' || options.compose !== 'prod' || options.render !== 'spa' || !['none','jwt'].includes(options.auth) || options.databases.length !== 1) {
30
+ throw new Error('Persistence lab requires SPA --compose prod --auth none and exactly one DB')
31
+ }
32
+ const declaration = options.databases[0]
33
+ if (declaration.adapter === 'universal') return enableUniversalLab(files, options, enablePersistentLab)
34
+ const settings = adapters[declaration.adapter]
35
+ if (!settings) throw new Error('Persistence lab supports postgresql|mariadb|mongodb only')
36
+ const envPrefix = `P5_DB_${declaration.alias.replaceAll('-', '_').toUpperCase()}`
37
+ const env = ` P5_PERSISTENCE_MODE: notes-lab\n P5_DATABASE_PASSWORD_FILE: /run/secrets/p5_database_password\n ${envPrefix}_HOST: db\n ${envPrefix}_PORT: "${settings.port}"\n ${envPrefix}_USER: featherslab\n ${envPrefix}_NAME: feathers_lab\n`
38
+ const deps = ' depends_on:\n migrate:\n condition: service_completed_successfully\n secrets:\n - p5_database_password\n'
39
+ change(files, 'compose.prod.yaml', ' FEATHERS_API_PORT: "3030"\n', ` FEATHERS_API_PORT: "3030"\n${env}`)
40
+ change(files, 'compose.prod.yaml', ' expose:\n - "3030"\n', ` expose:\n - "3030"\n${deps}`)
41
+ const job = ` migrate:\n build:\n context: .\n dockerfile: infra/compose/Dockerfile.api\n command: ["node", "scripts/prepare-notes.mjs"]\n environment:\n NODE_ENV: production\n${env} depends_on:\n db:\n condition: service_healthy\n secrets:\n - p5_database_password\n networks:\n - backend\n security_opt:\n - no-new-privileges:true\n read_only: true\n tmpfs:\n - /tmp\n db:\n image: ${settings.image}\n environment:\n${settings.environment} secrets:\n - p5_database_password\n networks:\n - backend\n volumes:\n - persistence_data:${settings.volume}\n healthcheck:\n${settings.health} interval: 10s\n timeout: 5s\n retries: 12\n start_period: 25s\n`
42
+ change(files, 'compose.prod.yaml', ' web:\n build:\n', `${job} web:\n build:\n`)
43
+ const prod = files.get('compose.prod.yaml')
44
+ if (!prod.endsWith(' internal: true\n')) throw new Error('Compose network declaration changed')
45
+ files.set('compose.prod.yaml', `${prod}volumes:\n persistence_data:\nsecrets:\n p5_database_password:\n file: ./secrets/db_password\n`)
46
+ change(files, 'infra/compose/Dockerfile.api', 'COPY api ./api\n', 'COPY api ./api\nCOPY scripts/prepare-notes.mjs ./scripts/prepare-notes.mjs\n')
47
+ change(files, '.dockerignore', 'docs-private\n', 'docs-private\nsecrets\n')
48
+ const ignores = files.get('.gitignore')
49
+ if (!ignores || !ignores.includes('docs-private/')) throw new Error('Missing safe .gitignore')
50
+ files.set('.gitignore', `${ignores.trimEnd()}\nsecrets/\n`)
51
+ files.set('api/config/database-connections.generated.mjs', `export const databaseDeclarations = Object.freeze(${JSON.stringify([{ alias: declaration.alias, adapter: declaration.adapter, envPrefix }])})\n`)
52
+ files.set('api/runtime/database-providers.generated.mjs', await readFile(new URL('./secure-runtime/providers.mjs', import.meta.url), 'utf8'))
53
+ for (const [name,path] of [
54
+ ['notes-service.mjs', 'api/runtime/notes-service.mjs'],
55
+ ['storage.mjs', 'api/runtime/storage.mjs'],
56
+ ['open.mjs', 'api/runtime/open.mjs'],
57
+ ['prepare.mjs', 'scripts/prepare-notes.mjs'],
58
+ ['create-secret.mjs', 'scripts/create-lab-secret.mjs'],
59
+ ]) files.set(path, await source(name))
60
+
61
+ const cap = `// Opt-in laboratory contract: never start a database-backed demo on Vite dev or in a public host.\n` +
62
+ `export function assertCapabilities() {\n` +
63
+ ` if (process.env.NODE_ENV !== 'production' || process.env.P5_PERSISTENCE_MODE !== 'notes-lab') {\n` +
64
+ ` throw new Error('External persistence requires the private Docker Production lab profile')\n` +
65
+ ` }\n` +
66
+ `}\n`
67
+ if (!files.get('api/config/capabilities.mjs')?.includes('Database adapters not provisioned:')) {
68
+ throw new Error('Existing capability guard drift')
69
+ }
70
+ files.set('api/config/capabilities.mjs', cap)
71
+ change(files, 'vite.config.ts', 'assertCapabilities()\nexport default defineConfig({',
72
+ "if (process.env.NODE_ENV !== 'production') assertCapabilities()\nexport default defineConfig({")
73
+ change(files, 'api/services/notes/notes.service.mjs', "app.use('notes', new NotesService(),", "const persistenceService = app.get('p5PersistentNotes')\n if (!persistenceService) throw new Error('DB_NOT_BOUND')\n app.use('notes', persistenceService,")
74
+
75
+ let server = files.get('api/server.mjs')
76
+ if (typeof server !== 'string' || !server.includes('assertCapabilities()') || !server.includes('const runtime = await createFeathersRuntime(')) {
77
+ throw new Error('Production server template drift')
78
+ }
79
+ // Invariant: provider opens and schema is checked before registering services or opening HTTP.
80
+ server = `import { openNotesDatabase } from './runtime/open.mjs'\nimport { PersistentNotesService } from './runtime/notes-service.mjs'\n${server}`
81
+ const start = 'const runtime = await createFeathersRuntime(modules.map(String), file => import(file), {'
82
+ if (server.split(start).length !== 2) throw new Error('Runtime bootstrap changed')
83
+ server = server.replace(start,
84
+ `const persistence = await openNotesDatabase()\nlet persistenceReady = false\ntry {\n${start}\n configure: app => app.set('p5PersistentNotes', new PersistentNotesService(persistence.adapter)),`)
85
+ const closeOld = ' await runtime.close()'
86
+ if (server.split(closeOld).length !== 2) throw new Error('Runtime close changed')
87
+ server = server.replace(closeOld, `${closeOld}\n await persistence.close()`)
88
+ server += `\npersistenceReady = true\n} finally {\n if (!persistenceReady) await persistence.close()\n}\n`
89
+ files.set('api/server.mjs', server)
90
+ const readme = [
91
+ '# Persistance Feathers — laboratoire Docker Production',
92
+ '',
93
+ '**Profil sans authentification : utilisation locale uniquement.** Nginx est exposé sur 127.0.0.1.',
94
+ `Base sélectionnée : ${declaration.adapter} (alias ${declaration.alias}).`,
95
+ '',
96
+ '## Premier démarrage',
97
+ '',
98
+ 'Dans le dossier du projet généré :',
99
+ '```powershell',
100
+ 'node scripts/create-lab-secret.mjs',
101
+ 'corepack pnpm install --lockfile-only',
102
+ 'docker compose -f compose.prod.yaml up --build -d',
103
+ '```',
104
+ '',
105
+ 'Ouvrir http://127.0.0.1:8080, ajouter une note puis redémarrer seulement l’API :',
106
+ '```powershell',
107
+ 'docker compose -f compose.prod.yaml restart api',
108
+ 'docker compose -f compose.prod.yaml ps',
109
+ '```',
110
+ '',
111
+ 'Les notes sont stockées dans le volume `persistence_data`. `docker compose down` le conserve.',
112
+ '**Ne jamais lancer `down -v` pour ce test : cela supprime le volume !**',
113
+ 'Le job `migrate` crée la structure au besoin, sans supprimer de données.',
114
+ 'Le mot de passe est fourni par un secret Docker `secrets/db_password`, exclu de Git et des images.',
115
+ '',
116
+ '## Limites et sécurité',
117
+ '',
118
+ '- Démonstration sans JWT/RBAC : ne pas exposer au réseau public.',
119
+ '- Une seule connexion, un service notes et Compose Production uniquement.',
120
+ '- Développement Vite avec DB externe bloqué ; aucun repli vers la mémoire.',
121
+ '- Le service add reste volontairement bloqué pour les DB externes.',
122
+ '- Socket.IO annonce des événements, pas un journal persistant.',
123
+ '- Les images Docker de DB doivent être figées à un digest et revues avant production réelle.',
124
+ '',
125
+ ].join('\n')
126
+ files.set('PERSISTENCE.md', readme)
127
+ const manifest = JSON.parse(files.get('package.json'))
128
+ // Pin operational DB drivers for reproducible lockfiles; do not modify other profiles.
129
+ if (declaration.adapter === 'postgresql') { manifest.dependencies.knex = '3.3.0'; manifest.dependencies.pg = '8.23.1' }
130
+ if (declaration.adapter === 'mariadb') { manifest.dependencies.knex = '3.3.0'; manifest.dependencies.mysql2 = '3.24.6' }
131
+ if (declaration.adapter === 'mongodb') manifest.dependencies.mongodb = '6.19.0'
132
+ manifest.scripts['db:secret:lab'] = 'node scripts/create-lab-secret.mjs'
133
+ files.set('package.json', JSON.stringify(manifest, null, 2) + '\n')
134
+ if (options.auth === 'jwt') await enableJwtSessionLab(files, options)
135
+ }
@@ -0,0 +1,74 @@
1
+ /** Candidate options for a future scaffolding CLI. No files or runtime services
2
+ * are generated by this read-only planner. Keep mappings reviewable and explicit.
3
+ */
4
+ export const UI_PRESETS = Object.freeze(['quasar2', 'daisyui', 'daisyui-kit'])
5
+ export const SCHEMA_PRESETS = Object.freeze(['typebox', 'json', 'zod'])
6
+ export const AUTH_PRESETS = Object.freeze(['none', 'jwt', 'local', 'oauth', 'oidc', 'ldap'])
7
+ export const DATABASE_PRESETS = Object.freeze(['memory', 'mongodb', 'postgresql', 'mariadb', 'mysql', 'sqlite'])
8
+ export const RENDER_PRESETS = Object.freeze(['spa', 'ssr', 'ssg'])
9
+ export const RENDER_ENGINES = Object.freeze(['auto', 'vite', 'quasar-cli-vite'])
10
+ export const TRANSPORT_PRESETS = Object.freeze(['rest', 'socketio', 'both'])
11
+ export const NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
12
+
13
+ const AVAILABLE_OPTIONS = new Set(['--name', '--ui', '--render', '--pwa', '--schema', '--auth', '--db', '--primary', '--transport', '--engine', '--dry-run', '--json', '--help'])
14
+
15
+ function requiredChoice(value, choices, name) {
16
+ if (!choices.includes(value)) throw new Error(`Invalid ${name}: expected ${choices.join('|')}`)
17
+ return value
18
+ }
19
+ function parseUniqueCsv(value, name) {
20
+ if (typeof value !== 'string' || !value || value.length > 512) throw new Error(`Invalid ${name}`)
21
+ const parts = value.split(',')
22
+ if (!parts.length || parts.some(part => !part || part !== part.trim()) || new Set(parts).size !== parts.length) throw new Error(`Invalid or duplicate ${name}`)
23
+ return parts
24
+ }
25
+ export function parsePlanArguments(args) {
26
+ if (args.includes('--help') || args.includes('-h')) return { help: true }
27
+ const options = { name: 'my-feathers-app', ui: 'daisyui', render: 'spa', pwa: 'off', schema: 'typebox', auth: 'none', db: 'main:memory', primary: 'main', transport: 'rest', engine: 'auto', dryRun: true, json: false }
28
+ const seen = new Set()
29
+ for (let index = 0; index < args.length; index++) {
30
+ const flag = args[index]
31
+ if (!AVAILABLE_OPTIONS.has(flag) || flag === '--help' || seen.has(flag)) throw new Error(`Unknown or duplicate option: ${flag}`)
32
+ seen.add(flag)
33
+ if (flag === '--dry-run' || flag === '--json') options[flag === '--dry-run' ? 'dryRun' : 'json'] = true
34
+ else {
35
+ const value = args[++index]
36
+ if (!value || value.startsWith('--')) throw new Error(`Missing value for ${flag}`)
37
+ options[flag.slice(2)] = value
38
+ }
39
+ }
40
+ return options
41
+ }
42
+ export function normalizePlanOptions(input) {
43
+ const name = input.name ?? 'my-feathers-app'
44
+ if (typeof name !== 'string' || name.length > 48 || !NAME_PATTERN.test(name)) throw new Error('Invalid --name: lowercase kebab-case, maximum 48 characters')
45
+ const ui = requiredChoice(input.ui ?? 'daisyui', UI_PRESETS, '--ui')
46
+ const render = requiredChoice(input.render ?? 'spa', RENDER_PRESETS, '--render')
47
+ const pwa = requiredChoice(input.pwa ?? 'off', ['on', 'off'], '--pwa')
48
+ const schema = requiredChoice(input.schema ?? 'typebox', SCHEMA_PRESETS, '--schema')
49
+ const transport = requiredChoice(input.transport ?? 'rest', TRANSPORT_PRESETS, '--transport')
50
+ const requestedEngine = requiredChoice(input.engine ?? 'auto', RENDER_ENGINES, '--engine')
51
+ // Quasar Vite plugin works in SPA; Quasar CLI with Vite is a separate toolchain
52
+ // required for SSR/SSG. Neither integrates this Feathers plugin automatically.
53
+ const engine = requestedEngine === 'auto'
54
+ ? (ui === 'quasar2' && render !== 'spa' ? 'quasar-cli-vite' : 'vite')
55
+ : requestedEngine
56
+ if (ui !== 'quasar2' && engine === 'quasar-cli-vite') throw new Error('Quasar CLI is only compatible with --ui quasar2')
57
+ if (ui === 'quasar2' && render !== 'spa' && engine === 'vite') throw new Error('Quasar standalone Vite plugin supports SPA only; select --engine quasar-cli-vite for SSR/SSG')
58
+ const auth = parseUniqueCsv(input.auth ?? 'none', '--auth')
59
+ for (const strategy of auth) requiredChoice(strategy, AUTH_PRESETS, '--auth')
60
+ if (auth.includes('none') && auth.length > 1) throw new Error('Cannot combine --auth none with authentication strategies')
61
+ // AuthenticationService issues JWTs for the standard local and OAuth flows.
62
+ if (auth.some(strategy => ['local', 'oauth'].includes(strategy)) && !auth.includes('jwt')) auth.unshift('jwt')
63
+ const databases = parseUniqueCsv(input.db ?? 'main:memory', '--db').map(spec => {
64
+ const match = /^([a-z][a-z0-9-]{0,31}):([a-z]+)$/.exec(spec)
65
+ if (!match) throw new Error('Invalid --db: use alias:adapter[,alias:adapter]')
66
+ const [, alias, adapter] = match
67
+ requiredChoice(adapter, DATABASE_PRESETS, '--db adapter')
68
+ return { alias, adapter }
69
+ })
70
+ if (new Set(databases.map(db => db.alias)).size !== databases.length) throw new Error('Duplicate database connection alias')
71
+ const primary = input.primary ?? 'main'
72
+ if (!databases.some(db => db.alias === primary)) throw new Error('--primary must match a --db alias')
73
+ return { name, ui, render, engine, pwa, schema, transport, auth, databases, primary, dryRun: true, json: Boolean(input.json) }
74
+ }