@dokebi/cli 0.4.1 → 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 +527 -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 +29 -3
  110. package/dist/install-module.js +142 -13
  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 +106 -27
  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 +59 -4
  124. package/dist/npm-client.js +220 -16
  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 +206 -20
  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 +3016 -336
  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 +50 -4
  170. package/dist/server.js +790 -103
  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 +67 -0
  180. package/dist/transaction.js +428 -46
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +113 -4
  184. package/dist/verify.js +326 -28
  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
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
  });
@@ -61,10 +177,31 @@ export async function npmScript(root, script, timeoutMs) {
61
177
  export const VERIFY_TOKEN_ENV = 'DOKEBI_VERIFY_TOKEN';
62
178
  /** The response header @dokebi/web answers with when that variable is set. */
63
179
  export const VERIFY_HEADER = 'x-dokebi-verify';
64
- /** GET with a Host header (fetch cannot set one). */
180
+ /**
181
+ * GET with a Host header (fetch cannot set one). Settles once: with the answer when the body
182
+ * ended, or with an error when the request fails, the response is aborted or its connection
183
+ * closes before the end (ISSUE-0006 A09), or nothing complete came within `timeoutMs` in all
184
+ * (the socket timeout alone never fires for a server that keeps trickling bytes).
185
+ */
65
186
  function get(url, host, timeoutMs = 5000) {
66
187
  return new Promise((resolve, reject) => {
67
188
  const target = new URL(url);
189
+ let settled = false;
190
+ const finish = (error, answer) => {
191
+ if (settled)
192
+ return;
193
+ settled = true;
194
+ clearTimeout(deadline);
195
+ if (error) {
196
+ // Frees the socket of a response that will never complete.
197
+ if (!req.destroyed)
198
+ req.destroy();
199
+ reject(error);
200
+ }
201
+ else
202
+ resolve(answer);
203
+ };
204
+ const failed = (error) => finish(error);
68
205
  const req = request({
69
206
  host: target.hostname.replace(/^\[|\]$/g, ''),
70
207
  port: target.port,
@@ -73,27 +210,65 @@ function get(url, host, timeoutMs = 5000) {
73
210
  timeout: timeoutMs,
74
211
  }, (response) => {
75
212
  response.resume();
76
- response.once('end', () => resolve({
213
+ response.once('end', () => finish(undefined, {
77
214
  status: response.statusCode ?? 0,
78
215
  type: String(response.headers['content-type'] ?? ''),
79
216
  instance: response.headers[VERIFY_HEADER],
80
217
  }));
218
+ response.once('aborted', () => finish(new Error('The response was aborted')));
219
+ // Error listeners stay after settling: a late error (the destroy below) is then ignored
220
+ // by `finish` instead of being an unhandled 'error' event.
221
+ response.on('error', failed);
222
+ response.once('close', () => {
223
+ if (!response.complete)
224
+ finish(new Error('The connection closed before the response ended'));
225
+ });
81
226
  });
227
+ const deadline = setTimeout(() => finish(new Error(`No complete answer within ${timeoutMs} ms`)), timeoutMs);
82
228
  req.once('timeout', () => req.destroy(new Error(`No answer within ${timeoutMs} ms`)));
83
- req.once('error', reject);
229
+ req.on('error', failed);
84
230
  req.end();
85
231
  });
86
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
+ }
87
258
  async function settings(root) {
88
- const pkg = parseObject(await readFile(join(root, 'package.json'), 'utf8'), 'package.json');
259
+ const pkg = parseObject(await projectFile(root, 'package.json'), 'package.json');
89
260
  let vite = false;
90
261
  for (const file of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'])
91
262
  vite ||= await exists(await securePath(root, file));
92
- 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'));
93
264
  const ids = new Set(state.plugins.map((plugin) => plugin.id));
94
265
  // The domains to check belong to the plugin that hosts apps (D-0070), whatever its id.
95
266
  const host = findHost(state.plugins, 'apps');
96
- 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;
97
272
  const web = (options['dokebi-web'] ?? {});
98
273
  const app = ((host ? options[host.id] : undefined) ?? {});
99
274
  const db = (options['dokebi-db'] ?? {});
@@ -123,18 +298,35 @@ async function settings(root) {
123
298
  * The HTTP check of verify: GET the readiness path, then `/` of each app domain, at `base`. An
124
299
  * answer counts only when it carries `token` in the x-dokebi-verify header (the instance verify
125
300
  * started); anything else is retried like a failed request and then reported FOREIGN_SERVER.
301
+ * With neither a readiness path (`healthRoutes: false`) nor an app domain, one identity probe
302
+ * runs instead (ISSUE-0006 A08: no request at all used to pass): `GET /`, which passes with any
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.
306
+ * Each request must complete within `timeoutMs` (ISSUE-0006 A09).
126
307
  */
127
- export async function httpChecks(base, paths, token, delayMs = 250) {
308
+ export async function httpChecks(base, paths, token, delayMs = 250, timeoutMs = 5000) {
128
309
  const requests = [];
129
- const probe = async (path, host, attempts) => {
310
+ const probe = async (path, host, attempts, identity = false) => {
130
311
  const url = `${base}${path}`;
312
+ const kind = identity ? { identity: true } : {};
131
313
  for (let attempt = 1;; attempt++) {
132
314
  try {
133
- const answer = await get(url, host);
315
+ const answer = await get(url, host, timeoutMs);
134
316
  const instance = answer.instance === token ? 'ours' : 'other';
135
- const ok = answer.status >= 200 && answer.status < 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';
136
320
  if (ok || attempt >= attempts) {
137
- requests.push({ url, ...(host ? { host } : {}), status: answer.status, ok, instance });
321
+ requests.push({
322
+ url,
323
+ ...(host ? { host } : {}),
324
+ status: answer.status,
325
+ ...(accessDenied ? { accessDenied: true } : {}),
326
+ ok,
327
+ instance,
328
+ ...kind,
329
+ });
138
330
  return;
139
331
  }
140
332
  if (instance === 'other')
@@ -151,6 +343,7 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
151
343
  status: 0,
152
344
  ok: false,
153
345
  error: error instanceof Error ? error.message : String(error),
346
+ ...kind,
154
347
  });
155
348
  return;
156
349
  }
@@ -163,6 +356,10 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
163
356
  await probe(paths.ready, undefined, 20);
164
357
  for (const domain of paths.domains)
165
358
  await probe('/', domain, 4);
359
+ if (!paths.ready && !paths.domains.length) {
360
+ debug('verify: no readiness route and no app domain; probing GET / for this run', { base });
361
+ await probe('/', undefined, 20, true);
362
+ }
166
363
  const failed = requests.filter((item) => !item.ok);
167
364
  const foreign = failed.filter((item) => item.instance === 'other');
168
365
  return {
@@ -183,6 +380,38 @@ export async function httpChecks(base, paths, token, delayMs = 250) {
183
380
  details: { requests },
184
381
  };
185
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
+ }
186
415
  export async function verify(root, options = {}) {
187
416
  const checks = [];
188
417
  const run = async (name, body) => {
@@ -210,6 +439,16 @@ export async function verify(root, options = {}) {
210
439
  };
211
440
  const skip = (reason) => async () => ({ ok: true, skipped: reason });
212
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
+ }
213
452
  let inspection;
214
453
  const doctor = await run('doctor', async () => {
215
454
  const result = await new ProjectInspector().inspect(root, { runChecks: true });
@@ -225,8 +464,10 @@ export async function verify(root, options = {}) {
225
464
  };
226
465
  });
227
466
  // D-0072: user-code conventions, on doctor's inspection (doctor is not run twice).
467
+ let allowed = [];
228
468
  await run('check', async () => {
229
469
  const result = await check(root, inspection ? { inspection } : {});
470
+ allowed = result.allowed;
230
471
  return {
231
472
  ok: result.ok,
232
473
  ...(result.ok
@@ -235,7 +476,12 @@ export async function verify(root, options = {}) {
235
476
  code: 'CHECK_FAILED',
236
477
  message: `${result.problems.length} convention problem(s): npx dokebi check`,
237
478
  }),
238
- 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
+ },
239
485
  };
240
486
  });
241
487
  const script = async (name, timeoutMs) => {
@@ -258,6 +504,17 @@ export async function verify(root, options = {}) {
258
504
  await run('build', typeof project.scripts.build === 'string' && project.vite
259
505
  ? () => script('build', 300_000)
260
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
+ });
261
518
  const preflight = await run('preflight', async () => {
262
519
  const problems = [];
263
520
  const running = await server.status(root);
@@ -268,11 +525,7 @@ export async function verify(root, options = {}) {
268
525
  fix: 'npx dokebi stop',
269
526
  });
270
527
  else if (project.web.installed && project.web.port > 0 && (await portInUse(project.web.port)))
271
- problems.push({
272
- code: 'PORT_IN_USE',
273
- message: `Port ${project.web.port} (options.dokebi-web.port) is in use`,
274
- fix: 'stop what uses it, or npx dokebi config set dokebi-web.port=<free port>',
275
- });
528
+ problems.push(await webPortProblem(project.web.port, (await readAppliedRecipes(root)).at(-1)?.answersFrom === 'defaults'));
276
529
  for (const db of project.dbUrls)
277
530
  if (db.missing)
278
531
  problems.push({
@@ -290,6 +543,7 @@ export async function verify(root, options = {}) {
290
543
  : { ok: true };
291
544
  });
292
545
  let started;
546
+ let release;
293
547
  // Only the instance started here echoes this token (x-dokebi-verify, @dokebi/web).
294
548
  const token = randomBytes(16).toString('hex');
295
549
  const reason = !doctor.ok ? 'doctor failed' : !preflight.ok ? 'preflight failed' : undefined;
@@ -300,6 +554,7 @@ export async function verify(root, options = {}) {
300
554
  waitMs: options.waitMs ?? 30_000,
301
555
  env: { [VERIFY_TOKEN_ENV]: token },
302
556
  });
557
+ release = stopOnTermination(root, started);
303
558
  return {
304
559
  ok: started.ready,
305
560
  ...(started.ready ? {} : { code: 'NOT_READY', message: started.warnings.join('; ') }),
@@ -366,15 +621,58 @@ export async function verify(root, options = {}) {
366
621
  };
367
622
  }
368
623
  : skip('nothing was started'));
624
+ release?.();
369
625
  }
370
626
  const ok = checks.every((check) => check.ok);
371
- const tellUser = ok ? tellUserLines(await readAppliedRecipes(root)) : [];
627
+ const asked = (preflight.details?.problems ?? []).find((item) => item.question);
372
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 });
373
660
  return {
374
661
  ok,
375
662
  checks,
376
- urls: shown.length ? appUrls(shown, await server.appHostsOrNone(root)) : [],
663
+ urls,
377
664
  listen: shown,
378
- ...(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 } : {}),
379
677
  };
380
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>;