@dokebi/cli 0.4.2 → 0.4.27

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 (200) hide show
  1. package/AI.md +279 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2520 -258
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +638 -37
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +15 -0
  46. package/dist/check/shared-state-rules.js +117 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +381 -36
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-secrets.d.ts +46 -0
  70. package/dist/config-secrets.js +187 -0
  71. package/dist/config-show.d.ts +82 -0
  72. package/dist/config-show.js +563 -0
  73. package/dist/config-values.d.ts +8 -0
  74. package/dist/config-values.js +68 -0
  75. package/dist/configuration-scaffold.d.ts +42 -0
  76. package/dist/configuration-scaffold.js +258 -4
  77. package/dist/configuration.d.ts +63 -3
  78. package/dist/configuration.js +178 -4
  79. package/dist/deploy-scripts.d.ts +4 -0
  80. package/dist/deploy-scripts.js +361 -0
  81. package/dist/deploy-templates.d.ts +46 -0
  82. package/dist/deploy-templates.js +379 -0
  83. package/dist/deploy.d.ts +46 -0
  84. package/dist/deploy.js +592 -0
  85. package/dist/deployment.d.ts +2 -0
  86. package/dist/deployment.js +111 -0
  87. package/dist/describe-db.d.ts +22 -0
  88. package/dist/describe-db.js +176 -0
  89. package/dist/describe.d.ts +92 -0
  90. package/dist/describe.js +417 -0
  91. package/dist/dev-compose.d.ts +20 -0
  92. package/dist/dev-compose.js +154 -0
  93. package/dist/dev-guard.js +44 -0
  94. package/dist/dev-host.d.ts +13 -0
  95. package/dist/dev-host.js +55 -0
  96. package/dist/dev-infra.d.ts +72 -4
  97. package/dist/dev-infra.js +237 -17
  98. package/dist/dev-watch.d.ts +34 -0
  99. package/dist/dev-watch.js +192 -0
  100. package/dist/diagnostics.d.ts +10 -7
  101. package/dist/diagnostics.js +22 -4
  102. package/dist/drift.d.ts +50 -0
  103. package/dist/drift.js +218 -0
  104. package/dist/env-file.d.ts +25 -1
  105. package/dist/env-file.js +64 -6
  106. package/dist/errors.d.ts +7 -1
  107. package/dist/example-config.d.ts +2 -0
  108. package/dist/example-config.js +3 -0
  109. package/dist/first-admin.d.ts +43 -0
  110. package/dist/first-admin.js +175 -0
  111. package/dist/formatter-ignore.d.ts +37 -0
  112. package/dist/formatter-ignore.js +344 -0
  113. package/dist/generator.d.ts +45 -3
  114. package/dist/generator.js +205 -15
  115. package/dist/guide.d.ts +1 -1
  116. package/dist/guide.js +42 -21
  117. package/dist/install-module.d.ts +20 -2
  118. package/dist/install-module.js +45 -8
  119. package/dist/install-plan.js +5 -3
  120. package/dist/layout.d.ts +12 -1
  121. package/dist/layout.js +19 -6
  122. package/dist/log-writer-control.d.ts +2 -0
  123. package/dist/log-writer-control.js +17 -2
  124. package/dist/log-writer.js +43 -10
  125. package/dist/logging-config.d.ts +8 -3
  126. package/dist/logging-config.js +19 -5
  127. package/dist/manifest.d.ts +29 -1
  128. package/dist/manifest.js +84 -6
  129. package/dist/merge-install-defaults.d.ts +0 -1
  130. package/dist/merge-install-defaults.js +13 -47
  131. package/dist/npm-client.d.ts +40 -4
  132. package/dist/npm-client.js +152 -15
  133. package/dist/npm-paths.js +18 -4
  134. package/dist/openapi.d.ts +27 -0
  135. package/dist/openapi.js +649 -0
  136. package/dist/option-fixes.d.ts +39 -0
  137. package/dist/option-fixes.js +130 -0
  138. package/dist/output.d.ts +1 -1
  139. package/dist/output.js +5 -5
  140. package/dist/plugin-lifecycle.d.ts +34 -2
  141. package/dist/plugin-lifecycle.js +195 -16
  142. package/dist/plugin-options.d.ts +19 -4
  143. package/dist/plugin-options.js +138 -6
  144. package/dist/plugins-command.js +44 -7
  145. package/dist/process-group.d.ts +39 -0
  146. package/dist/process-group.js +132 -0
  147. package/dist/production-doctor.d.ts +3 -0
  148. package/dist/production-doctor.js +176 -0
  149. package/dist/project-guide.d.ts +19 -1
  150. package/dist/project-guide.js +119 -20
  151. package/dist/project-inspector.d.ts +15 -0
  152. package/dist/project-inspector.js +497 -33
  153. package/dist/project-state.d.ts +5 -0
  154. package/dist/project-state.js +8 -0
  155. package/dist/project.d.ts +203 -19
  156. package/dist/project.js +3006 -333
  157. package/dist/recipe-facts.d.ts +127 -0
  158. package/dist/recipe-facts.js +563 -0
  159. package/dist/recipe-login.d.ts +63 -0
  160. package/dist/recipe-login.js +464 -0
  161. package/dist/recipe-mail.d.ts +21 -0
  162. package/dist/recipe-mail.js +214 -0
  163. package/dist/recipe-members.d.ts +35 -0
  164. package/dist/recipe-members.js +178 -0
  165. package/dist/recipe-record.d.ts +82 -7
  166. package/dist/recipe-record.js +226 -22
  167. package/dist/recipe-reset.d.ts +2 -0
  168. package/dist/recipe-reset.js +117 -0
  169. package/dist/recipe-site.d.ts +48 -0
  170. package/dist/recipe-site.js +396 -0
  171. package/dist/recipe-step.d.ts +42 -0
  172. package/dist/recipe-step.js +89 -0
  173. package/dist/recipe.d.ts +56 -20
  174. package/dist/recipe.js +703 -131
  175. package/dist/registry.d.ts +98 -2
  176. package/dist/registry.js +171 -5
  177. package/dist/reloadable.d.ts +35 -0
  178. package/dist/reloadable.js +257 -0
  179. package/dist/remote-verify.d.ts +31 -0
  180. package/dist/remote-verify.js +204 -0
  181. package/dist/server.d.ts +43 -4
  182. package/dist/server.js +601 -52
  183. package/dist/settings.d.ts +48 -0
  184. package/dist/settings.js +168 -0
  185. package/dist/site-auth.d.ts +39 -0
  186. package/dist/site-auth.js +195 -0
  187. package/dist/template-fixes.d.ts +26 -0
  188. package/dist/template-fixes.js +285 -0
  189. package/dist/test-reporter.d.ts +49 -0
  190. package/dist/test-reporter.js +190 -0
  191. package/dist/transaction.d.ts +47 -0
  192. package/dist/transaction.js +241 -7
  193. package/dist/verify-tests.d.ts +98 -0
  194. package/dist/verify-tests.js +449 -0
  195. package/dist/verify.d.ts +103 -3
  196. package/dist/verify.js +272 -21
  197. package/dist/view-choice.d.ts +58 -0
  198. package/dist/view-choice.js +131 -0
  199. package/dist/vite-watch.js +4 -2
  200. package/package.json +8 -6
package/dist/recipe.js CHANGED
@@ -3,22 +3,61 @@
3
3
  // package that provides it. Without every answer nothing is installed (ANSWERS_REQUIRED). With
4
4
  // answers each step calls the existing transactional command, in order, skipping steps that are
5
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';
6
+ import { readdir, readFile } from 'node:fs/promises';
8
7
  import { join, resolve } from 'node:path';
9
8
  import { parse } from 'yaml';
9
+ import { ANSWER_SOURCES, ASK_AND_WAIT } from './capability-guide.js';
10
10
  import { HOST_CAPABILITY, loadCatalog, providersOf, } from './catalog.js';
11
- import { CONFIG_FILE, parseProjectConfig } from './configuration.js';
11
+ import { runDevCompose } from './dev-compose.js';
12
+ import { AUTO_DOMAIN, devHost, readProjectHostLabel } from './dev-host.js';
12
13
  import { COMPOSE_FILE, commandOnPath, composeServiceRunning, DB_SERVICE, dockerAvailable, freePortAfter, portInUse, portQuestion, URL_PLACEHOLDER_PREFIX, } from './dev-infra.js';
13
14
  import { debug, describeError, isMissing } from './diagnostics.js';
14
15
  import { CliError } from './errors.js';
15
16
  import { exists } from './filesystem.js';
16
- import { OutputTail } from './npm-client.js';
17
+ import { describeFirstAdmin, firstAdminPhrase } from './first-admin.js';
17
18
  import { initFolder } from './project.js';
18
- import { saveAppliedRecipe } from './recipe-record.js';
19
+ import { csrfNotice, factOf, factToken, plannedText, SEAL_HEAD, SEAL_HTTP_MODULE, SESSION_OFFER, } from './recipe-facts.js';
20
+ import { ADMIN_LOGIN, ADMIN_PASSWORD_KEY, ADMIN_PATH, EXTENSION_FILE, LOGIN_NAME_SOURCES, loginFiles, MIGRATIONS_DIRECTORY, migrationPath, ROUTES_FILE, recipeAdminSection, recipeLoginNames, SEED_FILE, withLoginRoutes, } from './recipe-login.js';
21
+ import { checkMailCompose, mailSteps } from './recipe-mail.js';
22
+ import { APP_URL_PLACEHOLDER, FIRST_ADMIN_PLACEHOLDER, LOGIN_PAGES, NO_SCREENS_BUILT, saveAppliedRecipe, } from './recipe-record.js';
23
+ import { checkSite, sealedSiteApp, siteDefinition, siteSteps } from './recipe-site.js';
24
+ import { provider, shell, view } from './recipe-step.js';
19
25
  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.';
26
+ import { AI_REASON_MAX, aiReasonFits } from './view-choice.js';
27
+ // The notice sentences live with the facts they state (D-0137); the names stay importable here.
28
+ export { csrfNotice, SEAL_HEAD, SEAL_HTTP_MODULE, sealNotice, ssrSealNotice, } from './recipe-facts.js';
29
+ /**
30
+ * D-0098: a recipe that builds no browser screens (`screens: false`, the api recipe) answers the
31
+ * view with `none`. It is not a question (the recipe is the choice) and not a value the recipe
32
+ * fixes: who chose no screens is recorded like an answer, the user's unless the AI names `view`
33
+ * in `--ai-chose` (or the user left every choice to it), and verify's tellUser says it. Its app
34
+ * skeleton (the first app, which `add @dokebi/app` writes) has an EJS home page, but no screens
35
+ * for people. An app added later with `app add` inherits view none (no views, a JSON API) unless
36
+ * it says `--view ejs` or `--inertia` (M50).
37
+ */
38
+ export const SCREENS = 'view';
39
+ /** The view answer of a recipe without browser screens. */
40
+ export const NO_SCREENS = 'none';
41
+ /** The recipes that ask the view (blog, site): where a service people use in a browser goes. */
42
+ export function screenRecipes(catalog) {
43
+ return catalog.recipes
44
+ .filter((recipe) => recipe.questions.includes('view') && !recipe.fixed?.view)
45
+ .map((recipe) => recipe.name);
46
+ }
47
+ /** The words of a list: `blog or site`. */
48
+ function either(names) {
49
+ return names.length > 1
50
+ ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}`
51
+ : (names[0] ?? '');
52
+ }
53
+ /** What a recipe without screens tells the AI before and after planning (D-0098). */
54
+ export function noScreensNotice(recipe, browserRecipes) {
55
+ return `The ${recipe} recipe builds no browser screens: a JSON API whose clients are other programs (Bearer tokens, server to server). When people use the service in a browser (sign up, browse, press buttons, read lists), it is a browser app: use the ${either(browserRecipes)} recipe instead (they ask the view; default Inertia + Vue). No screens (API only) is a product choice: unless the user or the request said so, it is yours, allowed only when the user left it to you; then pass --ai-chose ${SCREENS} with --ai-reason ${SCREENS}=<why> and tell the user in the first lines of your final message.`;
56
+ }
57
+ /** The recorded notice of a recipe without screens: verify's tellUser tells the user (D-0098). */
58
+ export function noScreensRecord(recipe, browserRecipes) {
59
+ return `No browser screens (${recipe} recipe): ${NO_SCREENS_BUILT} Browser screens come from the ${either(browserRecipes)} recipe (default view Inertia + Vue).`;
60
+ }
22
61
  /** `dokebi recipe list`. */
23
62
  export async function listRecipes() {
24
63
  const catalog = await loadCatalog();
@@ -57,6 +96,19 @@ function applies(choice, answers) {
57
96
  }
58
97
  return true;
59
98
  }
99
+ /** An option's own `when` (admin login needs a database): unmet when an answer contradicts it. */
100
+ function unmet(option, answers, flagOf) {
101
+ for (const [id, rule] of Object.entries(option.when ?? {})) {
102
+ const value = answers[id];
103
+ if (value === undefined)
104
+ continue;
105
+ if (rule.startsWith('!') ? value === rule.slice(1) : value !== rule)
106
+ return rule.startsWith('!')
107
+ ? `Not available with ${flagOf(id)} ${value}`
108
+ : `Only available with ${flagOf(id)} ${rule}`;
109
+ }
110
+ return undefined;
111
+ }
60
112
  function variantOf(catalog, capability) {
61
113
  if (!capability)
62
114
  return undefined;
@@ -66,15 +118,6 @@ function variantOf(catalog, capability) {
66
118
  return variant;
67
119
  return undefined;
68
120
  }
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
121
  /** The devInfra docker note when `docker` is not on PATH (the default stays; the user decides). */
79
122
  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
123
  /** Question list with options adjusted to the answers given so far (docker per db variant). */
@@ -83,11 +126,16 @@ function questions(catalog, recipe, answers, dockerOnPath = () => commandOnPath(
83
126
  const db = catalog.choices
84
127
  .find((item) => item.id === 'db')
85
128
  ?.options.find((option) => option.value === values.db);
129
+ const flagOf = (id) => choiceOf(catalog, id).flag;
86
130
  return recipe.questions
87
131
  .filter((id) => !recipe.fixed?.[id])
88
132
  .map((id) => {
89
133
  const choice = choiceOf(catalog, id);
90
134
  const options = choice.options.map((option) => {
135
+ // An option another answer rules out (admin login with db none) is shown unavailable.
136
+ const why = option.available ? unmet(option, values, flagOf) : undefined;
137
+ if (why)
138
+ return { ...option, available: false, why };
91
139
  if (id !== 'devInfra' || option.value !== 'docker' || !db?.capability)
92
140
  return option;
93
141
  const docker = variantOf(catalog, db.capability)?.devInfra?.docker;
@@ -110,6 +158,7 @@ function questions(catalog, recipe, answers, dockerOnPath = () => commandOnPath(
110
158
  flag: choice.flag,
111
159
  question: choice.question,
112
160
  options: shown,
161
+ ...(choice.input ? { input: choice.input } : {}),
113
162
  default: choice.default,
114
163
  defaultReason: missingDocker && choice.default === 'docker'
115
164
  ? `${choice.defaultReason}. Note: ${DOCKER_NOT_FOUND}`
@@ -119,7 +168,104 @@ function questions(catalog, recipe, answers, dockerOnPath = () => commandOnPath(
119
168
  };
120
169
  });
121
170
  }
122
- /** Collects answers from flags, recipe-fixed values and (when asked) defaults; validates them. */
171
+ /** Where the answers came from: `--answers-from`, else `--defaults` alone; undefined: not said. */
172
+ function answerSource(request) {
173
+ return request.answersFrom ?? (request.defaults ? 'defaults' : undefined);
174
+ }
175
+ /**
176
+ * The given answers the agent chose (D-0095 `--ai-chose`) and its reasons (`--ai-reason`),
177
+ * checked against the recipe: an id it does not ask, one it fixes, one without its flag, and a
178
+ * reason for an id that is not an AI choice are refused before anything is planned.
179
+ */
180
+ function aiChoices(catalog, recipe, request) {
181
+ // D-0098: a recipe without screens records view = none, which --ai-chose can name (view, --view).
182
+ const screens = recipe.screens === false ? [SCREENS] : [];
183
+ const asked = [...recipe.questions.filter((id) => !recipe.fixed?.[id]), ...screens];
184
+ // A question id (devInfra) or its flag name (dev-infra, --dev-infra) names the same question.
185
+ // The recipe's fixed answers count too, so naming one is AI_CHOICE_FIXED, not unknown.
186
+ const ids = [...recipe.questions, ...Object.keys(recipe.fixed ?? {})];
187
+ const idOf = (name) => {
188
+ if (ids.includes(name) || screens.includes(name))
189
+ return name;
190
+ const flag = `--${name.replace(/^-+/, '')}`;
191
+ return [...ids, ...screens].find((id) => choiceOf(catalog, id).flag === flag);
192
+ };
193
+ const known = (name, flag) => {
194
+ const id = idOf(name);
195
+ if (!id)
196
+ throw new CliError('AI_CHOICE_UNKNOWN', `The ${recipe.name} recipe does not ask ${name}`, {
197
+ details: {
198
+ recipe: recipe.name,
199
+ [flag === '--ai-chose' ? 'aiChose' : 'aiReason']: name,
200
+ questions: asked,
201
+ fix: `name only question ids of this recipe in ${flag}: ${asked.join(', ')}`,
202
+ },
203
+ phase: 'arguments',
204
+ });
205
+ return id;
206
+ };
207
+ const chose = new Set();
208
+ for (const name of request.aiChose ?? []) {
209
+ const id = known(name, '--ai-chose');
210
+ const fixed = recipe.fixed?.[id];
211
+ if (fixed !== undefined)
212
+ throw new CliError('AI_CHOICE_FIXED', `The ${recipe.name} recipe fixes ${id} (${fixed}); it is not a choice the AI made`, {
213
+ details: {
214
+ recipe: recipe.name,
215
+ question: id,
216
+ value: fixed,
217
+ fix: `drop ${id} from --ai-chose`,
218
+ },
219
+ phase: 'arguments',
220
+ });
221
+ if (!screens.includes(id) && request.answers[id] === undefined) {
222
+ const flag = choiceOf(catalog, id).flag;
223
+ throw new CliError('AI_CHOICE_WITHOUT_ANSWER', `--ai-chose names ${id}, but ${flag} is not given`, {
224
+ details: {
225
+ recipe: recipe.name,
226
+ question: id,
227
+ fix: `pass ${flag} <your choice> with --ai-chose ${id}, or drop ${id} from --ai-chose and add --defaults to take the default (only when the user left the choice to you)`,
228
+ },
229
+ phase: 'arguments',
230
+ });
231
+ }
232
+ chose.add(id);
233
+ }
234
+ const reasons = new Map();
235
+ for (const [name, text] of Object.entries(request.aiReasons ?? {})) {
236
+ const id = known(name, '--ai-reason');
237
+ if (!chose.has(id))
238
+ throw new CliError('AI_REASON_WITHOUT_CHOICE', `--ai-reason ${id} is given, but ${id} is not in --ai-chose`, {
239
+ details: {
240
+ recipe: recipe.name,
241
+ question: id,
242
+ aiChose: [...chose],
243
+ fix: `add ${id} to --ai-chose if you chose it because the user left it to you; a reason for the user's own answer is not recorded`,
244
+ },
245
+ phase: 'arguments',
246
+ });
247
+ const reason = text.trim();
248
+ if (!aiReasonFits(reason))
249
+ throw new CliError('INVALID_AI_REASON', `--ai-reason ${id} must be one line of 1 to ${AI_REASON_MAX} characters`, {
250
+ details: {
251
+ recipe: recipe.name,
252
+ question: id,
253
+ length: reason.length,
254
+ fix: `give one short line the user reads: why you chose ${request.answers[id] ?? 'none'} for ${id}`,
255
+ },
256
+ phase: 'arguments',
257
+ });
258
+ reasons.set(id, reason);
259
+ }
260
+ if (chose.size)
261
+ debug('recipe: answers the AI chose', { ids: [...chose], reasons: reasons.size });
262
+ return { chose, reasons };
263
+ }
264
+ /**
265
+ * Collects answers from flags, recipe-fixed values and (when asked) defaults; validates them.
266
+ * Each given answer is the user's, or the agent's when it is in `aiChose` or when the user left
267
+ * every choice to the agent (`--defaults` alone, D-0095).
268
+ */
123
269
  function answer(catalog, recipe, request) {
124
270
  const answers = {};
125
271
  for (const id of Object.keys(request.answers))
@@ -131,6 +277,14 @@ function answer(catalog, recipe, request) {
131
277
  },
132
278
  phase: 'arguments',
133
279
  });
280
+ const { chose, reasons } = aiChoices(catalog, recipe, request);
281
+ const delegatedAll = answerSource(request) === 'defaults';
282
+ const fromFlag = (id, value) => {
283
+ if (!chose.has(id) && !delegatedAll)
284
+ return { value, answeredBy: 'user' };
285
+ const reason = reasons.get(id);
286
+ return { value, answeredBy: 'ai', ...(reason ? { aiReason: reason } : {}) };
287
+ };
134
288
  for (const [id, value] of Object.entries(recipe.fixed ?? {}))
135
289
  answers[id] = { value, answeredBy: 'recipe' };
136
290
  for (const id of recipe.questions) {
@@ -140,6 +294,14 @@ function answer(catalog, recipe, request) {
140
294
  const given = request.answers[id];
141
295
  if (given === undefined)
142
296
  continue;
297
+ if (choice.input === 'text') {
298
+ if (!given.trim() || given.length > 2048)
299
+ throw new CliError('INVALID_ANSWER', `${choice.flag} requires at most 2048 characters`, {
300
+ phase: 'arguments',
301
+ });
302
+ answers[id] = fromFlag(id, given);
303
+ continue;
304
+ }
143
305
  const option = choice.options.find((item) => item.value === given);
144
306
  if (!option)
145
307
  throw new CliError('INVALID_ANSWER', `${choice.flag} ${given} is not an option`, {
@@ -151,7 +313,7 @@ function answer(catalog, recipe, request) {
151
313
  details: { question: id, option: given, why: option.why ?? null },
152
314
  phase: 'arguments',
153
315
  });
154
- answers[id] = { value: given, answeredBy: 'flag' };
316
+ answers[id] = fromFlag(id, given);
155
317
  }
156
318
  const missing = [];
157
319
  const values = () => Object.fromEntries(Object.entries(answers).map(([id, item]) => [id, item.value]));
@@ -161,7 +323,7 @@ function answer(catalog, recipe, request) {
161
323
  const choice = choiceOf(catalog, id);
162
324
  if (!applies(choice, values()))
163
325
  continue;
164
- if (request.defaults || request.answersFrom === 'defaults')
326
+ if (request.defaults || answerSource(request) === 'defaults')
165
327
  answers[id] = {
166
328
  value: choice.default,
167
329
  answeredBy: 'default',
@@ -174,27 +336,26 @@ function answer(catalog, recipe, request) {
174
336
  for (const id of Object.keys(answers))
175
337
  if (!applies(choiceOf(catalog, id), values()))
176
338
  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;
339
+ // An answer another answer rules out (--admin login with --db none, D-0076 §6).
340
+ for (const [id, item] of Object.entries(answers)) {
341
+ const choice = choiceOf(catalog, id);
342
+ const option = choice.options.find((entry) => entry.value === item.value);
343
+ const why = option && unmet(option, values(), (other) => choiceOf(catalog, other).flag);
344
+ if (why)
345
+ throw new CliError('OPTION_NOT_APPLICABLE', `${choice.flag} ${item.value}: ${why}`, {
346
+ details: {
347
+ question: id,
348
+ option: item.value,
349
+ when: option?.when ?? null,
350
+ fix: `ask the user to choose another ${choice.flag} or change the answer it depends on`,
351
+ },
352
+ phase: 'arguments',
353
+ });
188
354
  }
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
- };
355
+ // D-0098: the recipe itself is the choice of no browser screens; who made it is recorded.
356
+ if (recipe.screens === false)
357
+ answers[SCREENS] = fromFlag(SCREENS, NO_SCREENS);
358
+ return { answers, missing };
198
359
  }
199
360
  function option(options, path) {
200
361
  let current = options;
@@ -205,42 +366,38 @@ function option(options, path) {
205
366
  }
206
367
  return current;
207
368
  }
369
+ /** The CSRF next step of both views ends with this rule (one text, D-0110 §5). */
370
+ const CSRF_NEVER_OFF = "Never turn the check off to make a test pass; a webhook route that verifies its own signature, an @dokebi/auth Bearer-only route (auth: { via: 'token' }) or the token refresh route (it calls tokens.refresh) takes config: { csrf: false }";
208
371
  /**
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).
372
+ * The tellUser line of `--admin login`: where to sign in and where the first password is.
373
+ * `appUrl` is the app's `<scheme>://<domain>:<port>`, or APP_URL_PLACEHOLDER in the recipe record
374
+ * (verify fills in the address of the moment, AI-M05). `firstAdmin` is firstAdminPhrase, or
375
+ * FIRST_ADMIN_PLACEHOLDER in the record: verify reads the login and the section from the project
376
+ * as it is then (D-0115: the builder may rename the section or sign the admin in by email).
211
377
  */
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
- });
378
+ export function loginNotice(appUrl, devInfra, firstAdmin = FIRST_ADMIN_PLACEHOLDER) {
379
+ const ready = devInfra === 'docker'
380
+ ? ''
381
+ : ' The account exists after npx dokebi-migrate up and npx dokebi-migrate seed (run them once the database URL is in dokebi.yaml).';
382
+ return `Admin login is on (@dokebi/auth): sign in at ${appUrl}/login ${firstAdmin}; ${LOGIN_PAGES}${ready}`;
239
383
  }
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}` : ''}.`;
384
+ /**
385
+ * The hosts of a blog/api plan (D-0102): the recipe's `web.domain` is `auto`, the project's own
386
+ * development hosts `main.<label>.localhost` and `admin.<label>.localhost`, so two projects on one
387
+ * machine never share cookies. A recipe naming a domain keeps it (admin then on `admin.<domain>`).
388
+ * An app that already exists keeps the first domain dokebi.yaml maps to it (`options`), so a plan
389
+ * applied again to an older project (main on `localhost`) seals and links the host it really has.
390
+ */
391
+ export function recipeHosts(recipe, label, options = {}) {
392
+ const domains = option(options, ['dokebi-app', 'domains']);
393
+ const mapped = (app) => domains && typeof domains === 'object'
394
+ ? Object.entries(domains).find(([, target]) => target?.app === app)?.[0]
395
+ : undefined;
396
+ const domain = recipe.web.domain;
397
+ const defaults = domain === AUTO_DOMAIN
398
+ ? { main: devHost('main', label), admin: devHost('admin', label) }
399
+ : { main: domain, admin: `admin.${domain}` };
400
+ return { main: mapped('main') ?? defaults.main, admin: mapped('admin') ?? defaults.admin };
244
401
  }
245
402
  /** Builds the plan steps for the answers (capability → package through the catalog). */
246
403
  function steps(catalog, recipe, answers, context) {
@@ -252,13 +409,13 @@ function steps(catalog, recipe, answers, context) {
252
409
  /** Installed by package name: the app and module hosts come from the catalog (D-0070). */
253
410
  const hasPackage = async (name) => (await now())?.packages.has(name) ?? false;
254
411
  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'));
412
+ // Without an answer (a recipe that neither asks nor fixes the view) the catalog default holds.
413
+ const viewQuestion = catalog.choices.find((item) => item.id === 'view');
414
+ const viewChoice = viewQuestion?.options.find((item) => item.value === (value('view') ?? viewQuestion.default));
258
415
  const inertia = viewChoice?.capability?.startsWith('view.inertia') ?? false;
259
416
  const ssr = viewChoice?.capability === 'view.inertia-vue-ssr';
260
417
  const appFlags = inertia ? ` --inertia${ssr ? ' --ssr' : ''}` : '';
261
- const domain = recipe.web.domain;
418
+ const { main: domain, admin: adminDomain } = recipeHosts(recipe, context.hostLabel);
262
419
  if (context.init)
263
420
  result.push({
264
421
  id: 'init',
@@ -269,6 +426,16 @@ function steps(catalog, recipe, answers, context) {
269
426
  await project.init(root);
270
427
  },
271
428
  });
429
+ const logger = plugin('logging.targets');
430
+ result.push({
431
+ id: 'logger-plugin',
432
+ command: `npx dokebi add ${logger}`,
433
+ why: 'the app logger (D-0151): JSON lines on stdout, readable lines in dokebi dev, redaction, and a rotating file target to turn on in production; init adds it, this step adds it to an older project',
434
+ satisfied: () => hasPackage(logger),
435
+ run: async () => {
436
+ await project.add(root, logger);
437
+ },
438
+ });
272
439
  const apps = plugin('web.apps');
273
440
  result.push({
274
441
  id: 'app-plugin',
@@ -292,13 +459,48 @@ function steps(catalog, recipe, answers, context) {
292
459
  ]);
293
460
  },
294
461
  });
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.
462
+ // The consumer convention seals every browser app (D-0092 §1, D-0062): a recipe with write
463
+ // forms is a browser app, so @dokebi/seal is installed and its hosts get seal: true. EJS: the
464
+ // shared layout (template-ejs) writes the seal head and @dokebi/seal/forms seals its forms.
465
+ // Inertia: the generated views/app.js awaits sealInertia before createInertiaApp, which opens
466
+ // the sealed first page and seals every visit. Server rendering cannot be sealed (SSR writes
467
+ // the props into plain HTML: SEAL_SSR_CONFLICT), so an SSR plan stays plain and its notice
468
+ // tells the user. The api recipe answers API clients (Bearer, server to server) and stays plain.
469
+ const sealed = recipe.web.forms === true && !ssr;
470
+ const sealEjs = sealed && !inertia;
471
+ if (sealed) {
472
+ const seal = plugin('security.seal');
473
+ result.push({
474
+ id: 'seal-plugin',
475
+ command: `npx dokebi add ${seal}`,
476
+ why: inertia
477
+ ? `browser apps are sealed (the consumer convention): ${seal} seals the first page, the Inertia visits and the JSON bodies of the host and serves ${SEAL_HTTP_MODULE}, which views/app.js imports (as @dokebi/seal/http, through dokebiInertia() of vite.config.ts) for sealInertia before createInertiaApp`
478
+ : `browser apps are sealed (the consumer convention): ${seal} seals the JSON bodies of the host and serves /dokebi/seal/forms.mjs, which the shared layout loads with ${SEAL_HEAD}`,
479
+ satisfied: () => has('dokebi-seal'),
480
+ run: async () => {
481
+ await project.add(root, seal);
482
+ },
483
+ });
484
+ }
485
+ if (sealEjs)
486
+ result.push({
487
+ id: 'seal-domain',
488
+ command: `npx dokebi config set dokebi-app.domains.${domain}.seal=true`,
489
+ why: `seal the browser host ${domain}: page navigations stay plain HTML, every <form method="post"> goes sealed through @dokebi/seal/forms and a plain form post is refused (415 SEAL_REQUIRED); never turn this off to make a form work`,
490
+ satisfied: async () => option((await now())?.options ?? {}, ['dokebi-app', 'domains', domain, 'seal']) === true,
491
+ run: async () => {
492
+ await project.configSet(root, [
493
+ { plugin: 'dokebi-app', set: { path: ['domains', domain, 'seal'], value: true } },
494
+ ]);
495
+ },
496
+ });
497
+ // HTML forms (the blog's write form) are form bodies, which dokebi-web refuses (415
498
+ // FORM_BODY_DISABLED) until formBody is on (AI-M02), sealed or not. Inertia posts JSON.
297
499
  if (recipe.web.forms && !inertia)
298
500
  result.push({
299
501
  id: 'form-body',
300
502
  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)}`,
503
+ why: `HTML forms are form bodies, sealed by @dokebi/seal/forms on a sealed host or not; dokebi-web refuses them (415 FORM_BODY_DISABLED) until formBody is on. ${csrfNotice(false)} The layout writes ${SEAL_HEAD} in its <head>: @dokebi/seal/forms then seals every post form (no per-form code), and the handler gets the same body.`,
302
504
  satisfied: async () => option((await now())?.options ?? {}, ['dokebi-web', 'formBody']) === true,
303
505
  run: async () => {
304
506
  await project.configSet(root, [
@@ -319,11 +521,11 @@ function steps(catalog, recipe, answers, context) {
319
521
  });
320
522
  result.push({
321
523
  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)`,
524
+ command: `npx dokebi app add main${appFlags} --domain ${domain}${sealed ? ' --seal' : ''}`,
525
+ why: `the app main on ${domain} with Vue pages (adds vite.config.ts, the build script, Vite and Vue)${sealed ? '; --seal seals its host: the first page and every visit go sealed and a plain JSON body is refused (415 SEAL_REQUIRED); never turn this off to make a request work' : ''}`,
324
526
  satisfied: async () => (await now())?.apps.includes('main') ?? false,
325
527
  run: async () => {
326
- await project.appAdd(root, 'main', { domains: [domain], inertia: true, ssr });
528
+ await project.appAdd(root, 'main', { domains: [domain], inertia: true, ssr, seal: sealed });
327
529
  },
328
530
  });
329
531
  }
@@ -407,7 +609,7 @@ function steps(catalog, recipe, answers, context) {
407
609
  result.push({
408
610
  id: 'dev-infra',
409
611
  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',
612
+ why: 'compose.yaml and a generated database URL/password only in dokebi.yaml',
411
613
  satisfied: async () => {
412
614
  const url = option((await now())?.options ?? {}, [
413
615
  'dokebi-db',
@@ -426,34 +628,143 @@ function steps(catalog, recipe, answers, context) {
426
628
  });
427
629
  result.push({
428
630
  id: 'dev-db-up',
429
- command: 'docker compose up -d --wait',
631
+ command: 'npx dokebi dev-infra up --json',
430
632
  why: `start the ${variant.name} container and wait until it is healthy`,
431
633
  satisfied: () => composeServiceRunning(root),
432
- run: () => shell(root, 'docker', ['compose', 'up', '-d', '--wait'], 300_000),
634
+ run: async () => {
635
+ await runDevCompose(root, 'up');
636
+ },
433
637
  });
434
638
  }
435
639
  }
436
- if (value('admin') === 'app-code') {
640
+ result.push(...mailSteps({ root, project, choice: value('mail'), now }));
641
+ const admin = value('admin');
642
+ if (admin === 'app-code' || admin === 'login') {
437
643
  const session = plugin('web.session');
438
644
  result.push({
439
645
  id: 'session',
440
646
  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)',
647
+ why: `server-side sessions for the admin login${admin === 'login' ? ' (@dokebi/auth keeps it there)' : ' the app writes'}; add also writes the generated cookie-signing secret into dokebi.yaml (dokebi-session.secrets; 0600, kept out of Git; the value is never printed)`,
442
648
  satisfied: () => has('dokebi-session'),
443
649
  run: async () => {
444
650
  await project.add(root, session);
445
651
  },
446
652
  });
653
+ }
654
+ // D-0076 §6: every write form gets CSRF protection, without a question (a security default).
655
+ if (recipe.web.forms) {
656
+ const csrf = plugin('csrf');
657
+ result.push({
658
+ id: 'csrf',
659
+ command: `npx dokebi add ${csrf}`,
660
+ why: `CSRF protection for the write forms (an origin check and a signed token on every POST, PUT, PATCH and DELETE): ${inertia ? 'Inertia sends the token by itself' : 'every EJS form writes <%- data.csrf.field %>'}; add writes its generated secret into dokebi.yaml (dokebi-csrf.secrets, never printed)`,
661
+ satisfied: () => has('dokebi-csrf'),
662
+ run: async () => {
663
+ await project.add(root, csrf);
664
+ },
665
+ });
666
+ }
667
+ const dialect = variant?.name;
668
+ if (admin === 'login' && dialect) {
669
+ const auth = plugin('auth');
670
+ result.push({
671
+ id: 'auth',
672
+ command: `npx dokebi add ${auth} --set homePath=${ADMIN_PATH}`,
673
+ why: `session login on the app's own user table (@dokebi/auth): it hashes and checks passwords, throttles logins and protects routes with config.auth; a signed-in user on the login page goes to ${ADMIN_PATH}; add writes its generated secret into dokebi.yaml (dokebi-auth.secrets, never printed)`,
674
+ satisfied: () => has('dokebi-auth'),
675
+ run: async () => {
676
+ await project.add(root, auth, false, [{ path: ['homePath'], value: ADMIN_PATH }]);
677
+ },
678
+ });
679
+ const files = async () => {
680
+ let existing = [];
681
+ try {
682
+ existing = await readdir(join(root, MIGRATIONS_DIRECTORY));
683
+ }
684
+ catch (error) {
685
+ if (!isMissing(error))
686
+ throw error;
687
+ }
688
+ return loginFiles(dialect, inertia ? 'inertia' : 'ejs', migrationPath(existing, new Date(), context.loginNames), await has('dokebi-mail'), context.adminSection, context.loginNames);
689
+ };
690
+ result.push({
691
+ id: 'login-code',
692
+ command: `write ${EXTENSION_FILE}, src/shared/models/members.ts, src/shared/services/members.ts, ${MIGRATIONS_DIRECTORY}/<time>_${context.loginNames.migration}.ts, ${SEED_FILE}, src/apps/main/routes/auth.ts, src/apps/main/controllers/auth.ts and its two pages, loginRoutes(route) in ${ROUTES_FILE}, dokebi.yaml options.${context.adminSection} (login ${context.adminLogin}, a generated ${ADMIN_PASSWORD_KEY} unless one is there)`,
693
+ why: `the admin login as user code (no command writes it): the extension class maps the app's own table ${context.loginNames.table} (${dialect}), the migration creates it, the seed adds the first admin (${context.adminLogin}) with passwords.hash of the generated initial password (never printed), and the main app gets GET/POST /login, POST /logout and ${ADMIN_PATH} (config.auth)`,
694
+ satisfied: async () => {
695
+ // Not the password key: it may be removed once the admin exists (it is never regenerated).
696
+ try {
697
+ for (const file of await files())
698
+ if ((await readFile(join(root, file.path), 'utf8')) !== file.content)
699
+ return false;
700
+ return /\bloginRoutes\(/.test(await readFile(join(root, ROUTES_FILE), 'utf8'));
701
+ }
702
+ catch (error) {
703
+ if (isMissing(error))
704
+ return false;
705
+ throw error;
706
+ }
707
+ },
708
+ run: async () => {
709
+ const recovery = await has('dokebi-mail');
710
+ await project.writeRecipeFiles(root, {
711
+ files: await files(),
712
+ edits: [{ path: ROUTES_FILE, edit: withLoginRoutes }],
713
+ config: {
714
+ section: context.adminSection,
715
+ values: [{ key: 'login', value: ADMIN_LOGIN }, { key: ADMIN_PASSWORD_KEY }],
716
+ },
717
+ });
718
+ if (recovery) {
719
+ const current = (await now())?.options ?? {};
720
+ const sets = [
721
+ { plugin: 'dokebi-web', set: { path: ['formBody'], value: true } },
722
+ ];
723
+ if (option(current, ['dokebi-auth', 'passwordReset']) === undefined)
724
+ sets.push({
725
+ plugin: 'dokebi-auth',
726
+ set: {
727
+ path: ['passwordReset', 'url'],
728
+ value: `http://${domain}:${context.port}/password/reset`,
729
+ },
730
+ });
731
+ await project.configSet(root, sets);
732
+ }
733
+ },
734
+ });
735
+ if (value('devInfra') === 'docker') {
736
+ const migrateBin = join(root, 'node_modules/.bin/dokebi-migrate');
737
+ result.push({
738
+ id: 'migrate-up',
739
+ command: 'npx dokebi-migrate up',
740
+ why: `create ${context.loginNames.table} in the development database (nothing to do when it is there)`,
741
+ satisfied: async () => false,
742
+ run: () => shell(root, migrateBin, ['up'], 300_000),
743
+ });
744
+ result.push({
745
+ id: 'seed',
746
+ command: 'npx dokebi-migrate seed',
747
+ why: `add the first admin (${context.adminLogin}) unless it exists; the initial password is hashed with passwords.hash`,
748
+ satisfied: async () => false,
749
+ run: () => shell(root, migrateBin, ['seed'], 300_000),
750
+ });
751
+ }
752
+ }
753
+ if (admin === 'app-code')
447
754
  result.push({
448
755
  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`,
756
+ command: `npx dokebi app add admin${appFlags} --domain ${adminDomain}${sealed ? ' --seal' : ''}`,
757
+ why: `a separate admin app on ${adminDomain}${sealed ? ' (sealed like the main host)' : ''}; its login (with @dokebi/auth) and permission checks are app code`,
451
758
  satisfied: async () => (await now())?.apps.includes('admin') ?? false,
452
759
  run: async () => {
453
- await project.appAdd(root, 'admin', { domains: [`admin.${domain}`], inertia, ssr });
760
+ await project.appAdd(root, 'admin', {
761
+ domains: [adminDomain],
762
+ inertia,
763
+ ssr,
764
+ seal: sealed,
765
+ });
454
766
  },
455
767
  });
456
- }
457
768
  if (inertia)
458
769
  result.push({
459
770
  id: 'build',
@@ -468,6 +779,62 @@ async function preflight(root, answers, catalog, plan, port, dbPort,
468
779
  /** The user left the choices to the agent (--defaults / --answers-from defaults). */
469
780
  delegated) {
470
781
  const result = [];
782
+ const current = await view(root);
783
+ const mail = answers.mail?.value;
784
+ if (mail === 'mailpit') {
785
+ await checkMailCompose(root);
786
+ const docker = await dockerAvailable();
787
+ if (!docker.ok)
788
+ result.push({
789
+ code: 'DOCKER_MISSING',
790
+ severity: 'error',
791
+ message: 'Mailpit requires running Docker with compose',
792
+ fix: 'Start Docker or ask the user to choose another --mail option.',
793
+ });
794
+ if (!(await exists(join(root, 'compose.mail.yaml'))) &&
795
+ (option(current?.options ?? {}, ['dokebi-nats', 'connections', 'recipeMail']) !== undefined ||
796
+ option(current?.options ?? {}, ['dokebi-job', 'queues', 'recipeMail']) !== undefined))
797
+ result.push({
798
+ code: 'MAIL_CONFIGURATION_REQUIRED',
799
+ severity: 'error',
800
+ message: 'The recipeMail connection or queue name is already in use',
801
+ fix: 'Keep the existing infrastructure and configure mail manually.',
802
+ });
803
+ // An existing mail setup belongs to the developer, unless this recipe created it.
804
+ if (current?.installed.has('dokebi-mail') && !(await exists(join(root, 'compose.mail.yaml'))))
805
+ result.push({
806
+ code: 'MAIL_CONFIGURATION_REQUIRED',
807
+ severity: 'error',
808
+ message: 'Mail is already configured outside this recipe',
809
+ fix: 'Keep it with --mail smtp or --mail ses; do not replace an existing transport implicitly.',
810
+ });
811
+ }
812
+ if (mail === 'smtp' || mail === 'ses') {
813
+ if (!current?.installed.has('dokebi-mail') ||
814
+ option(current.options, ['dokebi-mail', 'transport', 'driver']) !== mail)
815
+ result.push({
816
+ code: 'MAIL_CONFIGURATION_REQUIRED',
817
+ severity: 'error',
818
+ message: `Configure the selected ${mail} mail service before applying`,
819
+ fix: 'Run npx dokebi add @dokebi/mail, read its AI.md, and edit transport and from in dokebi.yaml. Never put credentials on a command line.',
820
+ });
821
+ }
822
+ if (mail !== 'mailpit' &&
823
+ plan.some((step) => step.id === 'login-code') &&
824
+ current?.installed.has('dokebi-mail')) {
825
+ const queues = option(current.options, ['dokebi-job', 'queues']);
826
+ const selected = option(current.options, ['dokebi-mail', 'queue']);
827
+ const names = queues && typeof queues === 'object' ? Object.keys(queues) : [];
828
+ if (!current.installed.has('dokebi-job') ||
829
+ !names.length ||
830
+ (typeof selected === 'string' && !names.includes(selected)))
831
+ result.push({
832
+ code: 'MAIL_QUEUE_REQUIRED',
833
+ severity: 'error',
834
+ message: 'Login recovery with @dokebi/mail requires a configured @dokebi/job queue',
835
+ fix: 'Install and configure @dokebi/job with a NATS connection, provision its queue, and set dokebi-mail.queue to that queue before applying this recipe. See @dokebi/job AI.md.',
836
+ });
837
+ }
471
838
  const devInfra = plan.find((step) => step.id === 'dev-infra');
472
839
  if (devInfra) {
473
840
  const docker = await dockerAvailable();
@@ -506,7 +873,7 @@ delegated) {
506
873
  }
507
874
  }
508
875
  const running = (await view(root)) ? (await server.status(root)).running : false;
509
- if (!running && (await portInUse(port))) {
876
+ if (!(answers.apps?.value === 'none') && !running && (await portInUse(port))) {
510
877
  const alternative = await freePortAfter(port);
511
878
  debug('recipe: web port in use', { port, alternative, delegated });
512
879
  result.push({
@@ -522,7 +889,7 @@ delegated) {
522
889
  result.push({
523
890
  code: 'VITE_BUILD',
524
891
  severity: 'info',
525
- message: 'vite, vue, @inertiajs/vue3 and @vitejs/plugin-vue are installed and npm run build runs',
892
+ message: 'vite, vue, @inertiajs/vue3, @vitejs/plugin-vue, vue-tsc and @typescript/typescript6 are installed, npm run build runs and npm run typecheck checks the .vue files',
526
893
  });
527
894
  if (plan.some((step) => step.id === 'db'))
528
895
  result.push({
@@ -546,6 +913,40 @@ function portAsks(checks) {
546
913
  next: [...new Set(ports.map((item) => item.next))].join(' '),
547
914
  };
548
915
  }
916
+ /** The first-admin seed an earlier recipe wrote, if any (D-0115: it may read the older section). */
917
+ async function readSeed(root) {
918
+ try {
919
+ return { file: SEED_FILE, text: await readFile(join(root, SEED_FILE), 'utf8') };
920
+ }
921
+ catch (error) {
922
+ if (isMissing(error))
923
+ return undefined;
924
+ throw error;
925
+ }
926
+ }
927
+ /** The login names of the project: its migrations and login files (recipeLoginNames). */
928
+ async function readLoginNames(root) {
929
+ let migrations = [];
930
+ try {
931
+ migrations = await readdir(join(root, MIGRATIONS_DIRECTORY));
932
+ }
933
+ catch (error) {
934
+ if (!isMissing(error))
935
+ throw error;
936
+ }
937
+ const sources = [];
938
+ for (const file of LOGIN_NAME_SOURCES)
939
+ try {
940
+ sources.push(await readFile(join(root, file), 'utf8'));
941
+ }
942
+ catch (error) {
943
+ if (!isMissing(error))
944
+ throw error;
945
+ }
946
+ const names = recipeLoginNames(migrations, sources);
947
+ debug('recipe: login names', { table: names.table, migration: names.migration });
948
+ return names;
949
+ }
549
950
  async function composePort(root) {
550
951
  try {
551
952
  const document = parse(await readFile(join(root, COMPOSE_FILE), 'utf8'));
@@ -558,7 +959,18 @@ async function composePort(root) {
558
959
  throw new CliError('COMPOSE_INVALID', `${COMPOSE_FILE} cannot be read`, { cause: error });
559
960
  }
560
961
  }
561
- function nextSteps(catalog, recipe, answers, plan, port) {
962
+ function nextSteps(catalog, recipe, answers, plan, port, answersFrom, adminSection, loginNames, adminLogin) {
963
+ if (recipe.name === 'site')
964
+ return [
965
+ {
966
+ command: 'read SITE.md',
967
+ why: 'app domains, realm extension hooks and worker configuration before serving traffic',
968
+ },
969
+ {
970
+ command: 'npx dokebi verify --json',
971
+ why: 'verify the configured site and report its remaining app-code work',
972
+ },
973
+ ];
562
974
  const result = [];
563
975
  // The app and module hosts by capability (D-0070), the rest by the step that installed them.
564
976
  const ids = new Set([HOST_CAPABILITY.apps, HOST_CAPABILITY.modules].flatMap((capability) => providersOf(catalog, capability).find((item) => item.kind === 'plugin')?.id ?? []));
@@ -572,6 +984,12 @@ function nextSteps(catalog, recipe, answers, plan, port) {
572
984
  ids.add('dokebi-migrate');
573
985
  if (plan.some((step) => step.id === 'session'))
574
986
  ids.add('dokebi-session');
987
+ if (plan.some((step) => step.id === 'csrf'))
988
+ ids.add('dokebi-csrf');
989
+ if (plan.some((step) => step.id === 'auth'))
990
+ ids.add('dokebi-auth');
991
+ if (plan.some((step) => step.id === 'seal-plugin'))
992
+ ids.add('dokebi-seal');
575
993
  for (const id of ids)
576
994
  result.push({
577
995
  command: `npx dokebi plugins doc ${id} --json`,
@@ -582,18 +1000,39 @@ function nextSteps(catalog, recipe, answers, plan, port) {
582
1000
  if (answers.devInfra?.value === 'url')
583
1001
  result.push({
584
1002
  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',
1003
+ why: 'url mode: write the existing database URL (with its password) here with your file editor, npx dokebi config set or npx dokebi config secret <path> --stdin (or ask the user to)',
586
1004
  });
587
- if (answers.admin?.value === 'app-code')
1005
+ const login = plan.some((step) => step.id === 'login-code');
1006
+ if (login && !plan.some((step) => step.id === 'seed'))
1007
+ result.push({
1008
+ command: 'npx dokebi-migrate up && npx dokebi-migrate seed',
1009
+ why: `after the database URL is in dokebi.yaml: creates ${loginNames.table} and the first admin (${adminLogin}); until then nobody can sign in`,
1010
+ });
1011
+ if (login) {
588
1012
  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',
1013
+ command: `edit: the write routes (GET /posts/new, POST /posts) — { config: { auth: 'required' } } (or { can: '${loginNames.ability}' }); a link to /login and a sign-out form where data.auth.user (Inertia: auth.user) is set`,
1014
+ why: `only signed-in users reach the write routes: a visitor without a login gets 303 to /login (JSON clients 401). Login, sign-out and ${ADMIN_PATH} are in src/apps/main/routes/auth.ts; the role rule is authorize in ${EXTENSION_FILE}`,
591
1015
  });
592
- if (plan.some((step) => step.id === 'form-body'))
593
1016
  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.`,
1017
+ command: `edit: a password change page for signed-in users (${ADMIN_PATH}/password) — check the current password with passwords.verify, store passwords.hash(new) and a new logout_key, then await request.auth.logoutOtherDevices()`,
1018
+ why: `the first admin's password is the generated options.${adminSection}.${ADMIN_PASSWORD_KEY} in dokebi.yaml: tell the user to change it before going live and then remove the key. Never compare or hash passwords yourself (use the plugin's passwords helpers; npx dokebi check: plain-password-compare)`,
596
1019
  });
1020
+ }
1021
+ if (answers.admin?.value === 'app-code')
1022
+ result.push({
1023
+ command: "edit src/apps/admin: the login with @dokebi/auth (npx dokebi add @dokebi/auth, then the three hooks of src/infra/auth/extension.ts on your user table), the login page and routes with config: { auth: 'required' }",
1024
+ why: '@dokebi/auth checks passwords, throttles logins and keeps the session: never compare or hash passwords yourself (passwords.hash for new ones) and show one sentence for every failed login. CSRF protection is already on (@dokebi/csrf)',
1025
+ });
1026
+ if (plan.some((step) => step.id === 'csrf'))
1027
+ result.push(plan.some((step) => step.id === 'view-plugin')
1028
+ ? {
1029
+ command: 'nothing to write for CSRF in Inertia forms: the XSRF-TOKEN cookie becomes the X-XSRF-TOKEN header by itself; a fetch call sends that header',
1030
+ why: `@dokebi/csrf refuses a POST, PUT, PATCH or DELETE without its token (403 CSRF_FAILED). ${CSRF_NEVER_OFF}`,
1031
+ }
1032
+ : {
1033
+ command: 'edit: every <form method="post"> (write form, sign-out) — <%- data.csrf.field %> inside the form; a fetch call sends the XSRF-TOKEN cookie as the X-XSRF-TOKEN header',
1034
+ why: `@dokebi/csrf refuses a POST without its token (403 CSRF_FAILED; an expired form comes back with data.errors._form). npx dokebi check warns: form-without-csrf. ${CSRF_NEVER_OFF}`,
1035
+ });
597
1036
  // D-0074: how a write form refuses input and shows it, the same for the schema and the app.
598
1037
  if (recipe.web.forms)
599
1038
  result.push(plan.some((step) => step.id === 'view-plugin')
@@ -602,17 +1041,25 @@ function nextSteps(catalog, recipe, answers, plan, port) {
602
1041
  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
1042
  }
604
1043
  : {
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',
1044
+ command: 'edit: every write form — <form method="post"> with <%- data.csrf.field %> first, each field value="<%= data.old.<field> ?? \'\' %>" and its message from data.errors.<field>; in the handler throw validationError({ <field>: \'<message>\' }) from @dokebi/web',
606
1045
  why: plan.some((step) => step.id === 'session')
607
1046
  ? '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
1047
  : '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
1048
  });
610
1049
  // D-0074 addendum 1: an EJS write form comes back to its page only with a session.
611
1050
  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
- });
1051
+ result.push(
1052
+ // AI-M05 F3: the defaults are the plan. A plugin outside it is not the agent's choice, with
1053
+ // --defaults or without: the user hears about it (a tellUser notice) and decides.
1054
+ answersFrom === 'defaults'
1055
+ ? {
1056
+ command: 'nothing to add: @dokebi/session is not part of the defaults',
1057
+ why: `do not add it now (the user left the recipe's choices to you, not extra plugins); verify's tellUser tells the user that a refused form shows the 422 page and that ${SESSION_OFFER}`,
1058
+ }
1059
+ : {
1060
+ command: 'npx dokebi add @dokebi/session',
1061
+ 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; only if the user wants it: ask first (it writes a generated signing secret into dokebi.yaml and keeps sessions in memory until another store is configured). verify's tellUser offers it to the user too`,
1062
+ });
616
1063
  const absent = catalog.absent.map((entry) => entry.capability).join(', ');
617
1064
  result.push({
618
1065
  command: 'npx dokebi plugins search <feature> --json',
@@ -620,41 +1067,92 @@ function nextSteps(catalog, recipe, answers, plan, port) {
620
1067
  });
621
1068
  result.push({
622
1069
  command: 'npx dokebi verify --json',
623
- why: `doctor, typecheck, build, start on port ${port}, HTTP checks, stop — say done only when ok is true`,
1070
+ why: `doctor, check, typecheck, build, the project's tests, start on port ${port}, HTTP checks, stop — say done only when ok is true`,
624
1071
  });
625
1072
  if (answers.devInfra?.value === 'docker')
626
1073
  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)',
1074
+ command: 'npx dokebi dev-infra down --json',
1075
+ why: 'stop the development database when you are done (npx dokebi dev-infra up starts it again)',
629
1076
  });
630
1077
  return result;
631
1078
  }
632
1079
  /** The lines to tell the user (RecipeOutcome.report). */
633
- function report(catalog, answers, answersFrom, plan) {
1080
+ function report(answers, answersFrom, plan, context) {
634
1081
  const lines = [];
635
- const list = (by) => Object.entries(answers).filter(([, item]) => item.answeredBy === by);
1082
+ // D-0098: view = none of a recipe without screens has its own line (below).
1083
+ const list = (by) => Object.entries(answers).filter(([id, item]) => !(context.noScreens && id === SCREENS) && item.answeredBy === by);
636
1084
  const defaults = list('default');
637
1085
  if (defaults.length)
638
1086
  lines.push(`Chosen by default (tell the user): ${defaults
639
1087
  .map(([id, item]) => `${id}=${item.value} (${item.defaultReason})`)
640
1088
  .join('; ')}`);
641
- const given = list('flag')
1089
+ const given = list('user')
642
1090
  .map(([id, item]) => `${id}=${item.value}`)
643
1091
  .join(', ');
644
1092
  if (given)
645
1093
  lines.push(answersFrom === 'user'
646
1094
  ? `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));
1095
+ : `Answers not yet confirmed (${given}): apply needs --answers-from user (only choices the user or the request explicitly made; name yours in --ai-chose) or --defaults (only when the user said to decide)`);
1096
+ // D-0095: the agent's own choices, each with the reason it gave.
1097
+ const chosen = list('ai')
1098
+ .map(([id, item]) => `${id}=${item.value}${item.aiReason ? ` (${item.aiReason})` : ''}`)
1099
+ .join('; ');
1100
+ if (chosen)
1101
+ lines.push(`Chosen by you while the user left the choice to you (tell the user): ${chosen}`);
1102
+ const screens = context.noScreens ? answers[SCREENS] : undefined;
1103
+ if (screens)
1104
+ lines.push(`${SCREENS}=${NO_SCREENS} (${context.recipe} recipe, no browser screens): ${screens.answeredBy === 'ai'
1105
+ ? `chosen by you while the user left the choice to you (tell the user in the first lines of your final message)${screens.aiReason ? `: ${screens.aiReason}` : ''}`
1106
+ : answersFrom === 'user'
1107
+ ? "the user's choice (the request asks for an API that other programs call)"
1108
+ : `not yet confirmed: apply with --answers-from user only when the user or the request asked for an API without screens; otherwise it is your choice (--ai-chose ${SCREENS}) or a browser app (another recipe)`}`);
1109
+ // The record keeps the placeholders; the report names the address and the first admin the
1110
+ // plan uses (verify reads them from the project again).
1111
+ // D-0137: a fact notice says what the plan does (verify says what it finds in the project).
1112
+ lines.push(...notices(plan, context).map((line) => {
1113
+ const fact = factOf(line);
1114
+ if (fact)
1115
+ return plannedText(fact, context.planFacts);
1116
+ return line
1117
+ .replaceAll(APP_URL_PLACEHOLDER, `http://${context.domain}:${context.port}`)
1118
+ .replaceAll(FIRST_ADMIN_PLACEHOLDER, context.firstAdmin);
1119
+ }));
651
1120
  return lines.join('\n');
652
1121
  }
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
- : [];
1122
+ /**
1123
+ * What the user must know about the applied plan (in the report and `.dokebi/recipe.json`). A
1124
+ * notice that states a fact of the project is a `{fact:…}` token (D-0137): the report says it as
1125
+ * the plan makes it, verify as it finds it in the project.
1126
+ */
1127
+ function notices(plan, context) {
1128
+ const lines = [];
1129
+ const has = (id) => plan.some((step) => step.id === id);
1130
+ const fact = (value) => lines.push(factToken(value));
1131
+ if (context.noScreens)
1132
+ lines.push(noScreensRecord(context.recipe, context.browserRecipes));
1133
+ if (has('site-files'))
1134
+ fact({ id: 'site' });
1135
+ if (has('csrf')) {
1136
+ if (has('site-files')) {
1137
+ fact({ id: 'csrf', mode: 'site-api' });
1138
+ if (has('app-web') || has('app-admin'))
1139
+ fact({ id: 'csrf', mode: has('view-plugin') ? 'site-inertia' : 'site-ejs' });
1140
+ }
1141
+ else
1142
+ fact({ id: 'csrf', mode: has('form-body') ? 'form-body' : 'inertia' });
1143
+ }
1144
+ if (context.sealed.length)
1145
+ fact({ id: 'seal', view: context.sealedView, domains: context.sealed });
1146
+ if (context.unsealedSsr.length)
1147
+ fact({ id: 'ssr-seal', domains: context.unsealedSsr });
1148
+ if (has('login-code'))
1149
+ lines.push(loginNotice(APP_URL_PLACEHOLDER, context.devInfra));
1150
+ if (has('mail-infra'))
1151
+ fact({ id: 'mail' });
1152
+ // AI-M05 F3: an EJS blog without a session; not installed behind the user's back.
1153
+ if (context.forms && !has('view-plugin') && !has('session'))
1154
+ fact({ id: 'session' });
1155
+ return lines;
658
1156
  }
659
1157
  /** A dry run's first next step: the same command applied, with the answer source to state. */
660
1158
  function applyStep(recipe, request, answersFrom) {
@@ -663,6 +1161,11 @@ function applyStep(recipe, request, answersFrom) {
663
1161
  db: '--db',
664
1162
  devInfra: '--dev-infra',
665
1163
  admin: '--admin',
1164
+ mail: '--mail',
1165
+ apps: '--apps',
1166
+ domains: '--domains',
1167
+ realms: '--realms',
1168
+ worker: '--worker',
666
1169
  };
667
1170
  const parts = [`npx dokebi recipe ${recipe.name}`];
668
1171
  if (request.dir !== undefined)
@@ -681,6 +1184,11 @@ function applyStep(recipe, request, answersFrom) {
681
1184
  if (request.defaults)
682
1185
  parts.push('--defaults');
683
1186
  }
1187
+ // D-0095: the answers the agent chose, and its reasons, stay what they were in the dry run.
1188
+ if (request.aiChose?.length)
1189
+ parts.push(`--ai-chose ${request.aiChose.join(',')}`);
1190
+ for (const [id, reason] of Object.entries(request.aiReasons ?? {}))
1191
+ parts.push(`--ai-reason ${JSON.stringify(`${id}=${reason}`)}`);
684
1192
  parts.push('--json');
685
1193
  return {
686
1194
  command: parts.join(' '),
@@ -699,25 +1207,27 @@ export async function runRecipe(request, project) {
699
1207
  .filter((id) => !recipe.fixed?.[id])
700
1208
  .map((id) => `${choiceOf(catalog, id).flag} <${id}>`)
701
1209
  .join(' ');
1210
+ const notice = recipe.screens === false ? noScreensNotice(recipe.name, screenRecipes(catalog)) : undefined;
702
1211
  return {
703
1212
  status: 'needs-answers',
704
1213
  recipe: recipe.name,
705
1214
  summary: recipe.summary,
706
1215
  questions: questions(catalog, recipe, answers),
707
1216
  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.`,
1217
+ next: `${ASK_AND_WAIT}${notice ? ` ${notice}` : ''} 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. ${ANSWER_SOURCES} Tell the user the report.`,
1218
+ ...(notice ? { notice } : {}),
709
1219
  waitForUser: true,
710
1220
  };
711
1221
  }
712
- const answersFrom = request.answersFrom ?? (request.defaults ? 'defaults' : undefined);
1222
+ const answersFrom = answerSource(request);
713
1223
  // D-0065 §11 (AI-M02): an agent printed the questions and installed anyway. Applying needs the
714
1224
  // agent to say where the answers came from; it is recorded in the result (answersFrom, report).
715
1225
  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.', {
1226
+ throw new CliError('ANSWERS_SOURCE_REQUIRED', 'Say where the answers came from: --answers-from user (the user or the request made these choices; name the ones you chose in --ai-chose) or --defaults (the user said to decide). If the user has not answered yet, ask and end your turn.', {
717
1227
  phase: 'arguments',
718
1228
  details: {
719
1229
  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).`,
1230
+ fix: `${ASK_AND_WAIT} Then run the same command with --answers-from user (or --defaults when the user left the choice to you). ${ANSWER_SOURCES}`,
721
1231
  },
722
1232
  });
723
1233
  const root = resolve(request.dir ?? process.cwd());
@@ -731,14 +1241,72 @@ export async function runRecipe(request, project) {
731
1241
  init = true;
732
1242
  }
733
1243
  const port = request.port ?? recipe.web.port;
734
- const plan = steps(catalog, recipe, answers, {
1244
+ // D-0102: the default hosts carry the project's name (package.json, else the folder name).
1245
+ const hostLabel = await readProjectHostLabel(root);
1246
+ const hosts = recipeHosts(recipe, hostLabel, current?.options);
1247
+ const site = recipe.name === 'site' ? siteDefinition(answers, hostLabel) : undefined;
1248
+ if (site)
1249
+ await checkSite(root, answers, hostLabel);
1250
+ // D-0115: a project an older recipe gave options.blog-admin keeps that section.
1251
+ const seed = await readSeed(root);
1252
+ const adminSection = recipeAdminSection(current?.options ?? {}, seed?.text);
1253
+ const firstAdmin = describeFirstAdmin(current?.options ?? {}, seed);
1254
+ // D-0137: the login the plan names is the one it writes: an existing login key is kept.
1255
+ const adminLogin = firstAdmin.section === adminSection && firstAdmin.identity?.key === 'login'
1256
+ ? firstAdmin.identity.value
1257
+ : ADMIN_LOGIN;
1258
+ // D-0115: a project an older recipe gave the blog_authors table keeps it (and its migration).
1259
+ const loginNames = await readLoginNames(root);
1260
+ const plan = (recipe.name === 'site' ? siteSteps : steps)(catalog, recipe, answers, {
735
1261
  root,
736
1262
  project,
737
1263
  port,
1264
+ hostLabel,
738
1265
  ...(request.dbPort !== undefined ? { dbPort: request.dbPort } : {}),
739
1266
  init,
740
1267
  ...(request.dir !== undefined ? { dir: request.dir } : {}),
1268
+ adminSection,
1269
+ adminLogin,
1270
+ loginNames,
741
1271
  });
1272
+ const has = (id) => plan.some((step) => step.id === id);
1273
+ // The browser hosts of the plan: the site's page apps, or the blog's main (and admin) host.
1274
+ const browserHosts = site
1275
+ ? site.apps.filter((app) => app !== 'api').map((app) => site.domains[app])
1276
+ : recipe.web.forms === true
1277
+ ? [hosts.main, ...(has('admin-app') ? [hosts.admin] : [])]
1278
+ : [];
1279
+ const ssrView = answers.view?.value === 'inertia-vue-ssr';
1280
+ const noScreens = recipe.screens === false;
1281
+ const noticeContext = {
1282
+ recipe: recipe.name,
1283
+ browserRecipes: screenRecipes(catalog),
1284
+ noScreens,
1285
+ port,
1286
+ domain: site?.domains[site.apps[0] ?? ''] ?? hosts.main,
1287
+ devInfra: answers.devInfra?.value,
1288
+ // What the plan writes (an existing login key in that section is kept by the recipe).
1289
+ firstAdmin: firstAdminPhrase({
1290
+ section: adminSection,
1291
+ identity: { key: 'login', value: adminLogin, kind: 'login' },
1292
+ candidates: [],
1293
+ }),
1294
+ planFacts: {
1295
+ recipe: recipe.name,
1296
+ ...(site ? { site } : {}),
1297
+ ...(has('login-code') ? { adminLogin } : {}),
1298
+ },
1299
+ forms: recipe.web.forms === true,
1300
+ sealed: site
1301
+ ? site.apps
1302
+ .filter((app) => sealedSiteApp(site, app))
1303
+ .map((app) => site.domains[app])
1304
+ : has('seal-plugin')
1305
+ ? browserHosts
1306
+ : [],
1307
+ sealedView: has('view-plugin') ? 'inertia' : 'ejs',
1308
+ unsealedSsr: ssrView ? browserHosts : [],
1309
+ };
742
1310
  const checks = await preflight(root, answers, catalog, plan, port, request.dbPort, answersFrom === 'defaults');
743
1311
  const asks = portAsks(checks);
744
1312
  const base = {
@@ -746,12 +1314,13 @@ export async function runRecipe(request, project) {
746
1314
  root,
747
1315
  answers,
748
1316
  answersFrom: answersFrom ?? null,
749
- report: report(catalog, answers, answersFrom, plan),
1317
+ report: report(answers, answersFrom, plan, noticeContext),
750
1318
  plan: { steps: plan.map(({ id, command, why }) => ({ id, command, why })) },
1319
+ ...(noScreens ? { notice: noScreensNotice(recipe.name, noticeContext.browserRecipes) } : {}),
751
1320
  preflight: checks,
752
1321
  nextSteps: [
753
1322
  ...(request.dryRun ? [applyStep(recipe, request, answersFrom)] : []),
754
- ...nextSteps(catalog, recipe, answers, plan, port),
1323
+ ...nextSteps(catalog, recipe, answers, plan, port, answersFrom, adminSection, loginNames, adminLogin),
755
1324
  ],
756
1325
  };
757
1326
  const blocking = checks.filter((item) => item.severity === 'error');
@@ -794,8 +1363,11 @@ export async function runRecipe(request, project) {
794
1363
  appliedAt: new Date().toISOString(),
795
1364
  answersFrom: answersFrom,
796
1365
  // 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
- });
1366
+ answers: Object.fromEntries([...recipe.questions, ...(noScreens ? [SCREENS] : [])].flatMap((id) => answers[id] ? [[id, answers[id]]] : [])),
1367
+ notices: notices(plan, noticeContext),
1368
+ domain: site?.domains[site.apps[0] ?? ''] ?? hosts.main,
1369
+ },
1370
+ // D-0098: the recipe's answers replace the recorded views of the apps it made.
1371
+ site ? [...site.apps] : ['main', ...(has('admin-app') ? ['admin'] : [])]);
800
1372
  return { status: 'applied', ...base, completedSteps, skippedSteps };
801
1373
  }