@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/verify.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // `dokebi verify` (D-0065): the one check an AI runs before it says "done". doctor → check
2
- // (D-0072, user-code conventions) → typecheck → build (Vite projects) → preflight → start →
2
+ // (D-0072, user-code conventions) → typecheck → build (Vite projects) → test (the project's
3
+ // `test` script, a result per node:test test: D-0077 H, verify-tests.ts) → preflight → start →
3
4
  // HTTP checks → stop; `ok` only when every check that ran passed. The app it starts is always
4
5
  // stopped again.
5
6
  //
@@ -16,20 +17,127 @@ import { join } from 'node:path';
16
17
  import { setTimeout as delay } from 'node:timers/promises';
17
18
  import { appUrls } from './app-urls.js';
18
19
  import { check } from './check/index.js';
19
- import { CONFIG_FILE, parseProjectConfig } from './configuration.js';
20
- import { portInUse, URL_PLACEHOLDER_PREFIX } from './dev-infra.js';
21
- import { debug, describeError } from './diagnostics.js';
20
+ import { CONFIG_FILE, configTarget, FRESH_COPY_FIX, parseProjectConfig, readNamedSettings, } from './configuration.js';
21
+ import { freePortAfter, portInUse, portQuestion, URL_PLACEHOLDER_PREFIX } from './dev-infra.js';
22
+ import { debug, describeError, isMissing, reportError } from './diagnostics.js';
23
+ import { envFileWarnings } from './env-file.js';
22
24
  import { CliError } from './errors.js';
23
25
  import { exists, parseObject, securePath } from './filesystem.js';
26
+ import { firstAdminPhrase, readFirstAdmin } from './first-admin.js';
24
27
  import { findHost } from './manifest.js';
25
28
  import { OutputTail } from './npm-client.js';
29
+ import { forwardTermination, killGroup, PROCESS_GROUPS } from './process-group.js';
26
30
  import { ProjectInspector } from './project-inspector.js';
27
31
  import { readState } from './project-state.js';
28
- import { readAppliedRecipes, tellUserLines } from './recipe-record.js';
32
+ import { factOf, factTexts, readProjectFacts } from './recipe-facts.js';
33
+ import { namesFirstAdmin, readAppliedRecipes, readRecordedChoices, TELL_USER_RELAY, tellUserLines, } from './recipe-record.js';
29
34
  import * as server from './server.js';
35
+ import { probeUserStores } from './site-auth.js';
36
+ import { runTests, TEST_TIMEOUT_MS } from './verify-tests.js';
37
+ /**
38
+ * D-0105 §4: with @dokebi/db installed, what the agent's final message must contain besides
39
+ * tellUser. The guidance asks for it, and an AI consumer that read it many times still left it
40
+ * out (AI-M23, AI-M24): verify ok repeats it at the moment the agent is about to report.
41
+ */
42
+ export function finalReportLines(ok, installed, allowed = []) {
43
+ if (!ok)
44
+ return [];
45
+ return [
46
+ ...(installed.has('dokebi-db') ? [DB_PRACTICE_REPORT] : []),
47
+ ...(allowed.length ? [allowedReport(allowed)] : []),
48
+ ];
49
+ }
50
+ /** The opening of the silenced-rules report and the words before its list of markers. */
51
+ const SILENCED = 'Silenced check rules: ';
52
+ const LISTED = 'why the rule does not apply there: ';
53
+ /**
54
+ * R1 measurement: a builder printed only `{ ok, checks, tests, tellUser }` of the verify JSON and
55
+ * never saw `finalReport`, so its final message lacked the section 6 table. The tellUser lines,
56
+ * which every builder relays, end with what finalReport asks for (the field stays as it is).
57
+ */
58
+ export function tellUserWithReport(tellUser, finalReport, dbRules = []) {
59
+ if (!tellUser.length || !finalReport.length)
60
+ return [...tellUser];
61
+ // R1 T5 review: the lines are relayed verbatim (relay), so each is said to the user: a heading
62
+ // the builder's table or list follows, never an instruction to the builder.
63
+ // R3 Codex T2: Codex reads only ok, checks and tellUser of the result (never relay or
64
+ // finalReport) and put the table in a file for the fifth time; the heading itself says where
65
+ // the table is, in words the user reads as a plain statement.
66
+ return [
67
+ ...tellUser,
68
+ ...finalReport.map((line) => line === DB_PRACTICE_REPORT
69
+ ? `Database practice (@dokebi/db AI.md section 6): the table below, here in this message, has one row per rule of that section (${dbRules.length ? dbRules.join('; ') : 'each by its number, 6.1 onwards'}), with the queries or jobs it applies to, the measurement (rows read, rate, refusals) and the verdict (pass, or the fix you made).`
70
+ : line.startsWith(SILENCED)
71
+ ? `Silenced check rules (dokebi-check allow markers), each followed below, here in this message, by why the rule does not apply there: ${line.slice(line.indexOf(LISTED) + LISTED.length).replace(/\. A marker on work[\s\S]*$/, '')}.`
72
+ : line),
73
+ ];
74
+ }
75
+ /**
76
+ * R6 Codex T3: the heading said "one row per rule" and the builder, which reads only tellUser of
77
+ * the result and had read part of section 6, wrote a table of twelve rules of its own. The
78
+ * heading names the rules of the installed @dokebi/db AI.md (`### 6.N Title`), so each row has
79
+ * one to answer. No installed AI.md: none.
80
+ */
81
+ export async function readDbPracticeRules(root) {
82
+ let text;
83
+ try {
84
+ text = await readFile(join(root, 'node_modules', '@dokebi', 'db', 'AI.md'), 'utf8');
85
+ }
86
+ catch (error) {
87
+ if (!isMissing(error))
88
+ throw error;
89
+ debug('verify: no @dokebi/db AI.md for the section 6 rules', { root });
90
+ return [];
91
+ }
92
+ return [...text.matchAll(/^### (6\.\d+ .+)$/gm)].map((match) => (match[1] ?? '').trim());
93
+ }
94
+ /**
95
+ * M34: a builder silenced background-timer on a database polling loop and check passed. verify ok
96
+ * asks the final message to list every silenced problem with its reason, so the person reading it
97
+ * sees each one (the markers stay legitimate for per-process upkeep).
98
+ */
99
+ export function allowedReport(allowed) {
100
+ const listed = allowed
101
+ .slice(0, 20)
102
+ .map((marker) => `${marker.file}:${marker.line} ${marker.rule} (${marker.reason})`)
103
+ .join('; ');
104
+ const more = allowed.length > 20 ? `; and ${allowed.length - 20} more (npx dokebi check)` : '';
105
+ return `${SILENCED}your final message lists every dokebi-check allow marker of the project with its reason and ${LISTED}${listed}${more}. A marker on work that reads or writes the database (a polling or folding loop) is not a valid reason: move that work to a job or an outbox handler.`;
106
+ }
107
+ /**
108
+ * R2 Codex T2, T4, T5 (and R1 T1, T2): the builder relayed the headings verbatim, "the table below
109
+ * …", and put the table in a results file; it reads relay and tellUser, not finalReport. With
110
+ * finalReport lines, relay itself says that each heading's table or list follows it in the message.
111
+ * R4 Claude T4 copied the other lines and wrote its own headings for these two (`## Database
112
+ * practice (…)`): relay called them "a heading". They are lines to copy like the others.
113
+ */
114
+ export const REPORT_RELAY = Object.freeze({
115
+ tellUser: 'verbatim',
116
+ instruction: `${TELL_USER_RELAY.instruction} The lines that start with Database practice and Silenced check rules are copied word for word too (they are lines of this list, not headings to write in your own words), and right under each of them you write the table or list it announces, in the same final message, as finalReport says; a results file or a link to one does not replace it.`,
117
+ });
118
+ export const DB_PRACTICE_REPORT = 'Database practice (@dokebi/db is installed): your final message contains a table with one row per rule of section 6 of node_modules/@dokebi/db/AI.md: the rule, the queries or jobs it applies to, the measurement (rows read, rate, refusals) and the verdict (pass, or the fix you made). A rule you did not measure is not done; a separate results file does not replace the table in the message.';
119
+ /**
120
+ * The preflight problem for a taken web port (AI-M04 F5), with what the recipe preflight gives:
121
+ * a free `alternative`, `waitForUser`, `question` and `next`. Who holds the port is not looked up
122
+ * (no `ps`); verify only knows it is not an app `dokebi start` runs for this project (that is
123
+ * ALREADY_RUNNING). `delegated`: the last recipe ran with --defaults (`.dokebi/recipe.json`).
124
+ */
125
+ export async function webPortProblem(port, delegated) {
126
+ const alternative = await freePortAfter(port);
127
+ debug('verify: web port in use', { port, alternative, delegated });
128
+ return {
129
+ code: 'PORT_IN_USE',
130
+ message: `Port ${port} (options.dokebi-web.port) is in use by another program (not an app npx dokebi start runs for this project; maybe its npx dokebi dev, another project or another program)`,
131
+ fix: `ask the user, then npx dokebi config set dokebi-web.port=${alternative ?? '<free port>'} and verify again, or stop what uses ${port}`,
132
+ ...(alternative ? { alternative } : {}),
133
+ ...portQuestion({ what: 'Web', port, alternative, flag: 'dokebi-web.port', delegated }),
134
+ };
135
+ }
30
136
  /**
31
137
  * Runs an npm script of the project; resolves with its exit code and the bounded, redacted end
32
- * of its output (stdout and stderr in arrival order, `OutputTail` of npm-client).
138
+ * of its output (stdout and stderr in arrival order, `OutputTail` of npm-client). The script runs
139
+ * in its own process group and the time limit kills the whole group: killing npm alone left tsc
140
+ * or Vite running, and the pipes they hold kept the step open until they ended by themselves.
33
141
  */
34
142
  export async function npmScript(root, script, timeoutMs) {
35
143
  const npmPath = process.env.npm_execpath;
@@ -37,7 +145,12 @@ export async function npmScript(root, script, timeoutMs) {
37
145
  const args = [...(npmPath?.endsWith('npm-cli.js') ? [npmPath] : []), 'run', script];
38
146
  debug('verify: running npm script', { script, root });
39
147
  return new Promise((resolve, reject) => {
40
- const child = spawn(command, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'] });
148
+ const child = spawn(command, args, {
149
+ cwd: root,
150
+ stdio: ['ignore', 'pipe', 'pipe'],
151
+ detached: PROCESS_GROUPS,
152
+ });
153
+ const forwarding = forwardTermination(child.pid);
41
154
  const output = new OutputTail();
42
155
  const keep = (chunk) => output.push(chunk);
43
156
  child.stdout.on('data', keep);
@@ -45,14 +158,17 @@ export async function npmScript(root, script, timeoutMs) {
45
158
  let timedOut = false;
46
159
  const timer = setTimeout(() => {
47
160
  timedOut = true;
48
- child.kill('SIGKILL');
161
+ debug('verify: the npm script reached its time limit; killing it', { script, timeoutMs });
162
+ killGroup(child.pid, 'SIGKILL');
49
163
  }, timeoutMs);
50
164
  child.once('error', (cause) => {
51
165
  clearTimeout(timer);
166
+ forwarding();
52
167
  reject(new CliError('NPM_FAILED', `Could not run npm run ${script}`, { cause }));
53
168
  });
54
169
  child.once('close', (code) => {
55
170
  clearTimeout(timer);
171
+ forwarding();
56
172
  resolve({ code, output: output.text(), timedOut });
57
173
  });
58
174
  });
@@ -114,16 +230,45 @@ function get(url, host, timeoutMs = 5000) {
114
230
  req.end();
115
231
  });
116
232
  }
233
+ /**
234
+ * A file verify needs before any check runs. A missing one is NOT_A_PROJECT with the file and a
235
+ * fix, never an INTERNAL_ERROR (a project copy without .dokebi/state.json, M32).
236
+ */
237
+ async function projectFile(root, file) {
238
+ try {
239
+ return await readFile(join(root, file), 'utf8');
240
+ }
241
+ catch (error) {
242
+ if (!isMissing(error))
243
+ throw new CliError('READ_FAILED', `Cannot read ${file}`, { cause: error });
244
+ throw new CliError('NOT_A_PROJECT', `No ${file}: verify runs in a Dokebi project (the folder with package.json, dokebi.yaml and .dokebi/state.json)`, {
245
+ phase: 'arguments',
246
+ details: {
247
+ root,
248
+ file,
249
+ fix: file === '.dokebi/state.json'
250
+ ? 'run verify in the project folder itself; .dokebi/state.json is written by the CLI and belongs in version control, so a copy needs it too (copy it from the project or check it out from git)'
251
+ : file === CONFIG_FILE
252
+ ? `run verify in the project folder; ${FRESH_COPY_FIX}`
253
+ : 'run verify in the project folder (npx dokebi verify from the folder with dokebi.yaml)',
254
+ },
255
+ });
256
+ }
257
+ }
117
258
  async function settings(root) {
118
- const pkg = parseObject(await readFile(join(root, 'package.json'), 'utf8'), 'package.json');
259
+ const pkg = parseObject(await projectFile(root, 'package.json'), 'package.json');
119
260
  let vite = false;
120
261
  for (const file of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'])
121
262
  vite ||= await exists(await securePath(root, file));
122
- const state = readState(parseObject(await readFile(join(root, '.dokebi/state.json'), 'utf8'), 'state'));
263
+ const state = readState(parseObject(await projectFile(root, '.dokebi/state.json'), 'state'));
123
264
  const ids = new Set(state.plugins.map((plugin) => plugin.id));
124
265
  // The domains to check belong to the plugin that hosts apps (D-0070), whatever its id.
125
266
  const host = findHost(state.plugins, 'apps');
126
- const options = parseProjectConfig(await readFile(join(root, CONFIG_FILE), 'utf8')).options;
267
+ // M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml).
268
+ const target = await configTarget(root);
269
+ const options = parseProjectConfig(target.fromEnvironment
270
+ ? await readNamedSettings(root, target)
271
+ : await projectFile(root, CONFIG_FILE)).options;
127
272
  const web = (options['dokebi-web'] ?? {});
128
273
  const app = ((host ? options[host.id] : undefined) ?? {});
129
274
  const db = (options['dokebi-db'] ?? {});
@@ -156,6 +301,8 @@ async function settings(root) {
156
301
  * With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
157
302
  * runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
158
303
  * status below 500 that carries the token, a 404 included (the app may have no route at `/`).
304
+ * Domain probes also accept 401/403 from this instance, reported as accessDenied: true.
305
+ * These prove reachability only; readiness still requires 2xx/3xx and login needs app tests.
159
306
  * Each request must complete within `timeoutMs` (ISSUE-0006 A09).
160
307
  */
161
308
  export async function httpChecks(base, paths, token, delayMs = 250, timeoutMs = 5000) {
@@ -167,12 +314,15 @@ export async function httpChecks(base, paths, token, delayMs = 250, timeoutMs =
167
314
  try {
168
315
  const answer = await get(url, host, timeoutMs);
169
316
  const instance = answer.instance === token ? 'ours' : 'other';
170
- const ok = answer.status >= 200 && answer.status < (identity ? 500 : 400) && instance === 'ours';
317
+ const accessDenied = host !== undefined && (answer.status === 401 || answer.status === 403);
318
+ const ok = ((answer.status >= 200 && answer.status < (identity ? 500 : 400)) || accessDenied) &&
319
+ instance === 'ours';
171
320
  if (ok || attempt >= attempts) {
172
321
  requests.push({
173
322
  url,
174
323
  ...(host ? { host } : {}),
175
324
  status: answer.status,
325
+ ...(accessDenied ? { accessDenied: true } : {}),
176
326
  ok,
177
327
  instance,
178
328
  ...kind,
@@ -230,6 +380,38 @@ export async function httpChecks(base, paths, token, delayMs = 250, timeoutMs =
230
380
  details: { requests },
231
381
  };
232
382
  }
383
+ const TERMINATING = ['SIGINT', 'SIGTERM', 'SIGHUP'];
384
+ /**
385
+ * While the app verify started runs: a SIGINT, SIGTERM or SIGHUP to verify (Ctrl+C, an agent's
386
+ * command time limit) stops that app first (stopStarted, forced after `limitMs`), then ends verify
387
+ * as the signal would have. Without it the background app outlived verify and held its port; the
388
+ * next verify then refused with ALREADY_RUNNING. Returns the function that removes the handlers.
389
+ */
390
+ export function stopOnTermination(root, started, limitMs = 5000) {
391
+ const handlers = new Map();
392
+ const remove = () => {
393
+ for (const [signal, handler] of handlers)
394
+ process.off(signal, handler);
395
+ handlers.clear();
396
+ };
397
+ for (const signal of TERMINATING) {
398
+ const handler = () => {
399
+ remove();
400
+ debug('verify: terminated; stopping the app it started', { signal, pid: started.pid });
401
+ server
402
+ .stopStarted(root, started, limitMs, true)
403
+ .catch((error) => reportError('VERIFY_STOP_FAILED', 'Unable to stop the app verify started', error))
404
+ .finally(() => {
405
+ // The signal's default action (ending this process) applies again.
406
+ if (process.listenerCount(signal) === 0)
407
+ process.kill(process.pid, signal);
408
+ });
409
+ };
410
+ handlers.set(signal, handler);
411
+ process.on(signal, handler);
412
+ }
413
+ return remove;
414
+ }
233
415
  export async function verify(root, options = {}) {
234
416
  const checks = [];
235
417
  const run = async (name, body) => {
@@ -257,6 +439,16 @@ export async function verify(root, options = {}) {
257
439
  };
258
440
  const skip = (reason) => async () => ({ ok: true, skipped: reason });
259
441
  const project = await settings(root);
442
+ // An unreadable .env fails the steps that need it (test, start) with its code.
443
+ let warnings = [];
444
+ try {
445
+ warnings = envFileWarnings(root).map(({ code, path, hint }) => ({ code, path, hint }));
446
+ }
447
+ catch (error) {
448
+ if (!(error instanceof CliError))
449
+ throw error;
450
+ debug('verify: .env not read for its warnings', { code: error.code });
451
+ }
260
452
  let inspection;
261
453
  const doctor = await run('doctor', async () => {
262
454
  const result = await new ProjectInspector().inspect(root, { runChecks: true });
@@ -272,8 +464,10 @@ export async function verify(root, options = {}) {
272
464
  };
273
465
  });
274
466
  // D-0072: user-code conventions, on doctor's inspection (doctor is not run twice).
467
+ let allowed = [];
275
468
  await run('check', async () => {
276
469
  const result = await check(root, inspection ? { inspection } : {});
470
+ allowed = result.allowed;
277
471
  return {
278
472
  ok: result.ok,
279
473
  ...(result.ok
@@ -282,7 +476,12 @@ export async function verify(root, options = {}) {
282
476
  code: 'CHECK_FAILED',
283
477
  message: `${result.problems.length} convention problem(s): npx dokebi check`,
284
478
  }),
285
- details: { problems: result.problems, files: result.files, ms: result.ms },
479
+ details: {
480
+ problems: result.problems,
481
+ allowed: result.allowed,
482
+ files: result.files,
483
+ ms: result.ms,
484
+ },
286
485
  };
287
486
  });
288
487
  const script = async (name, timeoutMs) => {
@@ -305,6 +504,17 @@ export async function verify(root, options = {}) {
305
504
  await run('build', typeof project.scripts.build === 'string' && project.vite
306
505
  ? () => script('build', 300_000)
307
506
  : skip('no Vite build in this project'));
507
+ // D-0077 H: the project's tests, after the build (they may need it) and before the preflight
508
+ // and the start (never next to the app: both may want the same port).
509
+ let tests;
510
+ await run('test', async () => {
511
+ const step = await runTests(root, project.scripts, {
512
+ timeoutMs: options.testTimeoutMs ?? TEST_TIMEOUT_MS,
513
+ });
514
+ const { tests: report, ...check } = step;
515
+ tests = report;
516
+ return check;
517
+ });
308
518
  const preflight = await run('preflight', async () => {
309
519
  const problems = [];
310
520
  const running = await server.status(root);
@@ -315,11 +525,7 @@ export async function verify(root, options = {}) {
315
525
  fix: 'npx dokebi stop',
316
526
  });
317
527
  else if (project.web.installed && project.web.port > 0 && (await portInUse(project.web.port)))
318
- problems.push({
319
- code: 'PORT_IN_USE',
320
- message: `Port ${project.web.port} (options.dokebi-web.port) is in use`,
321
- fix: 'stop what uses it, or npx dokebi config set dokebi-web.port=<free port>',
322
- });
528
+ problems.push(await webPortProblem(project.web.port, (await readAppliedRecipes(root)).at(-1)?.answersFrom === 'defaults'));
323
529
  for (const db of project.dbUrls)
324
530
  if (db.missing)
325
531
  problems.push({
@@ -337,6 +543,7 @@ export async function verify(root, options = {}) {
337
543
  : { ok: true };
338
544
  });
339
545
  let started;
546
+ let release;
340
547
  // Only the instance started here echoes this token (x-dokebi-verify, @dokebi/web).
341
548
  const token = randomBytes(16).toString('hex');
342
549
  const reason = !doctor.ok ? 'doctor failed' : !preflight.ok ? 'preflight failed' : undefined;
@@ -347,6 +554,7 @@ export async function verify(root, options = {}) {
347
554
  waitMs: options.waitMs ?? 30_000,
348
555
  env: { [VERIFY_TOKEN_ENV]: token },
349
556
  });
557
+ release = stopOnTermination(root, started);
350
558
  return {
351
559
  ok: started.ready,
352
560
  ...(started.ready ? {} : { code: 'NOT_READY', message: started.warnings.join('; ') }),
@@ -413,15 +621,58 @@ export async function verify(root, options = {}) {
413
621
  };
414
622
  }
415
623
  : skip('nothing was started'));
624
+ release?.();
416
625
  }
417
626
  const ok = checks.every((check) => check.ok);
418
- const tellUser = ok ? tellUserLines(await readAppliedRecipes(root)) : [];
627
+ const asked = (preflight.details?.problems ?? []).find((item) => item.question);
419
628
  const shown = started ? listen : [];
629
+ const urls = shown.length ? appUrls(shown, await server.appHostsOrNone(root)) : [];
630
+ // The notices name the app's address as it is now (AI-M05: the port may have changed since),
631
+ // the first admin as dokebi.yaml and the seed configure it now (D-0115), and every fact they
632
+ // state as the project has it now (D-0137): the app verify started was ready, so each job
633
+ // queue it configures was bound.
634
+ const recipes = ok ? await readAppliedRecipes(root) : [];
635
+ let firstAdmin;
636
+ const readAdmin = () => {
637
+ firstAdmin ??= readFirstAdmin(root);
638
+ return firstAdmin;
639
+ };
640
+ const facts = recipes.some((entry) => entry.notices.some((notice) => factOf(notice)))
641
+ ? await factTexts(recipes, await readProjectFacts(root, {
642
+ started: Boolean(started?.ready),
643
+ firstAdmin: readAdmin,
644
+ userStores: (realms) => probeUserStores(root, realms),
645
+ }))
646
+ : undefined;
647
+ const tellUser = ok
648
+ ? tellUserLines(recipes, {
649
+ urls,
650
+ ...(project.web.port > 0 ? { port: project.web.port } : {}),
651
+ },
652
+ // D-0098: the views of apps made outside a recipe, with who chose them.
653
+ await readRecordedChoices(root), recipes.some((entry) => entry.notices.some(namesFirstAdmin))
654
+ ? firstAdminPhrase(await readAdmin())
655
+ : undefined, facts)
656
+ : [];
657
+ const finalReport = finalReportLines(ok, new Set(inspection?.plugins.flatMap((item) => item.installed?.id ?? []) ?? []), allowed);
658
+ if (finalReport.length)
659
+ debug('verify: final report reminders', { lines: finalReport.length });
420
660
  return {
421
661
  ok,
422
662
  checks,
423
- urls: shown.length ? appUrls(shown, await server.appHostsOrNone(root)) : [],
663
+ urls,
424
664
  listen: shown,
425
- ...(tellUser.length ? { tellUser } : {}),
665
+ ...(tests ? { tests } : {}),
666
+ ...(tellUser.length
667
+ ? {
668
+ tellUser: tellUserWithReport(tellUser, finalReport, finalReport.includes(DB_PRACTICE_REPORT) ? await readDbPracticeRules(root) : []),
669
+ relay: finalReport.length ? REPORT_RELAY : TELL_USER_RELAY,
670
+ }
671
+ : {}),
672
+ ...(finalReport.length ? { finalReport } : {}),
673
+ ...(asked?.question
674
+ ? { waitForUser: asked.waitForUser ?? true, question: asked.question, next: asked.next ?? '' }
675
+ : {}),
676
+ ...(warnings.length ? { warnings } : {}),
426
677
  };
427
678
  }
@@ -0,0 +1,58 @@
1
+ import type { RecordedChoice } from './recipe-record.js';
2
+ /**
3
+ * The longest `--ai-reason` text, one line for the user like a catalog defaultReason: of a recipe
4
+ * answer (D-0095) and of the view `app add` records (D-0098). One rule, `aiReasonFits`, for both.
5
+ */
6
+ export declare const AI_REASON_MAX = 200;
7
+ /** The id `--ai-chose` and `--ai-reason` name on `app add`. */
8
+ export declare const VIEW_ID = "view";
9
+ /** The view answer of a project without browser screens (recipe.ts NO_SCREENS), and of `--view none`. */
10
+ export declare const NO_VIEW = "none";
11
+ /** What `app add --view` takes: EJS pages, or no views (a JSON API). */
12
+ export type AppView = 'ejs' | 'none';
13
+ /**
14
+ * The view the project's latest recipe recorded (`view` answer), if any: `none` for the api
15
+ * recipe, so `app add` makes an app without views there unless a flag says otherwise.
16
+ */
17
+ export declare function projectView(root: string): Promise<string | undefined>;
18
+ /** Who chose the view, as `app add` was told (`--answers-from`, `--ai-chose`, `--ai-reason`). */
19
+ export interface ViewSourceFlags {
20
+ readonly answersFrom?: 'user' | 'defaults';
21
+ readonly aiChose?: readonly string[];
22
+ readonly aiReasons?: Readonly<Record<string, string>>;
23
+ }
24
+ /** A trimmed `--ai-reason` text is one line of 1 to AI_REASON_MAX characters. */
25
+ export declare function aiReasonFits(reason: string): boolean;
26
+ /** The value of the view question for an app made with these flags. */
27
+ export declare function appViewValue(inertia: boolean, ssr: boolean, none?: boolean): string;
28
+ /**
29
+ * Checks the source flags of `app add` before anything changes: `--ai-chose` names only `view`,
30
+ * `--ai-reason` only an id of `--ai-chose`, and a reason is one line of 1 to 200 characters.
31
+ */
32
+ export declare function checkViewSource(flags: ViewSourceFlags): void;
33
+ /**
34
+ * The record of an app's view: `ai` with `--ai-chose view` (or `--answers-from defaults`),
35
+ * `user` with `--answers-from user`, `unstated` for an EJS app or one without views, without
36
+ * either (not the default, so the user hears about it); none for an Inertia app without either
37
+ * (the default), nor for an app without views the project's recipe implied (`inherited`: the
38
+ * recipe's own line already says who chose no screens).
39
+ */
40
+ export declare function appViewChoice(app: string, value: string, flags: ViewSourceFlags, now?: Date, inherited?: boolean): RecordedChoice | undefined;
41
+ /** The warning of an EJS app (or one without views) whose chooser was not stated (D-0098). */
42
+ export declare function unstatedViewWarning(app: string, value?: string): {
43
+ code: 'VIEW_SOURCE_UNSTATED';
44
+ path: string;
45
+ hint: string;
46
+ };
47
+ /**
48
+ * After `app add` (or `add` with the first app): records the app's view with its source and adds
49
+ * VIEW_SOURCE_UNSTATED to the result's warnings for an EJS app without a source. A dry run only
50
+ * warns. The result is the command's own; it is returned with the warning added.
51
+ */
52
+ export declare function recordAppView<T extends {
53
+ warnings?: object[];
54
+ appView?: {
55
+ value: string;
56
+ from: 'flags' | 'recipe';
57
+ };
58
+ }>(root: string, result: T, app: string, value: string, flags: ViewSourceFlags, dryRun: boolean): Promise<T>;
@@ -0,0 +1,131 @@
1
+ // D-0098: the view of an app is a product choice whose default is Inertia + Vue. Outside a recipe
2
+ // it is made by `dokebi app add <name>` (EJS unless --inertia or --view none; none when the
3
+ // project's recipe built no browser screens, the api recipe) and by the first app of
4
+ // `dokebi add <apps host>` (EJS). These commands record it in `.dokebi/recipe.json` (`choices`)
5
+ // with who made it, the same way D-0095 records recipe answers, so verify's tellUser says it.
6
+ import { debug } from './diagnostics.js';
7
+ import { CliError } from './errors.js';
8
+ /**
9
+ * The longest `--ai-reason` text, one line for the user like a catalog defaultReason: of a recipe
10
+ * answer (D-0095) and of the view `app add` records (D-0098). One rule, `aiReasonFits`, for both.
11
+ */
12
+ export const AI_REASON_MAX = 200;
13
+ /** The id `--ai-chose` and `--ai-reason` name on `app add`. */
14
+ export const VIEW_ID = 'view';
15
+ /** The view answer of a project without browser screens (recipe.ts NO_SCREENS), and of `--view none`. */
16
+ export const NO_VIEW = 'none';
17
+ /**
18
+ * The view the project's latest recipe recorded (`view` answer), if any: `none` for the api
19
+ * recipe, so `app add` makes an app without views there unless a flag says otherwise.
20
+ */
21
+ export async function projectView(root) {
22
+ const { readAppliedRecipes } = await import('./recipe-record.js');
23
+ const recipes = await readAppliedRecipes(root);
24
+ for (const recipe of [...recipes].reverse()) {
25
+ const value = recipe.answers[VIEW_ID]?.value;
26
+ if (value !== undefined)
27
+ return value;
28
+ }
29
+ return undefined;
30
+ }
31
+ /** A trimmed `--ai-reason` text is one line of 1 to AI_REASON_MAX characters. */
32
+ export function aiReasonFits(reason) {
33
+ return reason.length > 0 && reason.length <= AI_REASON_MAX && !/[\r\n]/.test(reason);
34
+ }
35
+ /** The value of the view question for an app made with these flags. */
36
+ export function appViewValue(inertia, ssr, none = false) {
37
+ return inertia ? (ssr ? 'inertia-vue-ssr' : 'inertia-vue') : none ? NO_VIEW : 'ejs';
38
+ }
39
+ /**
40
+ * Checks the source flags of `app add` before anything changes: `--ai-chose` names only `view`,
41
+ * `--ai-reason` only an id of `--ai-chose`, and a reason is one line of 1 to 200 characters.
42
+ */
43
+ export function checkViewSource(flags) {
44
+ for (const id of flags.aiChose ?? [])
45
+ if (id !== VIEW_ID)
46
+ throw new CliError('AI_CHOICE_UNKNOWN', `app add records only the view, not ${id}`, {
47
+ details: { aiChose: id, questions: [VIEW_ID], fix: '--ai-chose view' },
48
+ phase: 'arguments',
49
+ });
50
+ for (const [id, text] of Object.entries(flags.aiReasons ?? {})) {
51
+ if (!(flags.aiChose ?? []).includes(id))
52
+ throw new CliError('AI_REASON_WITHOUT_CHOICE', `--ai-reason ${id} is given, but ${id} is not in --ai-chose`, {
53
+ details: {
54
+ question: id,
55
+ aiChose: [...(flags.aiChose ?? [])],
56
+ fix: 'add --ai-chose view if you chose the view because the user left it to you',
57
+ },
58
+ phase: 'arguments',
59
+ });
60
+ const reason = text.trim();
61
+ if (!aiReasonFits(reason))
62
+ throw new CliError('INVALID_AI_REASON', `--ai-reason ${id} must be one line of 1 to ${AI_REASON_MAX} characters`, {
63
+ details: {
64
+ question: id,
65
+ length: reason.length,
66
+ fix: 'give one short line the user reads: why you chose this view',
67
+ },
68
+ phase: 'arguments',
69
+ });
70
+ }
71
+ }
72
+ /**
73
+ * The record of an app's view: `ai` with `--ai-chose view` (or `--answers-from defaults`),
74
+ * `user` with `--answers-from user`, `unstated` for an EJS app or one without views, without
75
+ * either (not the default, so the user hears about it); none for an Inertia app without either
76
+ * (the default), nor for an app without views the project's recipe implied (`inherited`: the
77
+ * recipe's own line already says who chose no screens).
78
+ */
79
+ export function appViewChoice(app, value, flags, now = new Date(), inherited = false) {
80
+ const recordedAt = now.toISOString();
81
+ if ((flags.aiChose ?? []).includes(VIEW_ID) || flags.answersFrom === 'defaults') {
82
+ const reason = flags.aiReasons?.[VIEW_ID]?.trim();
83
+ return {
84
+ app,
85
+ id: VIEW_ID,
86
+ value,
87
+ answeredBy: 'ai',
88
+ ...(reason ? { aiReason: reason } : {}),
89
+ recordedAt,
90
+ };
91
+ }
92
+ if (flags.answersFrom === 'user')
93
+ return { app, id: VIEW_ID, value, answeredBy: 'user', recordedAt };
94
+ if (inherited) {
95
+ debug('app view: no views, as the project recipe built no screens', { app, value });
96
+ return undefined;
97
+ }
98
+ if (value === 'ejs' || value === NO_VIEW) {
99
+ debug('app view: a view other than the default without a stated source', { app, value });
100
+ return { app, id: VIEW_ID, value, answeredBy: 'unstated', recordedAt };
101
+ }
102
+ return undefined;
103
+ }
104
+ /** The warning of an EJS app (or one without views) whose chooser was not stated (D-0098). */
105
+ export function unstatedViewWarning(app, value = 'ejs') {
106
+ const what = value === NO_VIEW ? 'an app without views (--view none)' : 'EJS';
107
+ return {
108
+ code: 'VIEW_SOURCE_UNSTATED',
109
+ path: `src/apps/${app}`,
110
+ hint: `The app ${app} is ${what}, which is not the default view (Inertia + Vue, npx dokebi app add <name> --inertia). verify's tellUser tells the user that nobody recorded who chose it. Next time say it: --answers-from user when the user or the request chose it, --ai-chose view --ai-reason view=<why> when the user left the view to you.`,
111
+ };
112
+ }
113
+ /**
114
+ * After `app add` (or `add` with the first app): records the app's view with its source and adds
115
+ * VIEW_SOURCE_UNSTATED to the result's warnings for an EJS app without a source. A dry run only
116
+ * warns. The result is the command's own; it is returned with the warning added.
117
+ */
118
+ export async function recordAppView(root, result, app, value, flags, dryRun) {
119
+ // app add says what the app really got (an app without views in an api project).
120
+ const actual = result.appView?.value ?? value;
121
+ const choice = appViewChoice(app, actual, flags, new Date(), result.appView?.from === 'recipe');
122
+ if (!choice)
123
+ return result;
124
+ if (!dryRun) {
125
+ const { saveRecordedChoice } = await import('./recipe-record.js');
126
+ await saveRecordedChoice(root, choice);
127
+ }
128
+ if (choice.answeredBy !== 'unstated')
129
+ return result;
130
+ return { ...result, warnings: [...(result.warnings ?? []), unstatedViewWarning(app, actual)] };
131
+ }
@@ -1,6 +1,8 @@
1
1
  // Run by `dokebi dev` (D-0044) for one Vite config: a watch build that never empties its outDir.
2
- // The browser build's `emptyOutDir: true` would otherwise delete dist/ssr/ on every rebuild,
3
- // which is fine for `npm run build` (one build after the other) but not for two watchers.
2
+ // The templates of @dokebi/app write `emptyOutDir: false` (a rolling deploy keeps the previous
3
+ // chunks), but a vite.config of a project created before them still says `true`: the browser
4
+ // build (outDir dist/) would then delete dist/ssr/ on every rebuild, which `npm run build` (one
5
+ // build after the other) survives but two watchers do not. So dev overrides it for every config.
4
6
  // Every finished build is reported to dev over the IPC channel ({ built: true } or
5
7
  // { failed: true }); output goes to the terminal as usual.
6
8
  import { join } from 'node:path';