@dokebi/cli 0.2.5 → 0.4.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.
Files changed (81) hide show
  1. package/AI.md +181 -87
  2. package/README.md +55 -24
  3. package/catalog.json +2203 -0
  4. package/dist/app-urls.d.ts +21 -0
  5. package/dist/app-urls.js +94 -0
  6. package/dist/banner-art.d.ts +4 -0
  7. package/dist/banner-art.js +31 -0
  8. package/dist/banner.d.ts +25 -0
  9. package/dist/banner.js +202 -0
  10. package/dist/bin.d.ts +2 -1
  11. package/dist/bin.js +475 -46
  12. package/dist/catalog.d.ts +161 -0
  13. package/dist/catalog.js +110 -0
  14. package/dist/check/form-rules.d.ts +13 -0
  15. package/dist/check/form-rules.js +224 -0
  16. package/dist/check/index.d.ts +23 -0
  17. package/dist/check/index.js +70 -0
  18. package/dist/check/packages.d.ts +17 -0
  19. package/dist/check/packages.js +161 -0
  20. package/dist/check/problems.d.ts +19 -0
  21. package/dist/check/problems.js +22 -0
  22. package/dist/check/project-rules.d.ts +10 -0
  23. package/dist/check/project-rules.js +142 -0
  24. package/dist/check/source-rules.d.ts +28 -0
  25. package/dist/check/source-rules.js +273 -0
  26. package/dist/check/typescript.d.ts +46 -0
  27. package/dist/check/typescript.js +168 -0
  28. package/dist/commands.d.ts +18 -0
  29. package/dist/commands.js +372 -0
  30. package/dist/config-edit.d.ts +5 -4
  31. package/dist/config-edit.js +9 -8
  32. package/dist/configuration.d.ts +1 -0
  33. package/dist/configuration.js +2 -1
  34. package/dist/dev-infra.d.ts +62 -0
  35. package/dist/dev-infra.js +215 -0
  36. package/dist/env-file.d.ts +24 -0
  37. package/dist/env-file.js +100 -0
  38. package/dist/generator.d.ts +24 -1
  39. package/dist/generator.js +57 -47
  40. package/dist/guide.d.ts +29 -0
  41. package/dist/guide.js +90 -0
  42. package/dist/install-plan.d.ts +8 -0
  43. package/dist/install-plan.js +21 -4
  44. package/dist/layout.d.ts +5 -4
  45. package/dist/layout.js +8 -7
  46. package/dist/log-writer-control.d.ts +55 -0
  47. package/dist/log-writer-control.js +327 -0
  48. package/dist/log-writer.js +53 -12
  49. package/dist/manifest.d.ts +28 -0
  50. package/dist/manifest.js +77 -2
  51. package/dist/npm-client.d.ts +49 -0
  52. package/dist/npm-client.js +162 -41
  53. package/dist/output.d.ts +1 -1
  54. package/dist/output.js +14 -8
  55. package/dist/plugin-lifecycle.d.ts +7 -0
  56. package/dist/plugin-lifecycle.js +21 -3
  57. package/dist/plugins-command.d.ts +47 -0
  58. package/dist/plugins-command.js +189 -0
  59. package/dist/project-guide.d.ts +36 -1
  60. package/dist/project-guide.js +142 -18
  61. package/dist/project-inspector.d.ts +10 -0
  62. package/dist/project-inspector.js +51 -2
  63. package/dist/project-state.d.ts +13 -2
  64. package/dist/project-state.js +8 -6
  65. package/dist/project.d.ts +89 -7
  66. package/dist/project.js +835 -85
  67. package/dist/recipe-record.d.ts +24 -0
  68. package/dist/recipe-record.js +67 -0
  69. package/dist/recipe.d.ts +120 -0
  70. package/dist/recipe.js +801 -0
  71. package/dist/registry.d.ts +79 -0
  72. package/dist/registry.js +212 -0
  73. package/dist/rotating-log.d.ts +12 -0
  74. package/dist/rotating-log.js +19 -0
  75. package/dist/server.d.ts +94 -4
  76. package/dist/server.js +624 -64
  77. package/dist/verify.d.ts +62 -0
  78. package/dist/verify.js +380 -0
  79. package/dist/vite-host-process.d.ts +20 -0
  80. package/dist/vite-host-process.js +117 -0
  81. package/package.json +30 -6
package/dist/recipe.js ADDED
@@ -0,0 +1,801 @@
1
+ // `dokebi recipe` (D-0065): turns a one-line request into questions, a plan and an applied
2
+ // project. A recipe names capabilities, never packages; the catalog maps each capability to the
3
+ // package that provides it. Without every answer nothing is installed (ANSWERS_REQUIRED). With
4
+ // answers each step calls the existing transactional command, in order, skipping steps that are
5
+ // already satisfied and stopping at the first failure with the steps that completed.
6
+ import { spawn } from 'node:child_process';
7
+ import { readFile } from 'node:fs/promises';
8
+ import { join, resolve } from 'node:path';
9
+ import { parse } from 'yaml';
10
+ import { HOST_CAPABILITY, loadCatalog, providersOf, } from './catalog.js';
11
+ import { CONFIG_FILE, parseProjectConfig } from './configuration.js';
12
+ import { COMPOSE_FILE, commandOnPath, composeServiceRunning, DB_SERVICE, dockerAvailable, freePortAfter, portInUse, portQuestion, URL_PLACEHOLDER_PREFIX, } from './dev-infra.js';
13
+ import { debug, describeError, isMissing } from './diagnostics.js';
14
+ import { CliError } from './errors.js';
15
+ import { exists } from './filesystem.js';
16
+ import { OutputTail } from './npm-client.js';
17
+ import { initFolder } from './project.js';
18
+ import { saveAppliedRecipe } from './recipe-record.js';
19
+ import * as server from './server.js';
20
+ /** The first sentence of `next` (and of the guide and AGENTS.md §1), word for word. */
21
+ export const ASK_AND_WAIT = 'Ask these questions and end your turn; do not run recipe/add/config until the user answers. Only use --defaults if the user said to decide.';
22
+ /** `dokebi recipe list`. */
23
+ export async function listRecipes() {
24
+ const catalog = await loadCatalog();
25
+ return {
26
+ recipes: catalog.recipes.map((recipe) => ({
27
+ name: recipe.name,
28
+ summary: recipe.summary,
29
+ questions: recipe.questions.filter((id) => !recipe.fixed?.[id]),
30
+ match: [...recipe.match],
31
+ })),
32
+ };
33
+ }
34
+ function recipeOf(catalog, name) {
35
+ const recipe = catalog.recipes.find((item) => item.name === name);
36
+ if (!recipe)
37
+ throw new CliError('UNKNOWN_RECIPE', `No recipe "${name}"`, {
38
+ details: { recipe: name, recipes: catalog.recipes.map((item) => item.name) },
39
+ phase: 'arguments',
40
+ });
41
+ return recipe;
42
+ }
43
+ function choiceOf(catalog, id) {
44
+ const choice = catalog.choices.find((item) => item.id === id);
45
+ if (!choice)
46
+ throw new CliError('CATALOG_INVALID', `Recipe question ${id} has no choice`);
47
+ return choice;
48
+ }
49
+ /** `when: { db: '!none' }`: whether the question applies to these answers (unknown: asked). */
50
+ function applies(choice, answers) {
51
+ for (const [id, rule] of Object.entries(choice.when ?? {})) {
52
+ const value = answers[id];
53
+ if (value === undefined)
54
+ continue;
55
+ if (rule.startsWith('!') ? value === rule.slice(1) : value !== rule)
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ function variantOf(catalog, capability) {
61
+ if (!capability)
62
+ return undefined;
63
+ for (const item of catalog.packages)
64
+ for (const variant of item.variants)
65
+ if (variant.capability === capability)
66
+ return variant;
67
+ return undefined;
68
+ }
69
+ /** The plugin package that provides a capability (the recipe never names packages). */
70
+ function provider(catalog, capability) {
71
+ const found = providersOf(catalog, capability).find((item) => item.kind === 'plugin');
72
+ if (!found)
73
+ throw new CliError('CATALOG_INVALID', `No plugin provides ${capability}`, {
74
+ details: { capability },
75
+ });
76
+ return found.package;
77
+ }
78
+ /** The devInfra docker note when `docker` is not on PATH (the default stays; the user decides). */
79
+ export const DOCKER_NOT_FOUND = 'docker was not found on PATH: install and start Docker before applying with docker, or choose url (a database server you already have)';
80
+ /** Question list with options adjusted to the answers given so far (docker per db variant). */
81
+ function questions(catalog, recipe, answers, dockerOnPath = () => commandOnPath('docker')) {
82
+ const values = Object.fromEntries(Object.entries(answers).map(([id, item]) => [id, item.value]));
83
+ const db = catalog.choices
84
+ .find((item) => item.id === 'db')
85
+ ?.options.find((option) => option.value === values.db);
86
+ return recipe.questions
87
+ .filter((id) => !recipe.fixed?.[id])
88
+ .map((id) => {
89
+ const choice = choiceOf(catalog, id);
90
+ const options = choice.options.map((option) => {
91
+ if (id !== 'devInfra' || option.value !== 'docker' || !db?.capability)
92
+ return option;
93
+ const docker = variantOf(catalog, db.capability)?.devInfra?.docker;
94
+ return docker
95
+ ? { ...option, alsoInstalls: [...option.alsoInstalls, `docker image ${docker.image}`] }
96
+ : { ...option, available: false, why: `${db.value} has no Docker setup` };
97
+ });
98
+ // AI-M03: say what this machine lacks, but leave the choice (and the default) to the user.
99
+ const missingDocker = id === 'devInfra' &&
100
+ options.some((option) => option.value === 'docker' && option.available) &&
101
+ !dockerOnPath();
102
+ const shown = missingDocker
103
+ ? options.map((option) => option.value === 'docker'
104
+ ? { ...option, missingNeeds: ['docker'], note: DOCKER_NOT_FOUND }
105
+ : option)
106
+ : options;
107
+ const answer = answers[id];
108
+ return {
109
+ id,
110
+ flag: choice.flag,
111
+ question: choice.question,
112
+ options: shown,
113
+ default: choice.default,
114
+ defaultReason: missingDocker && choice.default === 'docker'
115
+ ? `${choice.defaultReason}. Note: ${DOCKER_NOT_FOUND}`
116
+ : choice.defaultReason,
117
+ ...(choice.when ? { when: choice.when } : {}),
118
+ ...(answer ? { answer } : {}),
119
+ };
120
+ });
121
+ }
122
+ /** Collects answers from flags, recipe-fixed values and (when asked) defaults; validates them. */
123
+ function answer(catalog, recipe, request) {
124
+ const answers = {};
125
+ for (const id of Object.keys(request.answers))
126
+ if (!recipe.questions.includes(id) || recipe.fixed?.[id])
127
+ throw new CliError('USAGE', `The ${recipe.name} recipe does not ask ${id}`, {
128
+ details: {
129
+ recipe: recipe.name,
130
+ questions: recipe.questions.filter((q) => !recipe.fixed?.[q]),
131
+ },
132
+ phase: 'arguments',
133
+ });
134
+ for (const [id, value] of Object.entries(recipe.fixed ?? {}))
135
+ answers[id] = { value, answeredBy: 'recipe' };
136
+ for (const id of recipe.questions) {
137
+ if (answers[id])
138
+ continue;
139
+ const choice = choiceOf(catalog, id);
140
+ const given = request.answers[id];
141
+ if (given === undefined)
142
+ continue;
143
+ const option = choice.options.find((item) => item.value === given);
144
+ if (!option)
145
+ throw new CliError('INVALID_ANSWER', `${choice.flag} ${given} is not an option`, {
146
+ details: { question: id, options: choice.options.map((item) => item.value) },
147
+ phase: 'arguments',
148
+ });
149
+ if (!option.available)
150
+ throw new CliError('OPTION_ABSENT', `${choice.flag} ${given}: ${option.why ?? 'not available'}`, {
151
+ details: { question: id, option: given, why: option.why ?? null },
152
+ phase: 'arguments',
153
+ });
154
+ answers[id] = { value: given, answeredBy: 'flag' };
155
+ }
156
+ const missing = [];
157
+ const values = () => Object.fromEntries(Object.entries(answers).map(([id, item]) => [id, item.value]));
158
+ for (const id of recipe.questions) {
159
+ if (answers[id])
160
+ continue;
161
+ const choice = choiceOf(catalog, id);
162
+ if (!applies(choice, values()))
163
+ continue;
164
+ if (request.defaults || request.answersFrom === 'defaults')
165
+ answers[id] = {
166
+ value: choice.default,
167
+ answeredBy: 'default',
168
+ defaultReason: choice.defaultReason,
169
+ };
170
+ else
171
+ missing.push(id);
172
+ }
173
+ // A question that does not apply (devInfra with db none) is dropped even if it was answered.
174
+ for (const id of Object.keys(answers))
175
+ if (!applies(choiceOf(catalog, id), values()))
176
+ delete answers[id];
177
+ return { answers, missing };
178
+ }
179
+ async function view(root) {
180
+ let stateText;
181
+ try {
182
+ stateText = await readFile(join(root, '.dokebi/state.json'), 'utf8');
183
+ }
184
+ catch (error) {
185
+ if (isMissing(error))
186
+ return undefined;
187
+ throw error;
188
+ }
189
+ const state = JSON.parse(stateText);
190
+ const options = parseProjectConfig(await readFile(join(root, CONFIG_FILE), 'utf8')).options;
191
+ return {
192
+ installed: new Set((state.plugins ?? []).map((plugin) => plugin.id)),
193
+ packages: new Set((state.plugins ?? []).flatMap((plugin) => plugin.packageName ?? [])),
194
+ apps: state.apps ?? [],
195
+ modules: state.modules ?? [],
196
+ options,
197
+ };
198
+ }
199
+ function option(options, path) {
200
+ let current = options;
201
+ for (const key of path) {
202
+ if (!current || typeof current !== 'object')
203
+ return undefined;
204
+ current = current[key];
205
+ }
206
+ return current;
207
+ }
208
+ /**
209
+ * Runs a command in the project folder; a failure becomes STEP_COMMAND_FAILED with the bounded,
210
+ * redacted end of its output (`OutputTail` of npm-client).
211
+ */
212
+ export async function shell(root, command, args, timeoutMs) {
213
+ const npmPath = process.env.npm_execpath;
214
+ const viaNode = command === 'npm' && npmPath?.endsWith('npm-cli.js');
215
+ const file = viaNode ? process.execPath : command;
216
+ const argv = viaNode ? [npmPath, ...args] : args;
217
+ debug('recipe: running', { command, args, root });
218
+ await new Promise((done, fail) => {
219
+ const child = spawn(file, argv, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
220
+ const output = new OutputTail();
221
+ const keep = (chunk) => output.push(chunk);
222
+ child.stdout.on('data', keep);
223
+ child.stderr.on('data', keep);
224
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs);
225
+ child.once('error', (cause) => {
226
+ clearTimeout(timer);
227
+ fail(new CliError('STEP_COMMAND_FAILED', `${command} could not run`, { cause }));
228
+ });
229
+ child.once('close', (code, signal) => {
230
+ clearTimeout(timer);
231
+ if (code === 0)
232
+ done();
233
+ else
234
+ fail(new CliError('STEP_COMMAND_FAILED', `${command} ${args.join(' ')} failed`, {
235
+ details: { exitCode: code, signal, output: output.text() },
236
+ }));
237
+ });
238
+ });
239
+ }
240
+ /** The no-CSRF warning of a plan with HTML forms, with the catalog's `instead` for csrf. */
241
+ function noCsrf(catalog) {
242
+ const csrf = catalog.absent.find((entry) => entry.capability === 'csrf');
243
+ return `No CSRF protection: csrf is absent from Dokebi${csrf ? ` — instead: ${csrf.instead}` : ''}.`;
244
+ }
245
+ /** Builds the plan steps for the answers (capability → package through the catalog). */
246
+ function steps(catalog, recipe, answers, context) {
247
+ const { root, project } = context;
248
+ const result = [];
249
+ const value = (id) => answers[id]?.value;
250
+ const now = () => view(root);
251
+ const has = async (id) => (await now())?.installed.has(id) ?? false;
252
+ /** Installed by package name: the app and module hosts come from the catalog (D-0070). */
253
+ const hasPackage = async (name) => (await now())?.packages.has(name) ?? false;
254
+ const plugin = (capability) => provider(catalog, capability);
255
+ const viewChoice = catalog.choices
256
+ .find((item) => item.id === 'view')
257
+ ?.options.find((item) => item.value === (value('view') ?? 'ejs'));
258
+ const inertia = viewChoice?.capability?.startsWith('view.inertia') ?? false;
259
+ const ssr = viewChoice?.capability === 'view.inertia-vue-ssr';
260
+ const appFlags = inertia ? ` --inertia${ssr ? ' --ssr' : ''}` : '';
261
+ const domain = recipe.web.domain;
262
+ if (context.init)
263
+ result.push({
264
+ id: 'init',
265
+ command: `npx -y @dokebi/cli init ${JSON.stringify(context.dir ?? root)}`,
266
+ why: 'create the project: core, config, this CLI, TypeScript, AGENTS.md and CLAUDE.md',
267
+ satisfied: async () => (await now()) !== undefined,
268
+ run: async () => {
269
+ await project.init(root);
270
+ },
271
+ });
272
+ const apps = plugin('web.apps');
273
+ result.push({
274
+ id: 'app-plugin',
275
+ command: `npx dokebi add ${apps}${inertia ? ' --no-first-app' : ''}`,
276
+ why: inertia
277
+ ? 'multi-app web layer (web, controller, template-ejs come with it); the first app is added as Inertia below'
278
+ : `multi-app web layer (web, controller, template-ejs come with it) and the EJS app main on ${domain}`,
279
+ satisfied: () => hasPackage(apps),
280
+ run: async () => {
281
+ await project.add(root, apps, false, [], inertia ? { firstApp: false } : {});
282
+ },
283
+ });
284
+ result.push({
285
+ id: 'web-port',
286
+ command: `npx dokebi config set dokebi-web.port=${context.port}`,
287
+ why: `serve on a fixed port (${context.port}) instead of a random one, so verify and the browser know the address`,
288
+ satisfied: async () => option((await now())?.options ?? {}, ['dokebi-web', 'port']) === context.port,
289
+ run: async () => {
290
+ await project.configSet(root, [
291
+ { plugin: 'dokebi-web', set: { path: ['port'], value: context.port } },
292
+ ]);
293
+ },
294
+ });
295
+ // HTML forms (the blog's write form) post application/x-www-form-urlencoded, which dokebi-web
296
+ // refuses (415 FORM_BODY_DISABLED) until formBody is on (AI-M02). Inertia posts JSON.
297
+ if (recipe.web.forms && !inertia)
298
+ result.push({
299
+ id: 'form-body',
300
+ command: 'npx dokebi config set dokebi-web.formBody=true',
301
+ why: `HTML forms post application/x-www-form-urlencoded; dokebi-web refuses it (415 FORM_BODY_DISABLED) until formBody is on. ${noCsrf(catalog)}`,
302
+ satisfied: async () => option((await now())?.options ?? {}, ['dokebi-web', 'formBody']) === true,
303
+ run: async () => {
304
+ await project.configSet(root, [
305
+ { plugin: 'dokebi-web', set: { path: ['formBody'], value: true } },
306
+ ]);
307
+ },
308
+ });
309
+ if (inertia && viewChoice?.capability) {
310
+ const views = plugin(viewChoice.capability);
311
+ result.push({
312
+ id: 'view-plugin',
313
+ command: `npx dokebi add ${views}`,
314
+ why: `${viewChoice.label}: the server adapter for the Vue pages`,
315
+ satisfied: () => has('dokebi-template-inertia'),
316
+ run: async () => {
317
+ await project.add(root, views);
318
+ },
319
+ });
320
+ result.push({
321
+ id: 'main-app',
322
+ command: `npx dokebi app add main${appFlags} --domain ${domain}`,
323
+ why: `the app main on ${domain} with Vue pages (adds vite.config.ts, the build script, Vite and Vue)`,
324
+ satisfied: async () => (await now())?.apps.includes('main') ?? false,
325
+ run: async () => {
326
+ await project.appAdd(root, 'main', { domains: [domain], inertia: true, ssr });
327
+ },
328
+ });
329
+ }
330
+ if (recipe.module) {
331
+ const modules = plugin('web.modules');
332
+ const moduleName = recipe.module.name;
333
+ const noViews = recipe.module.views === false || inertia;
334
+ result.push({
335
+ id: 'module-plugin',
336
+ command: `npx dokebi add ${modules}`,
337
+ why: 'feature modules (src/shared/modules/<name>) that apps mount under a prefix',
338
+ satisfied: () => hasPackage(modules),
339
+ run: async () => {
340
+ await project.add(root, modules);
341
+ },
342
+ });
343
+ result.push({
344
+ id: 'module',
345
+ command: `npx dokebi module add ${moduleName}${noViews ? ' --no-views' : ''}`,
346
+ why: `${recipe.module.why}${noViews && inertia ? ' (JSON routes: Vue pages live in the app views)' : ''}`,
347
+ satisfied: async () => (await now())?.modules.includes(moduleName) ?? false,
348
+ run: async () => {
349
+ await project.moduleAdd(root, moduleName, { views: !noViews });
350
+ },
351
+ });
352
+ }
353
+ const dbChoice = catalog.choices
354
+ .find((item) => item.id === 'db')
355
+ ?.options.find((item) => item.value === value('db'));
356
+ const variant = variantOf(catalog, dbChoice?.capability ?? null);
357
+ if (dbChoice?.capability && variant?.adapter) {
358
+ const db = plugin(dbChoice.capability);
359
+ const adapter = variant.adapter;
360
+ result.push({
361
+ id: 'db',
362
+ command: `npx dokebi add ${db} --adapter ${adapter}`,
363
+ why: `${dbChoice.label}: named connection main, adapter src/infra/db/adapters/${adapter}.ts and the ${variant.driver?.package ?? adapter} driver`,
364
+ satisfied: async () => {
365
+ const current = await now();
366
+ return ((current?.installed.has('dokebi-db') ?? false) &&
367
+ option(current?.options ?? {}, ['dokebi-db', 'connections', 'main', 'adapter']) ===
368
+ adapter);
369
+ },
370
+ run: async () => {
371
+ await project.add(root, db, false, [], { adapter });
372
+ },
373
+ });
374
+ if (variant.migrate) {
375
+ const migrate = plugin('db.migrate');
376
+ result.push({
377
+ id: 'migrate',
378
+ command: `npx dokebi add ${migrate}`,
379
+ why: 'migrations and seeds for the connection (run them at deploy time, not at app start)',
380
+ satisfied: () => has('dokebi-migrate'),
381
+ run: async () => {
382
+ await project.add(root, migrate);
383
+ },
384
+ });
385
+ // The migration history and lock of connection main use the variant's dialect.
386
+ result.push({
387
+ id: 'migrate-connection',
388
+ command: `npx dokebi config set dokebi-migrate.connections.main.dialect=${variant.name}`,
389
+ why: `migrations of connection main keep their history and lock as ${variant.name}`,
390
+ satisfied: async () => option((await now())?.options ?? {}, [
391
+ 'dokebi-migrate',
392
+ 'connections',
393
+ 'main',
394
+ 'dialect',
395
+ ]) === variant.name,
396
+ run: async () => {
397
+ await project.configSet(root, [
398
+ {
399
+ plugin: 'dokebi-migrate',
400
+ set: { path: ['connections', 'main', 'dialect'], value: variant.name },
401
+ },
402
+ ]);
403
+ },
404
+ });
405
+ }
406
+ if (value('devInfra') === 'docker') {
407
+ result.push({
408
+ id: 'dev-infra',
409
+ command: `npx dokebi dev-infra${context.dbPort ? ` --db-port ${context.dbPort}` : ''}`,
410
+ why: 'compose.yaml, a generated password in .env (git-ignored) and the matching URL in dokebi.yaml',
411
+ satisfied: async () => {
412
+ const url = option((await now())?.options ?? {}, [
413
+ 'dokebi-db',
414
+ 'connections',
415
+ 'main',
416
+ 'options',
417
+ 'url',
418
+ ]);
419
+ return ((await exists(join(root, COMPOSE_FILE))) &&
420
+ typeof url === 'string' &&
421
+ !url.startsWith(URL_PLACEHOLDER_PREFIX));
422
+ },
423
+ run: async () => {
424
+ await project.devInfra(root, context.dbPort ? { dbPort: context.dbPort } : {});
425
+ },
426
+ });
427
+ result.push({
428
+ id: 'dev-db-up',
429
+ command: 'docker compose up -d --wait',
430
+ why: `start the ${variant.name} container and wait until it is healthy`,
431
+ satisfied: () => composeServiceRunning(root),
432
+ run: () => shell(root, 'docker', ['compose', 'up', '-d', '--wait'], 300_000),
433
+ });
434
+ }
435
+ }
436
+ if (value('admin') === 'app-code') {
437
+ const session = plugin('web.session');
438
+ result.push({
439
+ id: 'session',
440
+ command: `npx dokebi add ${session}`,
441
+ why: 'server-side sessions for the admin login the app writes (Dokebi has no auth plugin); add also generates the cookie-signing secret DOKEBI_SESSION_SECRETS into .env (git-ignored, read by dev, start and verify; the value is never printed)',
442
+ satisfied: () => has('dokebi-session'),
443
+ run: async () => {
444
+ await project.add(root, session);
445
+ },
446
+ });
447
+ result.push({
448
+ id: 'admin-app',
449
+ command: `npx dokebi app add admin${appFlags} --domain admin.${domain}`,
450
+ why: `a separate admin app on admin.${domain}; its login and permission checks are app code`,
451
+ satisfied: async () => (await now())?.apps.includes('admin') ?? false,
452
+ run: async () => {
453
+ await project.appAdd(root, 'admin', { domains: [`admin.${domain}`], inertia, ssr });
454
+ },
455
+ });
456
+ }
457
+ if (inertia)
458
+ result.push({
459
+ id: 'build',
460
+ command: 'npm run build',
461
+ why: 'build the Vue pages with Vite (dist/) so the app can serve them',
462
+ satisfied: async () => false,
463
+ run: () => shell(root, 'npm', ['run', 'build'], 300_000),
464
+ });
465
+ return result;
466
+ }
467
+ async function preflight(root, answers, catalog, plan, port, dbPort,
468
+ /** The user left the choices to the agent (--defaults / --answers-from defaults). */
469
+ delegated) {
470
+ const result = [];
471
+ const devInfra = plan.find((step) => step.id === 'dev-infra');
472
+ if (devInfra) {
473
+ const docker = await dockerAvailable();
474
+ if (!docker.ok)
475
+ result.push({
476
+ code: 'DOCKER_MISSING',
477
+ severity: 'error',
478
+ message: `Docker (with compose) is not available: ${docker.error ?? 'unknown error'}`,
479
+ fix: 'start Docker, or ask the user to choose --dev-infra url (an existing database) — never switch the database silently',
480
+ });
481
+ else if (!(await devInfra.satisfied()) || !(await composeServiceRunning(root))) {
482
+ const dbChoice = catalog.choices
483
+ .find((item) => item.id === 'db')
484
+ ?.options.find((item) => item.value === answers.db?.value);
485
+ const wanted = dbPort ?? variantOf(catalog, dbChoice?.capability ?? null)?.devInfra?.docker?.port;
486
+ // A compose.yaml this project already has decides its port; it is checked by `up`.
487
+ const kept = await composePort(root);
488
+ if (wanted && kept === undefined && (await portInUse(wanted))) {
489
+ const alternative = await freePortAfter(wanted);
490
+ debug('recipe: database port in use', { port: wanted, alternative, delegated });
491
+ result.push({
492
+ code: 'PORT_IN_USE',
493
+ severity: 'error',
494
+ message: `Database port ${wanted} on 127.0.0.1 is in use`,
495
+ fix: `ask the user, then run again with --db-port ${alternative ?? '<free port>'} (compose.yaml and dokebi.yaml get the same port), or stop what uses ${wanted}`,
496
+ ...(alternative ? { alternative } : {}),
497
+ ...portQuestion({
498
+ what: 'Database',
499
+ port: wanted,
500
+ alternative,
501
+ flag: '--db-port',
502
+ delegated,
503
+ }),
504
+ });
505
+ }
506
+ }
507
+ }
508
+ const running = (await view(root)) ? (await server.status(root)).running : false;
509
+ if (!running && (await portInUse(port))) {
510
+ const alternative = await freePortAfter(port);
511
+ debug('recipe: web port in use', { port, alternative, delegated });
512
+ result.push({
513
+ code: 'PORT_IN_USE',
514
+ severity: 'error',
515
+ message: `Web port ${port} on 127.0.0.1 is in use`,
516
+ fix: `ask the user, then run again with --port ${alternative ?? '<free port>'} (or stop what uses ${port})`,
517
+ ...(alternative ? { alternative } : {}),
518
+ ...portQuestion({ what: 'Web', port, alternative, flag: '--port', delegated }),
519
+ });
520
+ }
521
+ if (plan.some((step) => step.id === 'build'))
522
+ result.push({
523
+ code: 'VITE_BUILD',
524
+ severity: 'info',
525
+ message: 'vite, vue, @inertiajs/vue3 and @vitejs/plugin-vue are installed and npm run build runs',
526
+ });
527
+ if (plan.some((step) => step.id === 'db'))
528
+ result.push({
529
+ code: 'DRIVER_DOWNLOAD',
530
+ severity: 'info',
531
+ message: 'the database driver is installed from the npm registry',
532
+ });
533
+ return result;
534
+ }
535
+ /**
536
+ * The taken ports of a preflight as one question for the result (AI-M03): `waitForUser`,
537
+ * `question` and `next`, like the recipe questions. Empty when no port is taken.
538
+ */
539
+ function portAsks(checks) {
540
+ const ports = checks.filter((item) => item.code === 'PORT_IN_USE' && item.question);
541
+ if (!ports.length)
542
+ return {};
543
+ return {
544
+ waitForUser: ports.some((item) => item.waitForUser),
545
+ question: ports.map((item) => item.question).join(' '),
546
+ next: [...new Set(ports.map((item) => item.next))].join(' '),
547
+ };
548
+ }
549
+ async function composePort(root) {
550
+ try {
551
+ const document = parse(await readFile(join(root, COMPOSE_FILE), 'utf8'));
552
+ const published = String(document?.services?.[DB_SERVICE]?.ports?.[0] ?? '').match(/(?:^|:)(\d+):\d+$/)?.[1];
553
+ return published ? Number(published) : undefined;
554
+ }
555
+ catch (error) {
556
+ if (isMissing(error))
557
+ return undefined;
558
+ throw new CliError('COMPOSE_INVALID', `${COMPOSE_FILE} cannot be read`, { cause: error });
559
+ }
560
+ }
561
+ function nextSteps(catalog, recipe, answers, plan, port) {
562
+ const result = [];
563
+ // The app and module hosts by capability (D-0070), the rest by the step that installed them.
564
+ const ids = new Set([HOST_CAPABILITY.apps, HOST_CAPABILITY.modules].flatMap((capability) => providersOf(catalog, capability).find((item) => item.kind === 'plugin')?.id ?? []));
565
+ if (plan.some((step) => step.id === 'view-plugin'))
566
+ ids.add('dokebi-template-inertia');
567
+ else
568
+ ids.add('dokebi-template-ejs');
569
+ if (plan.some((step) => step.id === 'db'))
570
+ ids.add('dokebi-db');
571
+ if (plan.some((step) => step.id === 'migrate'))
572
+ ids.add('dokebi-migrate');
573
+ if (plan.some((step) => step.id === 'session'))
574
+ ids.add('dokebi-session');
575
+ for (const id of ids)
576
+ result.push({
577
+ command: `npx dokebi plugins doc ${id} --json`,
578
+ why: `read the installed ${id} AI.md before writing code with it`,
579
+ });
580
+ for (const line of recipe.then ?? [])
581
+ result.push({ command: `edit: ${line}`, why: recipe.summary });
582
+ if (answers.devInfra?.value === 'url')
583
+ result.push({
584
+ command: 'edit dokebi.yaml: options.dokebi-db.connections.main.options.url',
585
+ why: 'url mode: write the existing database URL (with its password) here; never pass it with --set',
586
+ });
587
+ if (answers.admin?.value === 'app-code')
588
+ result.push({
589
+ command: 'edit src/apps/admin: login form, password hash (node:crypto scrypt), session check, CSRF token',
590
+ why: 'Dokebi has no auth or csrf plugin (absent): the admin app writes them',
591
+ });
592
+ if (plan.some((step) => step.id === 'form-body'))
593
+ result.push({
594
+ command: 'edit: every HTML form that changes data (the write form) — a CSRF token check in app code',
595
+ why: `dokebi-web.formBody is on so forms post; ${noCsrf(catalog)} Tell the user the forms have no CSRF protection until the app adds it.`,
596
+ });
597
+ // D-0074: how a write form refuses input and shows it, the same for the schema and the app.
598
+ if (recipe.web.forms)
599
+ result.push(plan.some((step) => step.id === 'view-plugin')
600
+ ? {
601
+ command: "edit: every Inertia write form — useForm().post(url, { only: ['<props it changes>'] }), each field's message from form.errors.<field>; in the handler throw validationError({ <field>: '<message>' }) from @dokebi/web",
602
+ why: 'a refused field comes back to the form (422 → 303 back with errors, @dokebi/session); only keeps the GET after the redirect to the props that changed (npx dokebi check warns: inertia-form-only)',
603
+ }
604
+ : {
605
+ command: 'edit: every write form — <form method="post">, each field value="<%= data.old.<field> ?? \'\' %>" and its message from data.errors.<field>; in the handler throw validationError({ <field>: \'<message>\' }) from @dokebi/web',
606
+ why: plan.some((step) => step.id === 'session')
607
+ ? 'a refused route schema and the app check give the same 422 VALIDATION_FAILED (D-0074); with @dokebi/session (in this plan) the refused form comes back to its page (303) with data.errors and data.old; never send a hand-made 422'
608
+ : 'a refused route schema and the app check give the same 422 VALIDATION_FAILED (D-0074); never send a hand-made 422. Without a session the refused form shows views/default/errors/422.ejs (else errors/error.ejs) with fields, errors and old',
609
+ });
610
+ // D-0074 addendum 1: an EJS write form comes back to its page only with a session.
611
+ if (recipe.web.forms && !plan.some((step) => step.id === 'view-plugin' || step.id === 'session'))
612
+ result.push({
613
+ command: 'npx dokebi add @dokebi/session',
614
+ why: 'so a refused HTML form goes back to its page with the typed values and the field messages (D-0074 addendum 1) instead of the 422 page; ask the user first (it writes DOKEBI_SESSION_SECRETS into .env and keeps sessions in memory until another store is configured)',
615
+ });
616
+ const absent = catalog.absent.map((entry) => entry.capability).join(', ');
617
+ result.push({
618
+ command: 'npx dokebi plugins search <feature> --json',
619
+ why: `before adding a feature, check it exists; absent here: ${absent}`,
620
+ });
621
+ result.push({
622
+ command: 'npx dokebi verify --json',
623
+ why: `doctor, typecheck, build, start on port ${port}, HTTP checks, stop — say done only when ok is true`,
624
+ });
625
+ if (answers.devInfra?.value === 'docker')
626
+ result.push({
627
+ command: 'docker compose down',
628
+ why: 'stop the development database when you are done (docker compose up -d --wait starts it again)',
629
+ });
630
+ return result;
631
+ }
632
+ /** The lines to tell the user (RecipeOutcome.report). */
633
+ function report(catalog, answers, answersFrom, plan) {
634
+ const lines = [];
635
+ const list = (by) => Object.entries(answers).filter(([, item]) => item.answeredBy === by);
636
+ const defaults = list('default');
637
+ if (defaults.length)
638
+ lines.push(`Chosen by default (tell the user): ${defaults
639
+ .map(([id, item]) => `${id}=${item.value} (${item.defaultReason})`)
640
+ .join('; ')}`);
641
+ const given = list('flag')
642
+ .map(([id, item]) => `${id}=${item.value}`)
643
+ .join(', ');
644
+ if (given)
645
+ lines.push(answersFrom === 'user'
646
+ ? `Answers from the user: ${given}`
647
+ : answersFrom === 'defaults'
648
+ ? `Chosen by you while the user left the choice to you (tell the user): ${given}`
649
+ : `Answers not yet confirmed (${given}): apply needs --answers-from user (only answers the user gave) or --defaults (only when the user said to decide)`);
650
+ lines.push(...notices(catalog, plan));
651
+ return lines.join('\n');
652
+ }
653
+ /** What the user must know about the applied plan (in the report and `.dokebi/recipe.json`). */
654
+ function notices(catalog, plan) {
655
+ return plan.some((step) => step.id === 'form-body')
656
+ ? [`HTML forms are on (dokebi-web.formBody=true). ${noCsrf(catalog)}`]
657
+ : [];
658
+ }
659
+ /** A dry run's first next step: the same command applied, with the answer source to state. */
660
+ function applyStep(recipe, request, answersFrom) {
661
+ const flagOf = {
662
+ view: '--view',
663
+ db: '--db',
664
+ devInfra: '--dev-infra',
665
+ admin: '--admin',
666
+ };
667
+ const parts = [`npx dokebi recipe ${recipe.name}`];
668
+ if (request.dir !== undefined)
669
+ parts.push(`--dir ${JSON.stringify(request.dir)}`);
670
+ for (const [id, value] of Object.entries(request.answers))
671
+ parts.push(`${flagOf[id]} ${value}`);
672
+ if (request.port !== undefined)
673
+ parts.push(`--port ${request.port}`);
674
+ if (request.dbPort !== undefined)
675
+ parts.push(`--db-port ${request.dbPort}`);
676
+ if (answersFrom === 'defaults')
677
+ parts.push('--defaults');
678
+ else {
679
+ parts.push(`--answers-from ${answersFrom ?? 'user'}`);
680
+ // The user answered some questions and left the rest to you.
681
+ if (request.defaults)
682
+ parts.push('--defaults');
683
+ }
684
+ parts.push('--json');
685
+ return {
686
+ command: parts.join(' '),
687
+ why: answersFrom === undefined
688
+ ? 'apply this plan — only if the user gave these answers (--answers-from user); otherwise ask and end your turn'
689
+ : 'apply this plan (the same answers without --dry-run)',
690
+ };
691
+ }
692
+ /** `dokebi recipe <name> …`. */
693
+ export async function runRecipe(request, project) {
694
+ const catalog = await loadCatalog();
695
+ const recipe = recipeOf(catalog, request.name);
696
+ const { answers, missing } = answer(catalog, recipe, request);
697
+ if (missing.length) {
698
+ const flags = recipe.questions
699
+ .filter((id) => !recipe.fixed?.[id])
700
+ .map((id) => `${choiceOf(catalog, id).flag} <${id}>`)
701
+ .join(' ');
702
+ return {
703
+ status: 'needs-answers',
704
+ recipe: recipe.name,
705
+ summary: recipe.summary,
706
+ questions: questions(catalog, recipe, answers),
707
+ missing,
708
+ next: `${ASK_AND_WAIT} Show every question with its options, pros/cons and the default with its reason. After the user answers: npx dokebi recipe ${recipe.name} ${flags} --answers-from user --dry-run --json, then the same without --dry-run. If the user said to decide: --defaults instead of the answers, and tell them the report.`,
709
+ waitForUser: true,
710
+ };
711
+ }
712
+ const answersFrom = request.answersFrom ?? (request.defaults ? 'defaults' : undefined);
713
+ // D-0065 §11 (AI-M02): an agent printed the questions and installed anyway. Applying needs the
714
+ // agent to say where the answers came from; it is recorded in the result (answersFrom, report).
715
+ if (!request.dryRun && answersFrom === undefined)
716
+ throw new CliError('ANSWERS_SOURCE_REQUIRED', 'Say where the answers came from: --answers-from user (the user answered these questions) or --defaults (the user said to decide). If the user has not answered yet, ask and end your turn.', {
717
+ phase: 'arguments',
718
+ details: {
719
+ answers: Object.fromEntries(Object.entries(answers).map(([id, item]) => [id, item.value])),
720
+ fix: `${ASK_AND_WAIT} Then run the same command with --answers-from user (or --defaults when the user left the choice to you).`,
721
+ },
722
+ });
723
+ const root = resolve(request.dir ?? process.cwd());
724
+ const current = await view(root);
725
+ let init = false;
726
+ if (!current) {
727
+ if (request.dir === undefined)
728
+ throw new CliError('NOT_A_PROJECT', 'The current folder is not a Dokebi project; pass --dir <new folder> (--dir . when this folder is empty) or run inside a project', { phase: 'arguments', details: { root } });
729
+ // A new folder, or an existing empty one (D-0065 §11: the same rule as dokebi init).
730
+ await initFolder(root);
731
+ init = true;
732
+ }
733
+ const port = request.port ?? recipe.web.port;
734
+ const plan = steps(catalog, recipe, answers, {
735
+ root,
736
+ project,
737
+ port,
738
+ ...(request.dbPort !== undefined ? { dbPort: request.dbPort } : {}),
739
+ init,
740
+ ...(request.dir !== undefined ? { dir: request.dir } : {}),
741
+ });
742
+ const checks = await preflight(root, answers, catalog, plan, port, request.dbPort, answersFrom === 'defaults');
743
+ const asks = portAsks(checks);
744
+ const base = {
745
+ recipe: recipe.name,
746
+ root,
747
+ answers,
748
+ answersFrom: answersFrom ?? null,
749
+ report: report(catalog, answers, answersFrom, plan),
750
+ plan: { steps: plan.map(({ id, command, why }) => ({ id, command, why })) },
751
+ preflight: checks,
752
+ nextSteps: [
753
+ ...(request.dryRun ? [applyStep(recipe, request, answersFrom)] : []),
754
+ ...nextSteps(catalog, recipe, answers, plan, port),
755
+ ],
756
+ };
757
+ const blocking = checks.filter((item) => item.severity === 'error');
758
+ if (request.dryRun)
759
+ return { status: 'planned', ...base, completedSteps: [], skippedSteps: [], ...asks };
760
+ if (blocking.length)
761
+ throw new CliError('PREFLIGHT_FAILED', blocking.map((item) => `${item.code}: ${item.message}`).join('; '), { details: { preflight: checks, plan: base.plan, ...asks }, phase: 'arguments' });
762
+ const completedSteps = [];
763
+ const skippedSteps = [];
764
+ for (const step of plan) {
765
+ if (await step.satisfied()) {
766
+ skippedSteps.push(step.id);
767
+ debug('recipe: step already satisfied', { step: step.id });
768
+ continue;
769
+ }
770
+ try {
771
+ await step.run();
772
+ }
773
+ catch (error) {
774
+ debug('recipe: step failed', { step: step.id, error: describeError(error) });
775
+ throw new CliError('RECIPE_STEP_FAILED', `Step ${step.id} failed: ${step.command}`, {
776
+ cause: error,
777
+ details: {
778
+ failedStep: { id: step.id, command: step.command },
779
+ completedSteps,
780
+ skippedSteps,
781
+ error: {
782
+ code: error instanceof CliError ? error.code : 'INTERNAL_ERROR',
783
+ message: error instanceof Error ? error.message : String(error),
784
+ details: error instanceof CliError ? (error.details ?? null) : null,
785
+ },
786
+ },
787
+ });
788
+ }
789
+ completedSteps.push(step.id);
790
+ }
791
+ // `dokebi verify` returns these as tellUser: the agent's final message must carry them (AI-M03).
792
+ await saveAppliedRecipe(root, {
793
+ recipe: recipe.name,
794
+ appliedAt: new Date().toISOString(),
795
+ answersFrom: answersFrom,
796
+ // In the recipe's question order, the order the user saw them in.
797
+ answers: Object.fromEntries(recipe.questions.flatMap((id) => (answers[id] ? [[id, answers[id]]] : []))),
798
+ notices: notices(catalog, plan),
799
+ });
800
+ return { status: 'applied', ...base, completedSteps, skippedSteps };
801
+ }