@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
@@ -0,0 +1,285 @@
1
+ // Template drift (S-c): files copied once from a plugin template and given to the project
2
+ // (`owner: 'user'`: the @dokebi/db adapters, the Vite configs of `app add --inertia`). A later fix
3
+ // of the template never reaches such a copy, so doctor names the fixes it lacks
4
+ // (TEMPLATE_FIX_MISSING), and upgrade names them against the templates it is about to install.
5
+ // A fix is found by a marker in the template and its absence in the copy; the Vite configs are
6
+ // judged on their own (a rolling deploy needs the chunks of the previous build whatever the
7
+ // installed template says).
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import { join, relative } from 'node:path';
10
+ import { maskCode } from './check/form-rules.js';
11
+ import { debug, isMissing } from './diagnostics.js';
12
+ import { CliError } from './errors.js';
13
+ import { securePath } from './filesystem.js';
14
+ import { installedPath } from './workspace.js';
15
+ const SHARED_ADAPTER_FIXES = [
16
+ {
17
+ id: 'client-error',
18
+ marker: /DB_CLIENT_FAILED/,
19
+ what: 'a socket error of a client in use is answered and logged instead of ending the worker (DB_CLIENT_FAILED)',
20
+ },
21
+ { id: 'lazy', marker: /\blazy\s*:/, what: 'the pool opens on first use (lazy, D-0127)' },
22
+ {
23
+ id: 'usage',
24
+ marker: /\busage\s*:/,
25
+ what: 'pool usage for the owner share and the metrics (usage, D-0156)',
26
+ },
27
+ ];
28
+ /**
29
+ * The driver import of each adapter template: with an `@dokebi/db` import it tells a copy of that
30
+ * template that was moved or renamed (Studio v2, 10-31: copies outside src/infra/db/adapters were
31
+ * never compared).
32
+ */
33
+ const ADAPTER_DRIVERS = {
34
+ pg: /\bfrom\s+['"]pg['"]/,
35
+ mysql2: /\bfrom\s+['"]mysql2(?:\/promise)?['"]/,
36
+ mariadb: /\bfrom\s+['"]mariadb['"]/,
37
+ mongodb: /\bfrom\s+['"]mongodb['"]/,
38
+ };
39
+ /** An adapter of @dokebi/db: the template types its export as one. */
40
+ const ADAPTER_TYPE = /\bResourceAdapter\b[\s\S]*?\bfrom\s+['"]@dokebi\/db['"]|\bfrom\s+['"]@dokebi\/db['"][\s\S]*?\bResourceAdapter\b/;
41
+ /** The code without comments (imports stay: maskCode would blank their module names). */
42
+ const withoutComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1');
43
+ /** How many files under src/ the adapter search reads at most (a project, not a monorepo). */
44
+ const MAX_SCANNED_FILES = 2000;
45
+ /** TypeScript files under `src/` (not node_modules, not dot folders), project-relative paths. */
46
+ async function sourceFiles(root) {
47
+ const found = [];
48
+ const walk = async (folder) => {
49
+ if (found.length >= MAX_SCANNED_FILES)
50
+ return;
51
+ let entries;
52
+ try {
53
+ entries = await readdir(await securePath(root, folder), { withFileTypes: true });
54
+ }
55
+ catch (error) {
56
+ if (isMissing(error))
57
+ return;
58
+ throw new CliError('READ_FAILED', `Cannot read ${folder}`, { cause: error });
59
+ }
60
+ for (const entry of entries) {
61
+ if (found.length >= MAX_SCANNED_FILES)
62
+ return;
63
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
64
+ continue;
65
+ const path = join(folder, entry.name);
66
+ if (entry.isDirectory())
67
+ await walk(path);
68
+ else if (entry.isFile() && /\.(?:ts|mts)$/.test(entry.name))
69
+ found.push(path);
70
+ }
71
+ };
72
+ await walk('src');
73
+ return found.map((path) => relative('.', path));
74
+ }
75
+ /** The fixes of each @dokebi/db adapter template (`templates/adapters/<id>.ts`). */
76
+ export const ADAPTER_FIXES = {
77
+ pg: [
78
+ ...SHARED_ADAPTER_FIXES,
79
+ {
80
+ id: 'lost',
81
+ marker: /\blost\?\.\(/,
82
+ what: "a lost connection takes readiness down at once (the periodic healthIntervalMs check covers it later otherwise): take { logger, lost } in create and call lost?.(error) in the pool's and the clients' error handlers",
83
+ },
84
+ {
85
+ id: 'failed-connect',
86
+ marker: /\.stream\.destroy\(\)/,
87
+ what: "a client whose connect fails ends its socket (pg-pool drops it without ending it: with a URL without a password the socket stayed open and the process never exited): pass Client: PoolClient to new pg.Pool, a pg.Client subclass whose constructor adds this.connection.on('error', () => this.connection.stream.destroy())",
88
+ },
89
+ {
90
+ id: 'repeats',
91
+ marker: /logRepeatWindowMs/,
92
+ what: 'errors of many pooled connections at once (a database restart) are logged in full once per logRepeatWindowMs and counted into one record with repeated (D-0047): add the logRepeatWindowMs option and log the pool and client errors through diagnostics.Repeats (see the template)',
93
+ },
94
+ ],
95
+ mysql2: [
96
+ ...SHARED_ADAPTER_FIXES,
97
+ {
98
+ id: 'idle',
99
+ marker: /_removeIdleTimeoutConnections/,
100
+ what: 'idleTimeoutMs closes idle connections (mysql2 runs its idle reaper only when maxIdle < connectionLimit, so it never ran): call pool.pool._removeIdleTimeoutConnections() once after createPool (see the template)',
101
+ },
102
+ {
103
+ id: 'repeats',
104
+ marker: /logRepeatWindowMs/,
105
+ what: 'errors of many pooled connections at once (a database restart) are logged in full once per logRepeatWindowMs and counted into one record with repeated (D-0047): add the logRepeatWindowMs option and log the pool and client errors through diagnostics.Repeats (see the template)',
106
+ },
107
+ {
108
+ id: 'connect-cause',
109
+ marker: /connecting: opens/,
110
+ what: "a pool wait that ran out while the caller's own new connection was still connecting names that connect in its cause (ETIMEDOUT), so a stopped or frozen server takes readiness down even with max: 1 (it read as a busy pool): compute opens before getConnection and pass { cause } to the deadline error (see the template)",
111
+ },
112
+ {
113
+ id: 'queue-leave',
114
+ marker: /_connectionQueue/,
115
+ what: 'a caller whose pool wait ran out leaves mysql2\'s queue (it stayed there and counted against maxWaiting, so live callers got "Queue limit reached."): remove the settle callback from pool._connectionQueue at the deadline (see the template)',
116
+ },
117
+ ],
118
+ mariadb: [
119
+ ...SHARED_ADAPTER_FIXES,
120
+ {
121
+ id: 'lost',
122
+ marker: /\blost\?\.\(/,
123
+ what: "a lost connection takes readiness down at once (the periodic healthIntervalMs check covers it later otherwise): take { logger, lost } in create and call lost?.(error) in the pool's and the clients' error handlers",
124
+ },
125
+ {
126
+ id: 'url-query',
127
+ marker: /\.search\s*===\s*(['"])\1/,
128
+ what: "a URL with a query is refused instead of dropped (the adapter reads user, password, host, port and database only, so ?ssl=true connected without TLS): add the refine { test: (url) => URL.canParse(url) && new URL(url).search === '', message: ... } to the url option and set connector options such as ssl in the adapter file",
129
+ },
130
+ {
131
+ id: 'idle',
132
+ marker: /minimumIdle\s*:\s*0\b/,
133
+ what: 'connections open when callers need them and idle ones end after idleTimeoutMs (the connector kept max connections open at all times): minimumIdle: 0 in createPool',
134
+ },
135
+ {
136
+ id: 'ipv6',
137
+ marker: /hostname\.replace\(/,
138
+ what: "an IPv6 literal connects (the URL keeps its brackets, [::1]): host: address.hostname.replace(/^\\[(.*)\\]$/, '$1') in createPool",
139
+ },
140
+ {
141
+ id: 'repeats',
142
+ marker: /logRepeatWindowMs/,
143
+ what: 'errors of many pooled connections at once (a database restart) are logged in full once per logRepeatWindowMs and counted into one record with repeated (D-0047): add the logRepeatWindowMs option and log the pool and client errors through diagnostics.Repeats (see the template)',
144
+ },
145
+ ],
146
+ mongodb: SHARED_ADAPTER_FIXES,
147
+ };
148
+ /** The Vite configs `app add --inertia` writes (browser build, server build) and why each keeps its outDir. */
149
+ const VITE_CONFIGS = [
150
+ {
151
+ base: 'vite.config',
152
+ template: 'vite.config.ts',
153
+ why: 'a rolling deploy serves two builds at once, and the pages of the previous build load hashed chunks this build deleted (404); @dokebi/template-inertia serves every hashed file of dist/assets, so keep them and remove old ones with pruneBuildFiles of @dokebi/template-inertia/prune, which keeps every file the current manifest names (an age-only find also removes the current build after a week without a deploy)',
154
+ },
155
+ {
156
+ base: 'vite.ssr.config',
157
+ template: 'vite.ssr.config.ts',
158
+ why: 'a worker started before the build still imports the lazy chunks of its own SSR bundle from dist/ssr until it is replaced',
159
+ },
160
+ ];
161
+ const VITE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs'];
162
+ /** `script: { fs: … }` in the options of the Vue plugin: the compiler reads imported types. */
163
+ const VUE_TYPE_FILES = /\bscript\s*:\s*\{[^}]*\bfs\s*:/;
164
+ async function readOptional(path, label) {
165
+ try {
166
+ return await readFile(await path(), 'utf8');
167
+ }
168
+ catch (error) {
169
+ if (isMissing(error))
170
+ return undefined;
171
+ throw new CliError('READ_FAILED', `Cannot read ${label}`, {
172
+ cause: error,
173
+ details: { path: label },
174
+ });
175
+ }
176
+ }
177
+ /**
178
+ * TEMPLATE_FIX_MISSING warnings for the copies under `root`. Templates resolve from `base`
179
+ * (the project, or upgrade's staging install) like Node in a workspace member (D-0035).
180
+ */
181
+ export async function templateFixes(root, base, sources) {
182
+ const found = [];
183
+ // Every copy of an adapter template under src/: the place `dokebi add` writes it, and a file
184
+ // moved or renamed since (it imports the driver and @dokebi/db's ResourceAdapter).
185
+ const files = sources.db ? await sourceFiles(root) : [];
186
+ const texts = new Map();
187
+ for (const path of files) {
188
+ const text = await readOptional(() => securePath(root, path), path);
189
+ if (text !== undefined)
190
+ texts.set(path, text);
191
+ }
192
+ if (sources.db)
193
+ for (const [adapter, fixes] of Object.entries(ADAPTER_FIXES)) {
194
+ const usual = `src/infra/db/adapters/${adapter}.ts`;
195
+ const driver = ADAPTER_DRIVERS[adapter];
196
+ const copies = [...texts].filter(([path, text]) => path === usual ||
197
+ (driver.test(withoutComments(text)) && ADAPTER_TYPE.test(withoutComments(text))));
198
+ if (!copies.length)
199
+ continue;
200
+ const template = `node_modules/${sources.db}/templates/adapters/${adapter}.ts`;
201
+ const source = await readOptional(() => installedPath(base, template), template);
202
+ if (source === undefined)
203
+ continue;
204
+ for (const [path, copy] of copies) {
205
+ const missing = fixes.filter((fix) => fix.marker.test(source) && !fix.marker.test(copy));
206
+ if (missing.length)
207
+ found.push({
208
+ code: 'TEMPLATE_FIX_MISSING',
209
+ severity: 'warning',
210
+ path,
211
+ plugin: 'dokebi-db',
212
+ details: { template, missing: missing.map((fix) => fix.id) },
213
+ hint: `${path} was copied from ${template} before it got: ${missing.map((fix) => fix.what).join('; ')}. Compare the two files and bring the fixes over (the copy is yours: the CLI never rewrites it)`,
214
+ });
215
+ }
216
+ }
217
+ if (sources.inertia)
218
+ for (const config of VITE_CONFIGS) {
219
+ let path;
220
+ let text;
221
+ for (const extension of VITE_EXTENSIONS) {
222
+ const name = `${config.base}.${extension}`;
223
+ text = await readOptional(() => securePath(root, name), name);
224
+ if (text !== undefined) {
225
+ path = name;
226
+ break;
227
+ }
228
+ }
229
+ if (path === undefined || text === undefined)
230
+ continue;
231
+ // Comments and strings masked: a word in a comment changes no build.
232
+ const code = maskCode(text);
233
+ const template = sources.apps
234
+ ? `node_modules/${sources.apps.packageName}/templates/inertia-build/${config.template}`
235
+ : undefined;
236
+ const missing = [];
237
+ const hints = [];
238
+ if (!/\bemptyOutDir\s*:\s*false\b/.test(code)) {
239
+ const explicit = /\bemptyOutDir\s*:\s*true\b/.test(code);
240
+ debug('Vite config empties its outDir', { path, explicit });
241
+ missing.push('keep-chunks');
242
+ hints.push(`${path} ${explicit ? 'sets build.emptyOutDir: true' : 'does not set build.emptyOutDir, and Vite empties its outDir by default'}, so every build deletes the previous build's files: ${config.why}. Set build.emptyOutDir: false in ${path}${template ? ` as ${template} does` : ''}`);
243
+ }
244
+ // R4CB-8: the template's fix of 558faf7a (AI-M49 D3), found by its marker in the template.
245
+ const source = template
246
+ ? await readOptional(() => installedPath(base, template), template)
247
+ : undefined;
248
+ if (source !== undefined &&
249
+ VUE_TYPE_FILES.test(maskCode(source)) &&
250
+ !VUE_TYPE_FILES.test(code)) {
251
+ debug('Vite config gives the Vue compiler no file access', { path });
252
+ missing.push('vue-type-files');
253
+ hints.push(`${path} gives the Vue compiler no file access, so a page whose props type comes from another file (defineProps<HomeProps>() with HomeProps imported) fails the build: TypeScript 7 has no JavaScript API for the compiler to read files with. Pass vue({ script: { fs: { fileExists, readFile, realpath } } }) built from node:fs, as ${template} does`);
254
+ }
255
+ if (!missing.length)
256
+ continue;
257
+ found.push({
258
+ code: 'TEMPLATE_FIX_MISSING',
259
+ severity: 'warning',
260
+ path,
261
+ plugin: sources.apps?.id ?? 'dokebi-template-inertia',
262
+ details: { ...(template ? { template } : {}), missing },
263
+ hint: `${hints.join('. ')} (the file is yours: the CLI never rewrites it)`,
264
+ });
265
+ }
266
+ // R4CB-1: the generated src/main.ts read DOKEBI_CONFIG with `??`, so an empty value was taken
267
+ // for a path (the working folder) and the app failed to start, while every CLI command reads
268
+ // dokebi.yaml then (D-0154 D4). Not a plugin's template: doctor names it, upgrade does not.
269
+ const main = await readOptional(() => securePath(root, MAIN_FILE), MAIN_FILE);
270
+ if (main !== undefined && EMPTY_CONFIG_ENV.test(maskCode(main))) {
271
+ debug('src/main.ts takes an empty DOKEBI_CONFIG for a path', { path: MAIN_FILE });
272
+ found.push({
273
+ code: 'TEMPLATE_FIX_MISSING',
274
+ severity: 'warning',
275
+ path: MAIN_FILE,
276
+ plugin: null,
277
+ details: { missing: ['empty-config-env'] },
278
+ hint: `${MAIN_FILE} reads process.env.DOKEBI_CONFIG with ??, so an empty DOKEBI_CONFIG (DOKEBI_CONFIG= in a shell, an empty CI or container variable) is taken for a path: the app reads the working folder and stops with CONFIG_READ_FAILED, while every dokebi command reads dokebi.yaml then. Write process.env.DOKEBI_CONFIG || where it says process.env.DOKEBI_CONFIG ?? (the file is yours: the CLI never rewrites it)`,
279
+ });
280
+ }
281
+ return found;
282
+ }
283
+ const MAIN_FILE = 'src/main.ts';
284
+ /** `process.env.DOKEBI_CONFIG ??` in code (comments and strings masked). */
285
+ const EMPTY_CONFIG_ENV = /\bprocess\.env\.DOKEBI_CONFIG\s*\?\?/;
@@ -0,0 +1,49 @@
1
+ /** One line of the report (JSON): a finished test. */
2
+ export interface ReportedTest {
3
+ /** Its own name. */
4
+ readonly name: string;
5
+ /** Names of the suites and parent tests around it, outermost first. */
6
+ readonly parents: string[];
7
+ readonly ok: boolean;
8
+ readonly ms: number;
9
+ /** Absolute path of the test file, when node:test gave one. */
10
+ readonly file?: string;
11
+ readonly skipped?: true;
12
+ readonly todo?: true;
13
+ /** First line of the failure message (bounded, not yet masked). */
14
+ readonly error?: string;
15
+ }
16
+ /** Longest error line kept. */
17
+ export declare const ERROR_LINE_CHARACTERS = 300;
18
+ interface TestData {
19
+ name?: unknown;
20
+ nesting?: unknown;
21
+ file?: unknown;
22
+ entryFile?: unknown;
23
+ message?: unknown;
24
+ skip?: unknown;
25
+ todo?: unknown;
26
+ details?: {
27
+ duration_ms?: unknown;
28
+ type?: unknown;
29
+ error?: unknown;
30
+ };
31
+ }
32
+ /** The first non-empty line of a value's message, bounded; `undefined` when there is none. */
33
+ export declare function firstLine(value: unknown): string | undefined;
34
+ /**
35
+ * A compared value as it may appear in the report: numbers, booleans, null, undefined and
36
+ * upper-case codes (`'CSRF_FAILED'`); any other string only by its length (a test password such
37
+ * as `'hunter2'` never leaves), other values by kind. verify masks the known secrets again.
38
+ */
39
+ export declare function shownValue(value: unknown): string;
40
+ /**
41
+ * `name.test.ts:688`: the first frame of the stack in the test file itself. A frame names the file
42
+ * as a path or as a `file:` URL, where spaces and non-ASCII letters are percent-encoded.
43
+ */
44
+ export declare function thrownAt(thrown: unknown, file: string): string | undefined;
45
+ export default function dokebiVerifyReporter(source: AsyncIterable<{
46
+ type: string;
47
+ data?: TestData;
48
+ }>): AsyncGenerator<string>;
49
+ export {};
@@ -0,0 +1,190 @@
1
+ // The node:test reporter `dokebi verify` runs the project's tests with (D-0077 H):
2
+ // `node --test --test-reporter=<this file> --test-reporter-destination=<temp file>`. It writes one
3
+ // JSON object per finished test and nothing else: no stdout or stderr of the tests is copied, so
4
+ // a test that prints a secret never puts it in verify's result. Only the first line of a failure
5
+ // message is kept (for a test file that fails before any test runs, the first `…Error` line it
6
+ // printed), bounded in length; verify masks secrets in it again before it reports it. An
7
+ // assertion whose message node wrote itself ("Expected values to be strictly equal:") says
8
+ // nothing on that line, so the values it compared follow when they are short and plain (numbers,
9
+ // booleans, upper-case codes: `401 !== 200`; other strings by length, other values by kind), and every failure ends with
10
+ // where in the test file it was thrown (`portal.test.ts:688`).
11
+ //
12
+ // Node's own machine-readable reporters were the alternative: `tap` and `junit` need a parser
13
+ // for a text format (and junit loses the todo/skip difference and the nesting), while the event
14
+ // stream here is node:test's own structured API, stable since Node 20 and unchanged in 24.
15
+ //
16
+ // Only leaf tests are written (a parent test or a suite passes or fails with its subtests), plus a
17
+ // parent or suite that failed for its own reason (a hook, an assertion after its subtests).
18
+ import { basename, resolve } from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
20
+ /** Longest error line kept. */
21
+ export const ERROR_LINE_CHARACTERS = 300;
22
+ /** The first non-empty line of a value's message, bounded; `undefined` when there is none. */
23
+ export function firstLine(value) {
24
+ let text;
25
+ if (value instanceof Error) {
26
+ const message = String(value.message ?? '');
27
+ text = value.name && value.name !== 'Error' ? `${value.name}: ${message}` : message;
28
+ }
29
+ else if (typeof value === 'string')
30
+ text = value;
31
+ else if (value === undefined || value === null)
32
+ return undefined;
33
+ else
34
+ text = String(value);
35
+ const line = text
36
+ .split(/\r?\n/)
37
+ .map((item) => item.trim())
38
+ .find((item) => item.length > 0);
39
+ if (!line)
40
+ return undefined;
41
+ return line.length > ERROR_LINE_CHARACTERS ? `${line.slice(0, ERROR_LINE_CHARACTERS)}…` : line;
42
+ }
43
+ /**
44
+ * A compared value as it may appear in the report: numbers, booleans, null, undefined and
45
+ * upper-case codes (`'CSRF_FAILED'`); any other string only by its length (a test password such
46
+ * as `'hunter2'` never leaves), other values by kind. verify masks the known secrets again.
47
+ */
48
+ export function shownValue(value) {
49
+ if (value === null || value === undefined || typeof value === 'boolean')
50
+ return String(value);
51
+ if (typeof value === 'number' || typeof value === 'bigint')
52
+ return String(value);
53
+ if (typeof value === 'string')
54
+ // A code: upper-case words of at most 15 letters joined by `_`, digits only after one; never
55
+ // an access key id (AKIA…), a base32 TOTP secret or a licence key (R11).
56
+ return /^[A-Z]{1,15}(?:_[A-Z0-9]{1,15}){0,5}$/.test(value)
57
+ ? `'${value}'`
58
+ : `a string of ${value.length} character${value.length === 1 ? '' : 's'}`;
59
+ if (Array.isArray(value))
60
+ return `an array of ${value.length}`;
61
+ return typeof value === 'object' ? 'an object' : typeof value;
62
+ }
63
+ const OPERATORS = {
64
+ strictEqual: '!==',
65
+ equal: '!=',
66
+ notStrictEqual: '===',
67
+ notEqual: '==',
68
+ deepStrictEqual: 'differs from',
69
+ deepEqual: 'differs from',
70
+ };
71
+ /** What a generated assertion message leaves out: the two values it compared. */
72
+ function compared(thrown) {
73
+ const failure = thrown;
74
+ if (failure?.code !== 'ERR_ASSERTION' || failure.generatedMessage !== true)
75
+ return undefined;
76
+ const operator = typeof failure.operator === 'string' ? OPERATORS[failure.operator] : undefined;
77
+ if (!operator)
78
+ return undefined;
79
+ return `${shownValue(failure.actual)} ${operator} ${shownValue(failure.expected)}`;
80
+ }
81
+ /**
82
+ * `name.test.ts:688`: the first frame of the stack in the test file itself. A frame names the file
83
+ * as a path or as a `file:` URL, where spaces and non-ASCII letters are percent-encoded.
84
+ */
85
+ export function thrownAt(thrown, file) {
86
+ const stack = thrown?.stack;
87
+ if (!file || typeof stack !== 'string')
88
+ return undefined;
89
+ const names = [pathToFileURL(file).href, file];
90
+ for (const frame of stack.split('\n').slice(1))
91
+ for (const name of names) {
92
+ const at = frame.indexOf(name);
93
+ if (at < 0)
94
+ continue;
95
+ const line = /^:(\d+)/.exec(frame.slice(at + name.length));
96
+ if (line)
97
+ return `${basename(file)}:${line[1]}`;
98
+ }
99
+ return undefined;
100
+ }
101
+ /** The failure line of node:test's error: the thrown value (`cause`) rather than the wrapper. */
102
+ function failureLine(error, file = '') {
103
+ if (!(error instanceof Error)) {
104
+ const line = firstLine(error);
105
+ return line ? { line } : {};
106
+ }
107
+ const type = error.failureType;
108
+ const thrown = error.code === 'ERR_TEST_FAILURE' && error.cause !== undefined
109
+ ? error.cause
110
+ : error;
111
+ let line = firstLine(thrown);
112
+ if (line !== undefined) {
113
+ const values = compared(thrown);
114
+ const at = thrownAt(thrown, file);
115
+ line = `${line}${values ? ` ${values}` : ''}${at ? ` (at ${at})` : ''}`;
116
+ if (line.length > ERROR_LINE_CHARACTERS)
117
+ line = `${line.slice(0, ERROR_LINE_CHARACTERS)}…`;
118
+ }
119
+ return { ...(line ? { line } : {}), ...(typeof type === 'string' ? { type } : {}) };
120
+ }
121
+ /** `skip` and `todo` are `true` or the reason text when set. */
122
+ const isSet = (value) => value !== undefined && value !== false;
123
+ const ERROR_LINE = /^(?:[A-Z][A-Za-z]*)?Error\b.*$/;
124
+ export default async function* dokebiVerifyReporter(source) {
125
+ // Per test file: the names of the tests that are running, by nesting level.
126
+ const stacks = new Map();
127
+ // Per test file: the first `…Error` line it wrote to stderr (a file that fails to load).
128
+ const fileErrors = new Map();
129
+ for await (const event of source) {
130
+ const data = event.data ?? {};
131
+ const file = typeof data.file === 'string' ? resolve(data.file) : '';
132
+ if (event.type === 'test:stderr') {
133
+ const where = typeof data.entryFile === 'string' ? resolve(data.entryFile) : file;
134
+ if (fileErrors.has(where) || typeof data.message !== 'string')
135
+ continue;
136
+ const line = data.message
137
+ .split(/\r?\n/)
138
+ .map((item) => item.trim())
139
+ .find((item) => ERROR_LINE.test(item));
140
+ if (line)
141
+ fileErrors.set(where, firstLine(line));
142
+ continue;
143
+ }
144
+ if (event.type !== 'test:start' && event.type !== 'test:pass' && event.type !== 'test:fail')
145
+ continue;
146
+ const name = typeof data.name === 'string' ? data.name : '';
147
+ const nesting = typeof data.nesting === 'number' && data.nesting >= 0 ? data.nesting : 0;
148
+ let stack = stacks.get(file);
149
+ if (!stack) {
150
+ stack = [];
151
+ stacks.set(file, stack);
152
+ }
153
+ if (event.type === 'test:start') {
154
+ stack.length = Math.min(stack.length, nesting);
155
+ const parent = stack[nesting - 1];
156
+ if (parent)
157
+ parent.hasChildren = true;
158
+ stack[nesting] = { name, hasChildren: false };
159
+ continue;
160
+ }
161
+ const frame = stack[nesting];
162
+ const parents = stack.slice(0, nesting).map((item) => item?.name ?? '');
163
+ const failed = event.type === 'test:fail';
164
+ const failure = failed ? failureLine(data.details?.error, file) : {};
165
+ const suite = data.details?.type === 'suite';
166
+ const leaf = !suite && !(frame?.name === name && frame.hasChildren);
167
+ const ownFailure = failed && failure.type !== 'subtestsFailed';
168
+ if (frame?.name === name)
169
+ stack.length = nesting;
170
+ if (!leaf && !ownFailure)
171
+ continue;
172
+ let error = failure.line;
173
+ // A file that fails before its tests run: node:test says only "test failed"; the first
174
+ // error line the file printed says why (a syntax error, a failed import).
175
+ if (failed && nesting === 0 && (error === undefined || error === 'test failed'))
176
+ error = fileErrors.get(file) ?? error;
177
+ const duration = data.details?.duration_ms;
178
+ const report = {
179
+ name,
180
+ parents,
181
+ ok: !failed || isSet(data.todo),
182
+ ms: typeof duration === 'number' ? Math.round(duration) : 0,
183
+ ...(file ? { file } : {}),
184
+ ...(isSet(data.skip) ? { skipped: true } : {}),
185
+ ...(isSet(data.todo) ? { todo: true } : {}),
186
+ ...(failed && error ? { error } : {}),
187
+ };
188
+ yield `${JSON.stringify(report)}\n`;
189
+ }
190
+ }
@@ -1,3 +1,4 @@
1
+ import { CliError } from './errors.js';
1
2
  /**
2
3
  * A path the rollback left as it found it, because it is no longer what this commit put there
3
4
  * (ISSUE-0006 A06): `modified` (other content), `replaced` (a directory or special file now),
@@ -12,15 +13,60 @@ export interface RollbackConflict {
12
13
  readonly backup?: string;
13
14
  readonly kept?: string;
14
15
  }
16
+ /**
17
+ * INTERRUPTED: a termination signal (Ctrl+C, SIGTERM, SIGHUP) came before the commit, so the
18
+ * change was not applied; the lock and the staging folder are removed as after any refusal.
19
+ */
20
+ export declare function interruptedError(signal: NodeJS.Signals, cause?: unknown): CliError;
21
+ /** Something a termination signal cancels: a transaction, or init's staging (Interruption). */
22
+ interface Cancellable {
23
+ readonly interrupted: NodeJS.Signals | undefined;
24
+ cancel(signal: NodeJS.Signals): void;
25
+ }
26
+ /**
27
+ * A change without a transaction that a termination signal cancels like one (R4CB-3: `dokebi
28
+ * init`, whose staging folder a killed CLI left in the target folder, so the next init was
29
+ * PROJECT_EXISTS). Open it before the work, `check()` before the step that makes the change
30
+ * visible, `failure(error)` for what the work threw, and `close()` in a finally.
31
+ */
32
+ export declare class Interruption implements Cancellable {
33
+ #private;
34
+ private constructor();
35
+ static open(): Interruption;
36
+ get interrupted(): NodeJS.Signals | undefined;
37
+ cancel(signal: NodeJS.Signals): void;
38
+ /** Throws INTERRUPTED when a signal came. */
39
+ check(): void;
40
+ /** INTERRUPTED (with `error` as its cause) when a signal came, else `error` itself. */
41
+ failure(error: unknown): unknown;
42
+ close(): void;
43
+ }
15
44
  export declare class Transaction {
16
45
  #private;
17
46
  readonly root: string;
18
47
  readonly directory: string;
48
+ /** The termination signal that cancelled this change, if one came. */
49
+ get interrupted(): NodeJS.Signals | undefined;
50
+ /** Marks the change cancelled (watchSignals); a commit that has begun is not affected. */
51
+ cancel(signal: NodeJS.Signals): void;
52
+ /**
53
+ * What a command that owns this transaction throws for `error` (R4CB-4): INTERRUPTED when a
54
+ * signal cancelled the change before its commit began, whatever part of it then failed (an npm
55
+ * run other than the staging install, an install module the terminal's Ctrl+C reached too), so
56
+ * the result says nothing was applied (phase plan, mutation none). From the commit on, the
57
+ * error stays as it is (COMMIT_FAILED, RECOVERY_REQUIRED, ...).
58
+ */
59
+ failure(error: unknown): unknown;
19
60
  private constructor();
20
61
  static begin(root: string): Promise<Transaction>;
21
62
  capture(path: string): Promise<void>;
22
63
  ensureDirectory(path: string): Promise<void>;
23
64
  read(path: string): Promise<string>;
65
+ /**
66
+ * Rewrites text before it is staged (D-0154: the reloadable marks of dokebi.yaml and
67
+ * dokebi.example.yaml). Set by the operation that owns the transaction.
68
+ */
69
+ transformText: ((path: string, text: string) => Promise<string>) | undefined;
24
70
  write(path: string, content: string | Uint8Array): Promise<void>;
25
71
  /** Puts `source` (a file or folder) at `path` on commit; without `source`, removes `path`. */
26
72
  replace(path: string, source?: string): Promise<void>;
@@ -37,3 +83,4 @@ export declare class Transaction {
37
83
  commit(beforeApply?: (index: number) => void, afterApply?: () => Promise<void>): Promise<void>;
38
84
  close(): Promise<void>;
39
85
  }
86
+ export {};