@dokebi/cli 0.4.2 → 0.4.26

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 (188) hide show
  1. package/AI.md +262 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2519 -257
  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 +518 -35
  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 +2 -0
  46. package/dist/check/shared-state-rules.js +114 -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 +307 -31
  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-show.d.ts +82 -0
  70. package/dist/config-show.js +563 -0
  71. package/dist/config-values.d.ts +8 -0
  72. package/dist/config-values.js +68 -0
  73. package/dist/configuration-scaffold.d.ts +42 -0
  74. package/dist/configuration-scaffold.js +258 -4
  75. package/dist/configuration.d.ts +63 -3
  76. package/dist/configuration.js +178 -4
  77. package/dist/deployment.d.ts +2 -0
  78. package/dist/deployment.js +111 -0
  79. package/dist/describe-db.d.ts +22 -0
  80. package/dist/describe-db.js +176 -0
  81. package/dist/describe.d.ts +92 -0
  82. package/dist/describe.js +417 -0
  83. package/dist/dev-compose.d.ts +20 -0
  84. package/dist/dev-compose.js +154 -0
  85. package/dist/dev-guard.js +44 -0
  86. package/dist/dev-host.d.ts +13 -0
  87. package/dist/dev-host.js +55 -0
  88. package/dist/dev-infra.d.ts +72 -4
  89. package/dist/dev-infra.js +237 -17
  90. package/dist/dev-watch.d.ts +34 -0
  91. package/dist/dev-watch.js +192 -0
  92. package/dist/diagnostics.d.ts +10 -7
  93. package/dist/diagnostics.js +22 -4
  94. package/dist/drift.d.ts +50 -0
  95. package/dist/drift.js +218 -0
  96. package/dist/env-file.d.ts +25 -1
  97. package/dist/env-file.js +64 -6
  98. package/dist/errors.d.ts +7 -1
  99. package/dist/example-config.d.ts +2 -0
  100. package/dist/example-config.js +3 -0
  101. package/dist/first-admin.d.ts +43 -0
  102. package/dist/first-admin.js +175 -0
  103. package/dist/formatter-ignore.d.ts +37 -0
  104. package/dist/formatter-ignore.js +344 -0
  105. package/dist/generator.d.ts +45 -3
  106. package/dist/generator.js +205 -15
  107. package/dist/guide.d.ts +1 -1
  108. package/dist/guide.js +42 -21
  109. package/dist/install-module.d.ts +20 -2
  110. package/dist/install-module.js +45 -8
  111. package/dist/install-plan.js +5 -3
  112. package/dist/layout.d.ts +12 -1
  113. package/dist/layout.js +19 -6
  114. package/dist/log-writer-control.d.ts +2 -0
  115. package/dist/log-writer-control.js +17 -2
  116. package/dist/log-writer.js +43 -10
  117. package/dist/logging-config.d.ts +8 -3
  118. package/dist/logging-config.js +19 -5
  119. package/dist/manifest.d.ts +29 -1
  120. package/dist/manifest.js +84 -6
  121. package/dist/merge-install-defaults.d.ts +0 -1
  122. package/dist/merge-install-defaults.js +13 -47
  123. package/dist/npm-client.d.ts +40 -4
  124. package/dist/npm-client.js +152 -15
  125. package/dist/npm-paths.js +18 -4
  126. package/dist/openapi.d.ts +27 -0
  127. package/dist/openapi.js +649 -0
  128. package/dist/option-fixes.d.ts +39 -0
  129. package/dist/option-fixes.js +130 -0
  130. package/dist/output.d.ts +1 -1
  131. package/dist/output.js +5 -5
  132. package/dist/plugin-lifecycle.d.ts +34 -2
  133. package/dist/plugin-lifecycle.js +195 -16
  134. package/dist/plugin-options.d.ts +19 -4
  135. package/dist/plugin-options.js +138 -6
  136. package/dist/plugins-command.js +44 -7
  137. package/dist/process-group.d.ts +39 -0
  138. package/dist/process-group.js +132 -0
  139. package/dist/project-guide.d.ts +19 -1
  140. package/dist/project-guide.js +119 -20
  141. package/dist/project-inspector.d.ts +15 -0
  142. package/dist/project-inspector.js +481 -33
  143. package/dist/project-state.d.ts +5 -0
  144. package/dist/project-state.js +8 -0
  145. package/dist/project.d.ts +203 -19
  146. package/dist/project.js +3006 -333
  147. package/dist/recipe-facts.d.ts +127 -0
  148. package/dist/recipe-facts.js +563 -0
  149. package/dist/recipe-login.d.ts +63 -0
  150. package/dist/recipe-login.js +464 -0
  151. package/dist/recipe-mail.d.ts +21 -0
  152. package/dist/recipe-mail.js +214 -0
  153. package/dist/recipe-members.d.ts +35 -0
  154. package/dist/recipe-members.js +178 -0
  155. package/dist/recipe-record.d.ts +82 -7
  156. package/dist/recipe-record.js +226 -22
  157. package/dist/recipe-reset.d.ts +2 -0
  158. package/dist/recipe-reset.js +117 -0
  159. package/dist/recipe-site.d.ts +48 -0
  160. package/dist/recipe-site.js +396 -0
  161. package/dist/recipe-step.d.ts +42 -0
  162. package/dist/recipe-step.js +89 -0
  163. package/dist/recipe.d.ts +56 -20
  164. package/dist/recipe.js +703 -131
  165. package/dist/registry.d.ts +98 -2
  166. package/dist/registry.js +171 -5
  167. package/dist/reloadable.d.ts +35 -0
  168. package/dist/reloadable.js +257 -0
  169. package/dist/server.d.ts +43 -4
  170. package/dist/server.js +601 -52
  171. package/dist/settings.d.ts +48 -0
  172. package/dist/settings.js +168 -0
  173. package/dist/site-auth.d.ts +39 -0
  174. package/dist/site-auth.js +195 -0
  175. package/dist/template-fixes.d.ts +26 -0
  176. package/dist/template-fixes.js +285 -0
  177. package/dist/test-reporter.d.ts +49 -0
  178. package/dist/test-reporter.js +190 -0
  179. package/dist/transaction.d.ts +47 -0
  180. package/dist/transaction.js +241 -7
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +103 -3
  184. package/dist/verify.js +272 -21
  185. package/dist/view-choice.d.ts +58 -0
  186. package/dist/view-choice.js +131 -0
  187. package/dist/vite-watch.js +4 -2
  188. package/package.json +8 -6
@@ -1,14 +1,52 @@
1
1
  // What a recipe chose, kept for the end of the work (AI-M03): `dokebi recipe` writes
2
2
  // `.dokebi/recipe.json` when it applied a recipe, and `dokebi verify` (the last command an agent
3
3
  // runs) returns the lines the agent must pass on to the user as `tellUser`: every answer with who
4
- // gave it (a default with its reason) and the notices, such as no CSRF protection for forms.
4
+ // gave it (a default with its reason) and the notices, such as how forms send the CSRF token.
5
5
  // The file holds choice values and catalog texts only, never secrets.
6
+ // A notice that names where the app answers holds APP_URL_PLACEHOLDER instead of an address
7
+ // (AI-M05: a recorded `http://localhost:3001/login` was relayed after the port became 3002);
8
+ // verify fills in the current address. Records written before keep their literal text, and a
9
+ // loopback address in it whose port is no longer the app's is replaced by the current one.
10
+ // Each answer says who settled it (D-0095): the user, the AI (the user left it to it), a default
11
+ // or the recipe. Records written before D-0095 say `flag`; reading turns it into `user` or `ai`.
12
+ // D-0098: the view of an app made outside a recipe (`dokebi app add`, the first app of the apps
13
+ // host) is recorded in `choices` of the same file, with who chose it; a recipe that covers the
14
+ // app replaces it. Files without `choices` read as none.
15
+ // D-0115: the login notice holds FIRST_ADMIN_PLACEHOLDER instead of the login name and the
16
+ // section of the initial password; verify fills in what the project configures then (the builder
17
+ // may rename the section or sign the admin in by email). A record from before says them in words;
18
+ // that part is replaced the same way, so a stale name is never relayed.
19
+ // D-0137: a notice that states a fact of the project (CSRF, Seal, form bodies, a session, local
20
+ // mail, the site's realms, user store and worker) is recorded as a `{fact:…}` token
21
+ // (recipe-facts.ts); verify says what it finds then. The old sentences of such notices are read
22
+ // the same way.
6
23
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
7
24
  import { dirname, join } from 'node:path';
25
+ import { isLoopbackHost } from './app-urls.js';
8
26
  import { debug, isMissing, reportError } from './diagnostics.js';
27
+ import { firstAdminPhrase } from './first-admin.js';
28
+ import { factKey, factOf, unreadText } from './recipe-facts.js';
9
29
  export const RECIPE_RECORD = '.dokebi/recipe.json';
10
- /** The recipes recorded in the project; none when there is no record (or it is unreadable). */
11
- export async function readAppliedRecipes(root) {
30
+ /**
31
+ * How the agent passes `tellUser` on (AI-M05 F6: an agent summarized the lines in another
32
+ * language instead of relaying them): verify's JSON carries it next to the lines, the human
33
+ * output prints it as the block's heading.
34
+ */
35
+ export const TELL_USER_RELAY = {
36
+ tellUser: 'verbatim',
37
+ instruction: 'Copy these lines unchanged into your final message to the user: do not translate, reword or summarize them. You may add your own summary after them.',
38
+ };
39
+ /** Stands for `<scheme>://<domain>:<port>` of the app in a recorded notice. */
40
+ export const APP_URL_PLACEHOLDER = '{appUrl}';
41
+ /** Stands for the first admin's login and password key in the login notice (firstAdminPhrase). */
42
+ export const FIRST_ADMIN_PLACEHOLDER = '{firstAdmin}';
43
+ /** What the login notice says about protected pages (D-0115: no word of one kind of app). */
44
+ export const LOGIN_PAGES = 'pages that need a login send other visitors to the login page.';
45
+ /** The same part of the login notice as recipes before D-0115 recorded it, in words. */
46
+ const RECORDED_FIRST_ADMIN = / with the login name \S+ and the initial password stored in dokebi\.yaml at options\.[^\s.]+\.initialPassword \(it is never printed\); only signed-in users write posts\. Change that password before going live and then remove the key\./;
47
+ /** Both lists of the record; empty when there is no record (or it is unreadable). */
48
+ async function readRecord(root) {
49
+ const none = { recipes: [], choices: [] };
12
50
  let text;
13
51
  try {
14
52
  text = await readFile(join(root, RECIPE_RECORD), 'utf8');
@@ -16,52 +54,218 @@ export async function readAppliedRecipes(root) {
16
54
  catch (error) {
17
55
  if (!isMissing(error))
18
56
  reportError('RECIPE_RECORD_READ_FAILED', `Unable to read ${RECIPE_RECORD}`, error);
19
- return [];
57
+ return none;
20
58
  }
21
59
  try {
22
60
  const parsed = JSON.parse(text);
23
61
  if (parsed.version === 1 && Array.isArray(parsed.recipes))
24
- return parsed.recipes.filter((entry) => typeof entry?.recipe === 'string' &&
25
- entry.answers !== null &&
26
- typeof entry.answers === 'object' &&
27
- Array.isArray(entry.notices));
62
+ return {
63
+ recipes: parsed.recipes
64
+ .filter((entry) => typeof entry?.recipe === 'string' &&
65
+ entry.answers !== null &&
66
+ typeof entry.answers === 'object' &&
67
+ Array.isArray(entry.notices))
68
+ .map(migrateFlagAnswers),
69
+ choices: (Array.isArray(parsed.choices) ? parsed.choices : []).filter((entry) => typeof entry?.app === 'string' &&
70
+ entry.id === 'view' &&
71
+ typeof entry.value === 'string' &&
72
+ ['user', 'ai', 'unstated'].includes(entry.answeredBy)),
73
+ };
28
74
  debug('Recipe record ignored: unexpected content', { file: RECIPE_RECORD });
29
75
  }
30
76
  catch (error) {
31
77
  reportError('RECIPE_RECORD_INVALID', `${RECIPE_RECORD} is not valid JSON; ignoring it`, error);
32
78
  }
33
- return [];
79
+ return none;
34
80
  }
35
- /** Records an applied recipe, replacing an earlier application of the same recipe. */
36
- export async function saveAppliedRecipe(root, entry) {
81
+ /** The recipes recorded in the project; none when there is no record (or it is unreadable). */
82
+ export async function readAppliedRecipes(root) {
83
+ return (await readRecord(root)).recipes;
84
+ }
85
+ /** D-0098: the choices recorded outside recipes (the views of apps). */
86
+ export async function readRecordedChoices(root) {
87
+ return (await readRecord(root)).choices;
88
+ }
89
+ /**
90
+ * A record written before D-0095 says `answeredBy: 'flag'` for every given answer, and
91
+ * `answersFrom` said whose they were: `user` → the user's, `defaults` → the agent's. The same
92
+ * lines come out of tellUser as before.
93
+ */
94
+ function migrateFlagAnswers(entry) {
95
+ const old = Object.entries(entry.answers).filter(([, answer]) => answer?.answeredBy === 'flag');
96
+ if (!old.length)
97
+ return entry;
98
+ const answeredBy = entry.answersFrom === 'user' ? 'user' : 'ai';
99
+ debug('Recipe record: old flag answers read as their source', {
100
+ recipe: entry.recipe,
101
+ answers: old.map(([id]) => id),
102
+ answeredBy,
103
+ });
104
+ return {
105
+ ...entry,
106
+ answers: Object.fromEntries(Object.entries(entry.answers).map(([id, answer]) => [
107
+ id,
108
+ answer.answeredBy === 'flag' ? { ...answer, answeredBy } : answer,
109
+ ])),
110
+ };
111
+ }
112
+ async function writeRecord(root, record) {
37
113
  const file = join(root, RECIPE_RECORD);
38
- const recipes = [
39
- ...(await readAppliedRecipes(root)).filter((item) => item.recipe !== entry.recipe),
40
- entry,
41
- ];
42
- const record = { version: 1, recipes };
43
114
  await mkdir(dirname(file), { recursive: true });
44
115
  await rm(`${file}.tmp`, { force: true });
45
116
  await writeFile(`${file}.tmp`, `${JSON.stringify(record, null, 2)}\n`);
46
117
  await rename(`${file}.tmp`, file);
118
+ }
119
+ /**
120
+ * Records an applied recipe, replacing an earlier application of the same recipe. The recorded
121
+ * app choices of `apps` (the apps the recipe answers for) give way to the recipe's answers.
122
+ */
123
+ export async function saveAppliedRecipe(root, entry, apps = []) {
124
+ const current = await readRecord(root);
125
+ const recipes = [...current.recipes.filter((item) => item.recipe !== entry.recipe), entry];
126
+ const choices = current.choices.filter((item) => !apps.includes(item.app));
127
+ if (choices.length < current.choices.length)
128
+ debug('Recipe record: app choices replaced by the recipe', { recipe: entry.recipe, apps });
129
+ await writeRecord(root, { version: 1, recipes, ...(choices.length ? { choices } : {}) });
47
130
  debug('Recipe record written', { recipe: entry.recipe, file: RECIPE_RECORD });
48
131
  }
132
+ /**
133
+ * `dokebi app remove`: drops the choices recorded for `app`. True when the record changed (the
134
+ * file is left alone when it holds none of that app).
135
+ */
136
+ export async function forgetAppChoices(root, app) {
137
+ const current = await readRecord(root);
138
+ const choices = current.choices.filter((item) => item.app !== app);
139
+ if (choices.length === current.choices.length)
140
+ return false;
141
+ await writeRecord(root, {
142
+ version: 1,
143
+ recipes: current.recipes,
144
+ ...(choices.length ? { choices } : {}),
145
+ });
146
+ debug('Recipe record: choices of a removed app dropped', { app, file: RECIPE_RECORD });
147
+ return true;
148
+ }
149
+ /** D-0098: records the view of an app made outside a recipe, replacing that app's earlier one. */
150
+ export async function saveRecordedChoice(root, choice) {
151
+ const current = await readRecord(root);
152
+ const choices = [
153
+ ...current.choices.filter((item) => item.app !== choice.app || item.id !== choice.id),
154
+ choice,
155
+ ];
156
+ await writeRecord(root, { version: 1, recipes: current.recipes, choices });
157
+ debug('Recipe record: app choice written', {
158
+ app: choice.app,
159
+ id: choice.id,
160
+ value: choice.value,
161
+ answeredBy: choice.answeredBy,
162
+ });
163
+ }
164
+ const ADDRESS = /\b(https?):\/\/([A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\]):(\d{1,5})(?![\d])/g;
165
+ /** `<scheme>://<host>:<port>` of a URL; undefined when it does not parse. */
166
+ function origin(url) {
167
+ try {
168
+ const parsed = new URL(url);
169
+ return { href: `${parsed.protocol}//${parsed.host}`, hostname: parsed.hostname };
170
+ }
171
+ catch (error) {
172
+ debug('Skipped an app address that is not a URL', { url, error: String(error) });
173
+ return undefined;
174
+ }
175
+ }
176
+ /** The current address of the app for `domain`: a url on that host, else the first, else the port. */
177
+ function appOrigin(domain, address) {
178
+ const origins = address.urls.flatMap((url) => origin(url) ?? []);
179
+ const match = origins.find((item) => item.hostname === domain) ?? origins[0];
180
+ if (match)
181
+ return match.href;
182
+ return address.port ? `http://${domain}:${address.port}` : undefined;
183
+ }
184
+ /** Whether a recorded notice names the first admin (filled from the project by verify). */
185
+ export function namesFirstAdmin(notice) {
186
+ return notice.includes(FIRST_ADMIN_PLACEHOLDER) || RECORDED_FIRST_ADMIN.test(notice);
187
+ }
188
+ /** What the no-screens notice said before D-0137 (present tense: false once screens are added). */
189
+ const RECORDED_NO_SCREENS = 'the app answers JSON for other programs; people cannot sign up, browse or press buttons in a browser.';
190
+ /** The same part as the recipe says it now: what the recipe built, true whatever comes later. */
191
+ export const NO_SCREENS_BUILT = 'the recipe built a JSON API for other programs, with no pages where people sign up, browse or press buttons.';
192
+ /**
193
+ * One recorded notice as the user hears it now. A fact notice (D-0137) is the text verify read
194
+ * from the project (`facts`, by factKey), or, without it, that the CLI could not read it; '' when
195
+ * nothing is left to say. Any other notice gets the current app address: the placeholder filled
196
+ * in, or (a record from before the placeholder) a loopback address whose port is not the app's
197
+ * replaced by the current one; without a current address the placeholder becomes
198
+ * `http://<domain>`. The first admin placeholder gets `firstAdmin` (D-0115).
199
+ */
200
+ export function fillNotice(notice, entry, address, firstAdmin = firstAdminPhrase({ candidates: [] }), facts) {
201
+ const fact = factOf(notice);
202
+ if (fact)
203
+ return facts?.get(factKey(entry.recipe, fact)) ?? unreadText(fact, entry.recipe);
204
+ if (notice.includes(RECORDED_NO_SCREENS)) {
205
+ debug('Recorded no-screens notice: said as what the recipe built', { recipe: entry.recipe });
206
+ notice = notice.replace(RECORDED_NO_SCREENS, NO_SCREENS_BUILT);
207
+ }
208
+ if (RECORDED_FIRST_ADMIN.test(notice)) {
209
+ debug('Recorded login notice: the first admin is read from the project', {
210
+ recipe: entry.recipe,
211
+ });
212
+ notice = notice.replace(RECORDED_FIRST_ADMIN, ` ${FIRST_ADMIN_PLACEHOLDER}; ${LOGIN_PAGES}`);
213
+ }
214
+ notice = notice.replaceAll(FIRST_ADMIN_PLACEHOLDER, firstAdmin);
215
+ const domain = entry.domain ?? 'localhost';
216
+ if (notice.includes(APP_URL_PLACEHOLDER))
217
+ return notice.replaceAll(APP_URL_PLACEHOLDER, appOrigin(domain, address) ?? `http://${domain}`);
218
+ return notice.replace(ADDRESS, (text, scheme, host, port) => {
219
+ if (!isLoopbackHost(host))
220
+ return text;
221
+ const current = appOrigin(host.replace(/^\[|\]$/g, ''), address);
222
+ if (!current || current === `${scheme}://${host}:${port}`)
223
+ return text;
224
+ debug('Recorded notice address replaced by the current one', {
225
+ recipe: entry.recipe,
226
+ recorded: `${scheme}://${host}:${port}`,
227
+ current,
228
+ });
229
+ return current;
230
+ });
231
+ }
232
+ /** What the user hears about an answer the agent chose (D-0095), with its reason when given. */
233
+ export const AI_CHOICE_LINE = 'chosen by the AI (you left the choice to it)';
234
+ /** D-0098: an EJS app added without saying who chose EJS. */
235
+ export const UNSTATED_VIEW_LINE = 'not the default view (Inertia + Vue), and who chose it was not recorded';
49
236
  /**
50
237
  * The lines for the user's final message: each answer and who chose it (a default with its
51
- * reason), then the notices. Answers fixed by the recipe itself are not choices and are left out.
238
+ * reason, an AI choice with the agent's reason when it gave one), then the notices with the
239
+ * current app address. Answers fixed by the recipe itself are not choices and are left out.
52
240
  */
53
- export function tellUserLines(recipes) {
241
+ export function tellUserLines(recipes, address = { urls: [] }, choices = [],
242
+ /** firstAdminPhrase of the project now (D-0115); without it, the not-found words. */
243
+ firstAdmin,
244
+ /** D-0137: the fact notices as verify read them from the project (factTexts). */
245
+ facts) {
54
246
  const lines = [];
55
247
  for (const entry of recipes) {
56
248
  const prefix = recipes.length > 1 ? `[${entry.recipe}] ` : '';
57
249
  for (const [id, answer] of Object.entries(entry.answers)) {
58
250
  if (answer.answeredBy === 'default')
59
251
  lines.push(`${prefix}${id} = ${answer.value}: chosen by default (${answer.defaultReason ?? 'the recipe default'})`);
60
- else if (answer.answeredBy === 'flag')
61
- lines.push(`${prefix}${id} = ${answer.value}: ${entry.answersFrom === 'user' ? 'your answer' : 'chosen by the AI (you left the choice to it)'}`);
252
+ else if (answer.answeredBy === 'user')
253
+ lines.push(`${prefix}${id} = ${answer.value}: your answer`);
254
+ else if (answer.answeredBy === 'ai')
255
+ lines.push(`${prefix}${id} = ${answer.value}: ${AI_CHOICE_LINE}${answer.aiReason ? `: ${answer.aiReason}` : ''}`);
256
+ }
257
+ for (const notice of entry.notices) {
258
+ const text = fillNotice(notice, entry, address, firstAdmin, facts);
259
+ if (text)
260
+ lines.push(`${prefix}${text}`);
62
261
  }
63
- for (const notice of entry.notices)
64
- lines.push(`${prefix}${notice}`);
65
262
  }
263
+ // D-0098: the views of apps made outside a recipe, each with who chose it.
264
+ for (const choice of choices)
265
+ lines.push(`[app ${choice.app}] ${choice.id} = ${choice.value}: ${choice.answeredBy === 'user'
266
+ ? 'your answer'
267
+ : choice.answeredBy === 'ai'
268
+ ? `${AI_CHOICE_LINE}${choice.aiReason ? `: ${choice.aiReason}` : ''}`
269
+ : UNSTATED_VIEW_LINE}`);
66
270
  return lines;
67
271
  }
@@ -0,0 +1,2 @@
1
+ import type { LoginFile } from './recipe-login.js';
2
+ export declare function resetFiles(): LoginFile[];
@@ -0,0 +1,117 @@
1
+ export function resetFiles() {
2
+ return [
3
+ { path: 'src/apps/main/routes/password-reset.ts', content: ROUTES },
4
+ { path: 'src/apps/main/controllers/password-reset.ts', content: CONTROLLER },
5
+ { path: 'src/apps/main/views/default/password-forgot.ejs', content: FORGOT },
6
+ { path: 'src/apps/main/views/default/password-reset.ejs', content: RESET },
7
+ { path: 'src/shared/mail/password-reset/en/subject.ejs', content: 'Reset your password\n' },
8
+ {
9
+ path: 'src/shared/mail/password-reset/en/html.ejs',
10
+ content: '<p><a href="<%= data.resetUrl %>">Reset your password</a></p><p>This link expires in <%= data.expiresInMinutes %> minutes. If you did not request it, ignore this email.</p>\n',
11
+ },
12
+ ];
13
+ }
14
+ const ROUTES = `import type { AppRouter } from '@dokebi/app';
15
+ import { PasswordResetController } from '../controllers/password-reset.ts';
16
+
17
+ export function passwordResetRoutes(route: AppRouter): void {
18
+ const controller = new PasswordResetController();
19
+ route.get(
20
+ '/password/forgot',
21
+ (_request, reply, page) => controller.forgotPage(_request, reply, page),
22
+ { config: { auth: false } },
23
+ );
24
+ route.post(
25
+ '/password/forgot',
26
+ (request, reply, page) => controller.requestReset(request, reply, page),
27
+ { config: { auth: false } },
28
+ );
29
+ route.get(
30
+ '/password/reset',
31
+ (request, reply, page) => controller.resetPage(request, reply, page),
32
+ { config: { auth: false } },
33
+ );
34
+ route.post('/password/reset', (request, reply, page) => controller.reset(request, reply, page), {
35
+ config: { auth: false },
36
+ });
37
+ }
38
+ `;
39
+ const CONTROLLER = `// Password recovery. Mail is queued; tokens never go into a session or a log.
40
+ import type { AppPage } from '@dokebi/app';
41
+ import type { FastifyRequest, FastifyReply } from 'fastify';
42
+ import { validationError } from '@dokebi/web';
43
+
44
+ export class PasswordResetController {
45
+ forgotPage(_request: FastifyRequest, reply: FastifyReply, page: AppPage) {
46
+ return page.view.reply(reply, 'password-forgot', { sent: false }, { layout: false });
47
+ }
48
+ async requestReset(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
49
+ const body = (request.body ?? {}) as Record<string, unknown>;
50
+ const result = await request.auth.passwordReset.request(
51
+ typeof body.identity === 'string' ? body.identity : '',
52
+ );
53
+ if (!result.ok && result.error) throw result.error;
54
+ return page.view.reply(reply, 'password-forgot', { sent: true }, { layout: false });
55
+ }
56
+ async resetPage(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
57
+ const token = (request.query as { token?: unknown }).token;
58
+ const text = typeof token === 'string' ? token : '';
59
+ const checked = await request.auth.passwordReset.check(text);
60
+ return page.view.reply(
61
+ reply,
62
+ 'password-reset',
63
+ {
64
+ token: checked.ok ? text : '',
65
+ error: checked.ok ? '' : 'This link is invalid or expired. Request another link.',
66
+ },
67
+ { layout: false },
68
+ );
69
+ }
70
+ async reset(request: FastifyRequest, reply: FastifyReply, page: AppPage) {
71
+ const body = (request.body ?? {}) as Record<string, unknown>;
72
+ const token = typeof body.token === 'string' ? body.token : '';
73
+ const password = typeof body.password === 'string' ? body.password : '';
74
+ // A no-referrer page cannot rely on the generic form-back redirect. Render its field error
75
+ // directly, with the same validationError message, keeping only the token (never password).
76
+ if (password.length < 12 || Buffer.byteLength(password) > 1024) {
77
+ const invalid = validationError({
78
+ password: 'Use at least 12 characters and at most 1024 bytes.',
79
+ });
80
+ return page.view.reply(
81
+ reply.code(422),
82
+ 'password-reset',
83
+ { token, error: invalid.errors.password },
84
+ { layout: false },
85
+ );
86
+ }
87
+ const result = await request.auth.passwordReset.complete({ token, password });
88
+ if (!result.ok) {
89
+ if (result.error) throw result.error;
90
+ return page.view.reply(
91
+ reply.code(422),
92
+ 'password-reset',
93
+ { token: '', error: 'This link is invalid or expired. Request another link.' },
94
+ { layout: false },
95
+ );
96
+ }
97
+ return reply.redirect('/login', 303);
98
+ }
99
+ }
100
+ `;
101
+ // Both pages render without the shared layout ({ layout: false }), so they write the seal head
102
+ // themselves (D-0092 §1.5): on a sealed domain it loads @dokebi/seal/forms, which seals their post
103
+ // forms. `?.` keeps the pages working on an @dokebi/app that does not provide data.seal yet.
104
+ const FORGOT = `<!doctype html>
105
+ <html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Reset your password</title><%- data.seal?.head ?? '' %></head><body>
106
+ <h1>Reset your password</h1>
107
+ <% if (data.sent) { %><p>If an eligible account exists, a reset link will be emailed to it.</p><% } %>
108
+ <form method="post" action="/password/forgot"><%- data.csrf.field %>
109
+ <label>Login <input name="identity" autocomplete="username" required></label><button>Send reset link</button></form>
110
+ <a href="/login">Back to login</a></body></html>\n`;
111
+ const RESET = `<!doctype html>
112
+ <html lang="en"><head><meta charset="utf-8"><meta name="referrer" content="no-referrer"><title>Choose a password</title><%- data.seal?.head ?? '' %></head><body>
113
+ <h1>Choose a new password</h1><% if (data.error) { %><p role="alert"><%= data.error %></p><% } %>
114
+ <% if (data.token) { %><form method="post" action="/password/reset"><%- data.csrf.field %>
115
+ <input type="hidden" name="token" value="<%= data.token %>">
116
+ <label>New password <input type="password" name="password" autocomplete="new-password" minlength="12" required></label><button>Change password</button></form><% } %>
117
+ <a href="/password/forgot">Request another link</a></body></html>\n`;
@@ -0,0 +1,48 @@
1
+ import type { Catalog, CatalogRecipe } from './catalog.js';
2
+ import type { Project } from './project.js';
3
+ import { type Answer, type Step } from './recipe-step.js';
4
+ /** Where older site recipes wrote the job worker; it keeps working (doctor LEGACY_LAYOUT). */
5
+ export declare const LEGACY_SITE_WORKER_PATH = "src/site-worker.ts";
6
+ /** Process-level runtime wiring sits in src/infra/runtime, beside the cluster worker factory. */
7
+ export declare const SITE_WORKER_PATH: string;
8
+ /**
9
+ * The opening of SITE.md, the guide the recipe writes once (kept byte for byte, so a re-run finds
10
+ * the guide it wrote). Its conditions ("until connected", "needs … before verify") are how the
11
+ * scaffold starts. D-0137: it is no longer a recorded notice: the record holds the `{fact:site}`
12
+ * token and verify says what it finds; records written before keep this text and are read the
13
+ * same way (recipe-facts.ts recordedFact).
14
+ */
15
+ export declare const SITE_NOTICE = "Site scaffolding separates domains and auth realms. User lookup and login screens are app code: the generated auth extensions deny every login until connected to your user store. Admin and API routes require authentication. A job worker needs a configured NATS connection and a provisioned siteWorker queue before verify; the thread worker handles CPU tasks in this process.";
16
+ /**
17
+ * The site's apps, domains, realms and worker. `label` is the project's host label (D-0102):
18
+ * `--domains auto` gives every app `<app>.<label>.localhost`, unique to this project.
19
+ */
20
+ export declare function siteDefinition(answers: Readonly<Record<string, Answer>>, label: string): {
21
+ apps: string[];
22
+ worker: string;
23
+ domains: Record<string, string>;
24
+ realms: Record<string, string>;
25
+ inertia: boolean;
26
+ ssr: boolean;
27
+ };
28
+ export declare function checkSite(root: string, answers: Readonly<Record<string, Answer>>, label: string): Promise<void>;
29
+ /** The auth extension the site recipe writes: one stub class per realm (exported for its tests). */
30
+ export declare function siteAuthSource(realms: string[]): string;
31
+ /** What the site scaffold's GET /login answers until the app writes its login (verify reads it). */
32
+ export declare const SITE_LOGIN_HINT = "Connect the realm extension to your user store and implement login with request.auth.attempt.";
33
+ /** The routes file of a site app (the scaffold's GET /login lives there). */
34
+ export declare const siteRoutesPath: (app: string) => string;
35
+ /**
36
+ * A browser app of the site (not api): its host is sealed (D-0092 §1, D-0062), EJS and Inertia
37
+ * alike. Server rendering cannot be sealed (SEAL_SSR_CONFLICT), so SSR apps stay plain and the
38
+ * recipe notice says so.
39
+ */
40
+ export declare function sealedSiteApp(spec: ReturnType<typeof siteDefinition>, app: string): boolean;
41
+ /** Site reuses transactional install/app/config/file commands and the common recipe executor. */
42
+ export declare function siteSteps(catalog: Catalog, _recipe: CatalogRecipe, answers: Readonly<Record<string, Answer>>, context: {
43
+ root: string;
44
+ project: Project;
45
+ port: number;
46
+ init: boolean;
47
+ hostLabel: string;
48
+ }): Step[];