@dokebi/cli 0.4.2 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/AI.md +262 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2519 -257
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +518 -35
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +2 -0
  46. package/dist/check/shared-state-rules.js +114 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +307 -31
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-show.d.ts +82 -0
  70. package/dist/config-show.js +563 -0
  71. package/dist/config-values.d.ts +8 -0
  72. package/dist/config-values.js +68 -0
  73. package/dist/configuration-scaffold.d.ts +42 -0
  74. package/dist/configuration-scaffold.js +258 -4
  75. package/dist/configuration.d.ts +63 -3
  76. package/dist/configuration.js +178 -4
  77. package/dist/deployment.d.ts +2 -0
  78. package/dist/deployment.js +111 -0
  79. package/dist/describe-db.d.ts +22 -0
  80. package/dist/describe-db.js +176 -0
  81. package/dist/describe.d.ts +92 -0
  82. package/dist/describe.js +417 -0
  83. package/dist/dev-compose.d.ts +20 -0
  84. package/dist/dev-compose.js +154 -0
  85. package/dist/dev-guard.js +44 -0
  86. package/dist/dev-host.d.ts +13 -0
  87. package/dist/dev-host.js +55 -0
  88. package/dist/dev-infra.d.ts +72 -4
  89. package/dist/dev-infra.js +237 -17
  90. package/dist/dev-watch.d.ts +34 -0
  91. package/dist/dev-watch.js +192 -0
  92. package/dist/diagnostics.d.ts +10 -7
  93. package/dist/diagnostics.js +22 -4
  94. package/dist/drift.d.ts +50 -0
  95. package/dist/drift.js +218 -0
  96. package/dist/env-file.d.ts +25 -1
  97. package/dist/env-file.js +64 -6
  98. package/dist/errors.d.ts +7 -1
  99. package/dist/example-config.d.ts +2 -0
  100. package/dist/example-config.js +3 -0
  101. package/dist/first-admin.d.ts +43 -0
  102. package/dist/first-admin.js +175 -0
  103. package/dist/formatter-ignore.d.ts +37 -0
  104. package/dist/formatter-ignore.js +344 -0
  105. package/dist/generator.d.ts +45 -3
  106. package/dist/generator.js +205 -15
  107. package/dist/guide.d.ts +1 -1
  108. package/dist/guide.js +42 -21
  109. package/dist/install-module.d.ts +20 -2
  110. package/dist/install-module.js +45 -8
  111. package/dist/install-plan.js +5 -3
  112. package/dist/layout.d.ts +12 -1
  113. package/dist/layout.js +19 -6
  114. package/dist/log-writer-control.d.ts +2 -0
  115. package/dist/log-writer-control.js +17 -2
  116. package/dist/log-writer.js +43 -10
  117. package/dist/logging-config.d.ts +8 -3
  118. package/dist/logging-config.js +19 -5
  119. package/dist/manifest.d.ts +29 -1
  120. package/dist/manifest.js +84 -6
  121. package/dist/merge-install-defaults.d.ts +0 -1
  122. package/dist/merge-install-defaults.js +13 -47
  123. package/dist/npm-client.d.ts +40 -4
  124. package/dist/npm-client.js +152 -15
  125. package/dist/npm-paths.js +18 -4
  126. package/dist/openapi.d.ts +27 -0
  127. package/dist/openapi.js +649 -0
  128. package/dist/option-fixes.d.ts +39 -0
  129. package/dist/option-fixes.js +130 -0
  130. package/dist/output.d.ts +1 -1
  131. package/dist/output.js +5 -5
  132. package/dist/plugin-lifecycle.d.ts +34 -2
  133. package/dist/plugin-lifecycle.js +195 -16
  134. package/dist/plugin-options.d.ts +19 -4
  135. package/dist/plugin-options.js +138 -6
  136. package/dist/plugins-command.js +44 -7
  137. package/dist/process-group.d.ts +39 -0
  138. package/dist/process-group.js +132 -0
  139. package/dist/project-guide.d.ts +19 -1
  140. package/dist/project-guide.js +119 -20
  141. package/dist/project-inspector.d.ts +15 -0
  142. package/dist/project-inspector.js +481 -33
  143. package/dist/project-state.d.ts +5 -0
  144. package/dist/project-state.js +8 -0
  145. package/dist/project.d.ts +203 -19
  146. package/dist/project.js +3006 -333
  147. package/dist/recipe-facts.d.ts +127 -0
  148. package/dist/recipe-facts.js +563 -0
  149. package/dist/recipe-login.d.ts +63 -0
  150. package/dist/recipe-login.js +464 -0
  151. package/dist/recipe-mail.d.ts +21 -0
  152. package/dist/recipe-mail.js +214 -0
  153. package/dist/recipe-members.d.ts +35 -0
  154. package/dist/recipe-members.js +178 -0
  155. package/dist/recipe-record.d.ts +82 -7
  156. package/dist/recipe-record.js +226 -22
  157. package/dist/recipe-reset.d.ts +2 -0
  158. package/dist/recipe-reset.js +117 -0
  159. package/dist/recipe-site.d.ts +48 -0
  160. package/dist/recipe-site.js +396 -0
  161. package/dist/recipe-step.d.ts +42 -0
  162. package/dist/recipe-step.js +89 -0
  163. package/dist/recipe.d.ts +56 -20
  164. package/dist/recipe.js +703 -131
  165. package/dist/registry.d.ts +98 -2
  166. package/dist/registry.js +171 -5
  167. package/dist/reloadable.d.ts +35 -0
  168. package/dist/reloadable.js +257 -0
  169. package/dist/server.d.ts +43 -4
  170. package/dist/server.js +601 -52
  171. package/dist/settings.d.ts +48 -0
  172. package/dist/settings.js +168 -0
  173. package/dist/site-auth.d.ts +39 -0
  174. package/dist/site-auth.js +195 -0
  175. package/dist/template-fixes.d.ts +26 -0
  176. package/dist/template-fixes.js +285 -0
  177. package/dist/test-reporter.d.ts +49 -0
  178. package/dist/test-reporter.js +190 -0
  179. package/dist/transaction.d.ts +47 -0
  180. package/dist/transaction.js +241 -7
  181. package/dist/verify-tests.d.ts +98 -0
  182. package/dist/verify-tests.js +449 -0
  183. package/dist/verify.d.ts +103 -3
  184. package/dist/verify.js +272 -21
  185. package/dist/view-choice.d.ts +58 -0
  186. package/dist/view-choice.js +131 -0
  187. package/dist/vite-watch.js +4 -2
  188. package/package.json +8 -6
@@ -1,21 +1,101 @@
1
- import { readdir, readFile, realpath } from 'node:fs/promises';
1
+ import { readdir, readFile, realpath, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
2
3
  import satisfies from 'semver/functions/satisfies.js';
3
4
  import valid from 'semver/functions/valid.js';
4
- import { CONFIG_FILE, LEGACY_CONFIG_FILE, parseProjectConfig } from './configuration.js';
5
+ import { CONFIG_FILE, configTarget, FRESH_COPY_FIX, LEGACY_CONFIG_FILE, parseProjectConfig, readProjectConfigSource, } from './configuration.js';
6
+ import { connectionSecrets } from './dev-compose.js';
7
+ import { COMPOSE_FILE, composeAppPortHint, composeBuildsAppHint, composeServicesBuildingProject, composeServicesPublishing, composeUnsetVariables, composeVariablesHint, composeVariablesOf, envKeys, } from './dev-infra.js';
8
+ import { debug, describeError, isMissing } from './diagnostics.js';
9
+ import { ENV_FILE, envFileWarnings } from './env-file.js';
5
10
  import { CliError } from './errors.js';
6
11
  import { exists, fingerprint, hash, json, parseObject, securePath } from './filesystem.js';
7
- import { legacyLayout } from './layout.js';
12
+ import { adminSections } from './first-admin.js';
13
+ import { legacyLayout, MOVED_RECIPE_FILES } from './layout.js';
8
14
  import { loggingDiagnostics } from './logging-config.js';
9
- import { orderPlugins, readManifest } from './manifest.js';
15
+ import { optionsKey, orderPlugins, readManifest } from './manifest.js';
10
16
  import { dependencyMap, validateProjectPackage } from './npm-client.js';
11
- import { checkPlugin } from './plugin-lifecycle.js';
17
+ import { optionsFix } from './option-fixes.js';
18
+ import { checkPlugin, checkPluginsOptions } from './plugin-lifecycle.js';
12
19
  import { PluginOptionsValidator } from './plugin-options.js';
13
- import { AGENTS_FILE, guideEntries, INSTALLED_END, INSTALLED_START, installedBlockEdited, } from './project-guide.js';
20
+ import { AGENTS_FILE, guideEntries, INSTALLED_END, INSTALLED_START, installedBlockEdited, PROJECT_END, PROJECT_START, projectBlockState, } from './project-guide.js';
14
21
  import { readState } from './project-state.js';
22
+ import { ADMIN_SECTION, LEGACY_ADMIN_SECTION, SEED_FILE } from './recipe-login.js';
15
23
  import { readRequiredPlugins } from './required-plugins.js';
24
+ import { CONVENTION_SECTIONS, catalogPackageOf, renderSettingsSchema, settingsFile, settingsSchemaFile, undeclaredSection, } from './settings.js';
25
+ import { templateFixes } from './template-fixes.js';
16
26
  import { installedPath } from './workspace.js';
27
+ /**
28
+ * Reads `.dokebi/lock` (`{ pid }`, written by Transaction.begin) without taking it (AI-M04 F6):
29
+ * only a signal-0 probe, no `ps`. A reused pid can look alive; `since` lets a reader see an old
30
+ * lock. Null when the lock is gone.
31
+ */
32
+ export async function lockHolder(root) {
33
+ const file = join(root, '.dokebi', 'lock');
34
+ let pid = null;
35
+ let since = null;
36
+ try {
37
+ since = (await stat(file)).mtime.toISOString();
38
+ const value = JSON.parse(await readFile(file, 'utf8'));
39
+ if (Number.isSafeInteger(value?.pid) && value.pid > 0)
40
+ pid = value.pid;
41
+ }
42
+ catch (error) {
43
+ // Gone since the listing: the command ended. Otherwise written half: no pid known.
44
+ if (isMissing(error))
45
+ return null;
46
+ debug('inspect: lock file without a readable pid', { error: describeError(error) });
47
+ }
48
+ if (pid === null)
49
+ return { pid, running: null, since };
50
+ let running;
51
+ try {
52
+ process.kill(pid, 0);
53
+ running = true;
54
+ }
55
+ catch (error) {
56
+ const code = error.code;
57
+ // EPERM: it lives under another user or sandbox; ESRCH: gone; anything else: unknown.
58
+ running = code === 'EPERM' ? true : code === 'ESRCH' ? false : null;
59
+ if (running === null)
60
+ debug('inspect: lock holder probe failed', { pid, error: describeError(error) });
61
+ }
62
+ debug('inspect: project lock held', { pid, running, since });
63
+ return { pid, running, since };
64
+ }
65
+ /** The PROJECT_LOCKED diagnostic with what to do (AI-M04 F6). */
66
+ export function lockDiagnostic(holder, transactions) {
67
+ const who = holder.pid === null ? 'a dokebi command' : `a dokebi command (pid ${holder.pid})`;
68
+ return {
69
+ code: 'PROJECT_LOCKED',
70
+ severity: 'error',
71
+ path: '.dokebi/lock',
72
+ plugin: null,
73
+ details: { ...holder, transactions },
74
+ hint: holder.running === true
75
+ ? `${who} holds the lock and is changing the project right now (a --dry-run takes it too), so this read may show its unfinished work${transactions.length ? ` (${transactions.join(', ')} is its open transaction, not an interrupted one)` : ''}. inspect is read-only and takes no lock: run it again when that command has ended. Never delete the lock`
76
+ : holder.running === false
77
+ ? `${who} took the lock and is no longer running: a change was interrupted. Do not delete .dokebi/lock or .dokebi/transaction-* (the backups of the originals); ask the user to look`
78
+ : `.dokebi/lock names no process. If no dokebi command is running, a change was interrupted: ask the user to look; never delete the lock`,
79
+ };
80
+ }
17
81
  // Reads metadata only and never creates locks. With runChecks (doctor), each plugin's
18
82
  // installation module check runs in the permission-restricted child process.
83
+ /**
84
+ * S-c: fixes of the @dokebi/db adapter templates a copy made earlier may lack: found by a marker
85
+ * of the fix in the installed template and its absence in the copy.
86
+ */
87
+ /** The apps under src/apps with an Inertia browser entry (views/app.js), sorted by name. */
88
+ async function inertiaAppNames(root) {
89
+ const apps = await securePath(root, 'src/apps');
90
+ if (!(await exists(apps)))
91
+ return [];
92
+ const names = [];
93
+ for (const entry of await readdir(apps, { withFileTypes: true }))
94
+ if (entry.isDirectory() &&
95
+ (await exists(await securePath(root, `src/apps/${entry.name}/views/app.js`))))
96
+ names.push(entry.name);
97
+ return names.sort();
98
+ }
19
99
  export class ProjectInspector {
20
100
  async inspect(directory, settings = {}) {
21
101
  const root = await realpath(directory);
@@ -29,7 +109,35 @@ export class ProjectInspector {
29
109
  diagnostics: [],
30
110
  };
31
111
  const originals = new Map();
32
- const optionsValidator = new PluginOptionsValidator();
112
+ // M50: the settings file the app reads (DOKEBI_CONFIG, else dokebi.yaml); diagnostics name it.
113
+ const settingsTarget = await configTarget(root);
114
+ const configFile = settingsTarget.file;
115
+ // R4CC-7: a DOKEBI_CONFIG line of .env is never passed to the app (the environment names the
116
+ // settings file); doctor says so as a warning, as start, dev and verify do.
117
+ try {
118
+ for (const warning of envFileWarnings(root))
119
+ result.diagnostics.push({
120
+ code: warning.code,
121
+ path: warning.path,
122
+ plugin: null,
123
+ severity: 'warning',
124
+ hint: warning.hint,
125
+ });
126
+ }
127
+ catch (error) {
128
+ if (!(error instanceof CliError))
129
+ throw error;
130
+ result.diagnostics.push({
131
+ code: error.code,
132
+ path: ENV_FILE,
133
+ plugin: null,
134
+ severity: 'error',
135
+ ...(error.details !== undefined ? { details: error.details } : {}),
136
+ });
137
+ }
138
+ const composePath = await securePath(root, COMPOSE_FILE);
139
+ // Every issue (D-0153): the fix names all unknown keys in one config unset.
140
+ const optionsValidator = new PluginOptionsValidator({ allErrors: true });
33
141
  const issue = (code, path, plugin = null, severity = 'error') => {
34
142
  result.diagnostics.push({ code, path, plugin, severity });
35
143
  };
@@ -57,24 +165,46 @@ export class ProjectInspector {
57
165
  };
58
166
  const read = async (path) => {
59
167
  // node_modules paths resolve like Node in a workspace member (D-0035).
60
- const text = await readFile(await installedPath(root, path), 'utf8');
168
+ const text = path === configFile
169
+ ? await readProjectConfigSource(root, settingsTarget.path)
170
+ : await readFile(await installedPath(root, path), 'utf8');
61
171
  originals.set(path, hash(text));
62
172
  return text;
63
173
  };
64
174
  const state = await check('.dokebi/state.json', async () => readState(parseObject(await read('.dokebi/state.json'), 'state')));
65
- const config = await check(CONFIG_FILE, async () => {
66
- if (await exists(await securePath(root, LEGACY_CONFIG_FILE)))
67
- throw new CliError((await exists(await securePath(root, CONFIG_FILE)))
68
- ? 'CONFIG_CONFLICT'
69
- : 'MIGRATION_REQUIRED', 'Explicit YAML migration is required');
70
- return parseProjectConfig(await read(CONFIG_FILE));
71
- });
175
+ // R3 Claude T4 review: a fresh copy without dokebi.yaml got ENOENT with no fix, and every
176
+ // plugin's options then read as missing (INVALID_PLUGIN_OPTIONS "correct options.…").
177
+ const configMissing = !settingsTarget.fromEnvironment &&
178
+ !(await exists(settingsTarget.path)) &&
179
+ !(await exists(await securePath(root, LEGACY_CONFIG_FILE)));
180
+ if (configMissing)
181
+ result.diagnostics.push({
182
+ code: 'NOT_A_PROJECT',
183
+ path: configFile,
184
+ plugin: null,
185
+ severity: 'error',
186
+ details: { file: configFile, fix: FRESH_COPY_FIX },
187
+ hint: FRESH_COPY_FIX,
188
+ });
189
+ const config = configMissing
190
+ ? null
191
+ : await check(configFile, async () => {
192
+ if (await exists(await securePath(root, LEGACY_CONFIG_FILE)))
193
+ throw new CliError((await exists(await securePath(root, CONFIG_FILE)))
194
+ ? 'CONFIG_CONFLICT'
195
+ : 'MIGRATION_REQUIRED', 'Explicit YAML migration is required');
196
+ return parseProjectConfig(await read(configFile));
197
+ });
72
198
  result.requiredPlugins =
73
199
  (await check('node_modules/@dokebi/core/package.json', () => readRequiredPlugins(root))) ??
74
200
  [];
201
+ let typecheckScript;
75
202
  const pkg = await check('package.json', async () => {
76
203
  const value = parseObject(await read('package.json'), 'package');
77
204
  validateProjectPackage(value);
205
+ const scripts = value.scripts;
206
+ if (scripts && typeof scripts === 'object' && !Array.isArray(scripts))
207
+ typecheckScript = scripts.typecheck;
78
208
  return dependencyMap(value);
79
209
  });
80
210
  const corePath = 'node_modules/@dokebi/core/package.json';
@@ -102,19 +232,187 @@ export class ProjectInspector {
102
232
  ...(state?.plugins.map((plugin) => plugin.id) ?? []),
103
233
  ...result.requiredPlugins.map((plugin) => plugin.id),
104
234
  ]);
105
- result.diagnostics.push(...loggingDiagnostics(config.options, known));
235
+ const loggerHost = state?.plugins.find((plugin) => plugin.hosts?.includes('logger'))?.id;
236
+ // M50: the paths name the file the app reads (configFile), as every other diagnostic.
237
+ // Typed with the `file` parameter of the config area's change, so this call compiles on
238
+ // either side of that merge (before it, the fourth argument is not read).
239
+ const logging = loggingDiagnostics;
240
+ result.diagnostics.push(...logging(config.options, known, loggerHost, configFile));
241
+ // D-0115: the first admin of an older recipe sits under options.blog-admin. It keeps
242
+ // working (the CLI reads both names); the CLI never renames a user's section itself.
243
+ if (Object.hasOwn(config.options, LEGACY_ADMIN_SECTION) && !known.has(LEGACY_ADMIN_SECTION)) {
244
+ debug('Older first-admin section in dokebi.yaml', { section: LEGACY_ADMIN_SECTION });
245
+ result.diagnostics.push({
246
+ code: 'LEGACY_ADMIN_SECTION',
247
+ severity: 'warning',
248
+ path: configFile,
249
+ plugin: null,
250
+ details: { section: LEGACY_ADMIN_SECTION, renameTo: ADMIN_SECTION },
251
+ hint: `options.${LEGACY_ADMIN_SECTION} (the first admin an older recipe wrote) keeps working and needs nothing now. Recipes now call it options.${ADMIN_SECTION}, a name not tied to one kind of app; to follow, rename the section in dokebi.yaml and the options['${LEGACY_ADMIN_SECTION}'] read in ${SEED_FILE} together (the CLI never renames them for you)`,
252
+ });
253
+ }
254
+ }
255
+ // R3 Codex T2: the app itself as a compose.yaml service on its own port, so dev-infra up
256
+ // started it and start, dev and verify found the port taken.
257
+ // R2 Codex T2: a compose.yaml that `dokebi dev-infra up` cannot start (a variable without a
258
+ // default that only the project's own script set) was silent here. D-0165: the passwords of
259
+ // dokebi.yaml that dev-infra up passes count as set.
260
+ const passed = Object.keys(connectionSecrets(config?.options ?? {}));
261
+ const envPath = await securePath(root, ENV_FILE);
262
+ const provided = async () => [
263
+ ...passed,
264
+ ...((await exists(envPath)) ? envKeys(await readFile(envPath, 'utf8')) : []),
265
+ ];
266
+ if (await exists(composePath)) {
267
+ const unset = composeUnsetVariables(await readFile(composePath, 'utf8'), await provided());
268
+ debug('doctor: compose.yaml variables', { unset: unset.length, passed: passed.length });
269
+ if (unset.length)
270
+ result.diagnostics.push({
271
+ code: 'COMPOSE_VARIABLES_UNSET',
272
+ path: COMPOSE_FILE,
273
+ plugin: null,
274
+ severity: 'warning',
275
+ details: { variables: unset },
276
+ hint: composeVariablesHint(unset),
277
+ });
278
+ }
279
+ const webPort = config?.options['dokebi-web']?.port;
280
+ if (typeof webPort === 'number' && webPort > 0 && (await exists(composePath))) {
281
+ const services = composeServicesPublishing(await readFile(composePath, 'utf8'), webPort);
282
+ debug('doctor: compose.yaml services on the web port', { port: webPort, services });
283
+ if (services.length)
284
+ result.diagnostics.push({
285
+ code: 'COMPOSE_PUBLISHES_APP_PORT',
286
+ path: COMPOSE_FILE,
287
+ plugin: null,
288
+ severity: 'warning',
289
+ details: { services, port: webPort },
290
+ hint: composeAppPortHint(services, webPort),
291
+ });
292
+ }
293
+ // R4 Codex T4: the app as a compose.yaml service built from the project (`build: .`) whose
294
+ // port is a variable without a default, which the port rule above cannot read.
295
+ if (await exists(composePath)) {
296
+ const text = await readFile(composePath, 'utf8');
297
+ const named = new Set(typeof webPort === 'number' && webPort > 0 ? composeServicesPublishing(text, webPort) : []);
298
+ const services = composeServicesBuildingProject(text).filter((name) => !named.has(name));
299
+ debug('doctor: compose.yaml services built from the project', { services });
300
+ if (services.length) {
301
+ const unset = composeUnsetVariables(text, await provided());
302
+ const variables = composeVariablesOf(text, services, unset);
303
+ result.diagnostics.push({
304
+ code: 'COMPOSE_BUILDS_APP',
305
+ path: COMPOSE_FILE,
306
+ plugin: null,
307
+ severity: 'warning',
308
+ details: { services, ...(variables.length ? { variables } : {}) },
309
+ hint: composeBuildsAppHint(services, variables),
310
+ });
311
+ }
312
+ }
313
+ // D-0155: the settings sections of the application, like plugin sections: their options
314
+ // against the schema file, and the schema file against the declaration it was written from.
315
+ if (config && state)
316
+ for (const section of state.settings ?? []) {
317
+ const file = settingsFile(section);
318
+ const schemaFile = settingsSchemaFile(section);
319
+ const value = Object.hasOwn(config.options, section) ? config.options[section] : undefined;
320
+ const checked = await check(schemaFile, () => optionsValidator.checkSettings(root, section, value ?? {}), section);
321
+ if (checked && !checked.schema)
322
+ result.diagnostics.push({
323
+ code: 'SETTINGS_SCHEMA_MISSING',
324
+ severity: 'error',
325
+ path: schemaFile,
326
+ plugin: section,
327
+ hint: 'npx dokebi settings sync writes it from the declaration',
328
+ });
329
+ if (checked?.errors.length)
330
+ result.diagnostics.push({
331
+ code: 'INVALID_PLUGIN_OPTIONS',
332
+ severity: 'error',
333
+ path: configFile,
334
+ plugin: section,
335
+ details: checked.errors,
336
+ hint: optionsFix(section, checked.errors, `correct options.${section} in dokebi.yaml (${schemaFile} lists the option keys)`, section, checked.schema),
337
+ });
338
+ const rendered = await check(file, () => renderSettingsSchema(root, section), section);
339
+ if (rendered !== null && checked?.schema) {
340
+ const current = await readFile(join(root, schemaFile), 'utf8');
341
+ if (current !== rendered)
342
+ result.diagnostics.push({
343
+ code: 'SETTINGS_SCHEMA_STALE',
344
+ severity: 'warning',
345
+ path: schemaFile,
346
+ plugin: section,
347
+ hint: `${file} changed since its schema was written: npx dokebi settings sync`,
348
+ });
349
+ }
350
+ }
351
+ // D-0155: a section of dokebi.yaml that nothing declares (code that reads options.<name> by
352
+ // hand): never checked at start, unknown to config show/set/unset/secret and the example.
353
+ if (config && state) {
354
+ const declared = new Set([
355
+ 'logging',
356
+ 'reload',
357
+ ...CONVENTION_SECTIONS,
358
+ // The first admin a recipe wrote, under any section name (D-0115).
359
+ ...adminSections(config.options),
360
+ ...state.plugins.flatMap((plugin) => [plugin.id, optionsKey(plugin)]),
361
+ ...result.requiredPlugins.map((plugin) => plugin.id),
362
+ ...(state.settings ?? []),
363
+ ]);
364
+ const undeclared = Object.keys(config.options).filter((section) => !declared.has(section));
365
+ if (undeclared.length) {
366
+ const packageOf = await catalogPackageOf();
367
+ for (const section of undeclared) {
368
+ const found = undeclaredSection(section, packageOf, state.plugins.map((plugin) => plugin.id), state.settings ?? []);
369
+ debug('Undeclared section in dokebi.yaml', { section, code: found.code });
370
+ result.diagnostics.push({
371
+ code: found.code,
372
+ severity: 'warning',
373
+ path: configFile,
374
+ plugin: section,
375
+ details: found.details,
376
+ hint: `${found.message}; ${found.details.fix}`,
377
+ });
378
+ }
379
+ }
106
380
  }
107
381
  const generated = await check('src/dokebi.generated.ts', () => read('src/dokebi.generated.ts'));
108
382
  if (state && generated !== null && hash(generated) !== state.generatedHash)
109
- issue('MODIFIED_REGISTRATION', 'src/dokebi.generated.ts');
383
+ // R1 measurement: a code formatter over src/ is the usual cause; the hint says both.
384
+ result.diagnostics.push({
385
+ code: 'MODIFIED_REGISTRATION',
386
+ path: 'src/dokebi.generated.ts',
387
+ plugin: null,
388
+ severity: 'error',
389
+ hint: 'a hand edit or a code formatter (prettier, biome) changed it: restore it (git checkout -- src/dokebi.generated.ts) and keep formatters out of it (the file in .prettierignore, "!src/dokebi.generated.ts" in biome files.includes; npx dokebi check warns formatter-generated)',
390
+ });
110
391
  const control = await check('.dokebi', async () => readdir(await securePath(root, '.dokebi')));
111
- if (control?.includes('lock'))
112
- issue('PROJECT_LOCKED', '.dokebi/lock');
113
- for (const name of control ?? []) {
114
- if (name.startsWith('transaction-'))
115
- issue('RECOVERY_REQUIRED', '.dokebi/' + name);
116
- }
392
+ // AI-M04 F6: a lock whose command still runs is that command's work in progress (its open
393
+ // transaction folder included), not an interrupted change that needs recovery.
394
+ const transactions = (control ?? [])
395
+ .filter((name) => name.startsWith('transaction-'))
396
+ .map((name) => `.dokebi/${name}`);
397
+ await check('.dokebi', async () => {
398
+ const holder = control?.includes('lock') ? await lockHolder(root) : null;
399
+ if (holder)
400
+ result.diagnostics.push(lockDiagnostic(holder, transactions));
401
+ if (holder?.running !== true)
402
+ // A command that ended since the listing removed its folder with the lock.
403
+ for (const path of transactions)
404
+ if (await exists(await securePath(root, path)))
405
+ result.diagnostics.push({
406
+ code: 'RECOVERY_REQUIRED',
407
+ severity: 'error',
408
+ path,
409
+ plugin: null,
410
+ hint: 'an interrupted change left its transaction folder (backups of the originals). Never delete it or the lock; ask the user to look',
411
+ });
412
+ return true;
413
+ });
117
414
  if (state) {
415
+ const ownRules = [];
118
416
  for (const plugin of state.plugins) {
119
417
  const path = 'node_modules/' + plugin.packageName + '/package.json';
120
418
  const installed = await check(path, async () => readManifest(parseObject(await read(path), 'plugin')), plugin.id);
@@ -131,20 +429,27 @@ export class ProjectInspector {
131
429
  issue('MISSING_PACKAGE_DEPENDENCY', 'package.json', plugin.id);
132
430
  if (installed)
133
431
  matches(installed.packageName, installed.version, plugin.id);
134
- const options = config && Object.hasOwn(config.options, plugin.id)
135
- ? config.options[plugin.id]
136
- : undefined;
137
- const checked = installed
432
+ // D-0151: the logger host's options are options.logging.
433
+ const key = optionsKey(plugin);
434
+ const options = config && Object.hasOwn(config.options, key) ? config.options[key] : undefined;
435
+ const checked = installed && !configMissing
138
436
  ? await check(path, async () => optionsValidator.check(root, installed, options ?? {}), plugin.id)
139
437
  : null;
140
438
  if (checked?.errors.length)
141
439
  result.diagnostics.push({
142
440
  code: 'INVALID_PLUGIN_OPTIONS',
143
441
  severity: 'error',
144
- path: CONFIG_FILE,
442
+ path: configFile,
145
443
  plugin: plugin.id,
146
444
  details: checked.errors,
445
+ // D-0153: unknown keys name `config unset`, moved ones where they went.
446
+ // The commands name the section (options.logging for the logger host, D-0151).
447
+ hint: optionsFix(plugin.id, checked.errors, `correct options.${optionsKey(plugin)} in dokebi.yaml (npx dokebi plugins info ${plugin.id} --json lists the option keys)`, optionsKey(plugin), checked.schema),
147
448
  });
449
+ // D-0162: the plugin's own rules once its section passes the schema (checked below, all
450
+ // plugins in one restricted child).
451
+ if (settings.runChecks && installed && config && checked && !checked.errors.length)
452
+ ownRules.push(installed);
148
453
  let documentation = null;
149
454
  if (installed?.documentation) {
150
455
  const candidate = 'node_modules/' + installed.packageName + '/' + installed.documentation;
@@ -178,7 +483,7 @@ export class ProjectInspector {
178
483
  result.diagnostics.push({
179
484
  code: 'MISSING_REQUIRED_PLUGIN',
180
485
  severity: 'error',
181
- path: CONFIG_FILE,
486
+ path: configFile,
182
487
  plugin: plugin.id,
183
488
  details: { requires: missing },
184
489
  });
@@ -189,7 +494,7 @@ export class ProjectInspector {
189
494
  result.diagnostics.push({
190
495
  code: 'PLUGIN_CHECK',
191
496
  severity: item.severity,
192
- path: CONFIG_FILE,
497
+ path: configFile,
193
498
  plugin: plugin.id,
194
499
  details: {
195
500
  code: item.code,
@@ -210,6 +515,38 @@ export class ProjectInspector {
210
515
  : [],
211
516
  });
212
517
  }
518
+ // D-0162: the plugins' own option rules (cross-field checks the schema cannot say), as each
519
+ // plugin runs them when it is created at start.
520
+ if (ownRules.length && config) {
521
+ const verdicts = await check(configFile, () => checkPluginsOptions(root, ownRules, config.options));
522
+ for (const plugin of ownRules) {
523
+ const verdict = verdicts?.get(plugin.id);
524
+ if (!verdict)
525
+ continue;
526
+ if ('failed' in verdict) {
527
+ result.diagnostics.push({
528
+ code: 'PLUGIN_OPTIONS_CHECK_FAILED',
529
+ severity: 'warning',
530
+ path: configFile,
531
+ plugin: plugin.id,
532
+ details: { code: verdict.failed },
533
+ hint: `the plugin's own option rules could not run here (${verdict.failed}); npx dokebi start checks them too`,
534
+ });
535
+ continue;
536
+ }
537
+ if (verdict.issues.length)
538
+ result.diagnostics.push({
539
+ code: 'INVALID_PLUGIN_OPTIONS',
540
+ severity: 'error',
541
+ path: configFile,
542
+ plugin: plugin.id,
543
+ details: verdict.issues,
544
+ hint: `correct options.${optionsKey(plugin)} in dokebi.yaml: ${verdict.issues
545
+ .map((one) => `${one.instancePath.slice(1).replaceAll('/', '.') || '(section)'} ${one.message}`)
546
+ .join('; ')} (the app refuses to start with this; npx dokebi plugins info ${plugin.id} --json lists the option keys)`,
547
+ });
548
+ }
549
+ }
213
550
  if (core)
214
551
  await check('.dokebi/state.json', async () => orderPlugins(state.plugins, core, result.requiredPlugins));
215
552
  // D-0065 §11: only CLI-owned files (`owner: 'cli'`, regenerated by the CLI or a plugin) are
@@ -232,11 +569,109 @@ export class ProjectInspector {
232
569
  }
233
570
  for (const [path, expected] of originals) {
234
571
  await check(path, async () => {
235
- if (hash(await readFile(await installedPath(root, path))) !== expected)
572
+ const text = path === configFile
573
+ ? await readProjectConfigSource(root, settingsTarget.path)
574
+ : await readFile(await installedPath(root, path));
575
+ if (hash(text) !== expected)
236
576
  throw new CliError('CONCURRENT_CHANGE', 'Metadata changed during inspection');
237
577
  return true;
238
578
  });
239
579
  }
580
+ // A file an older recipe wrote at a place the current layout moved: it keeps working.
581
+ for (const [path, moveTo] of Object.entries(MOVED_RECIPE_FILES)) {
582
+ const present = await check(path, async () => exists(await securePath(root, path)));
583
+ if (present)
584
+ result.diagnostics.push({
585
+ code: 'LEGACY_LAYOUT',
586
+ severity: 'warning',
587
+ path,
588
+ plugin: null,
589
+ details: { moveTo },
590
+ hint: `${path} was written by an older recipe and keeps working; the src root holds only main.ts and dokebi.generated.ts. Move it to ${moveTo}, fix its relative imports and change its import in src/main.ts to './${moveTo.slice('src/'.length)}'`,
591
+ });
592
+ }
593
+ // S-c: files copied from a template once are the app's, so a later fix of the template never
594
+ // reaches them. The fixes the installed template carries and the copy lacks are named
595
+ // (template-fixes.ts).
596
+ const installedId = (id) => result.plugins.find((item) => item.installed?.id === id)?.installed;
597
+ const appsHost = state?.plugins.find((plugin) => plugin.hosts?.includes('apps'));
598
+ const drift = await check('src/infra/db/adapters', () => templateFixes(root, root, {
599
+ db: installedId('dokebi-db')?.packageName,
600
+ apps: appsHost ? { id: appsHost.id, packageName: appsHost.packageName } : undefined,
601
+ inertia: installedId('dokebi-template-inertia') !== undefined,
602
+ }));
603
+ if (drift)
604
+ result.diagnostics.push(...drift);
605
+ // D-0154: an entry that does not pass configFile cannot read dokebi.yaml again; dokebi dev then
606
+ // restarts on every change of it (S-c). In cluster mode the workers' app is built in
607
+ // src/infra/runtime/application.ts (createApplication): a worker without configFile cannot
608
+ // take the reload its primary asks for (Studio v2, 10-31: only src/main.ts was read).
609
+ for (const entry of ['src/main.ts', 'src/infra/runtime/application.ts']) {
610
+ const text = await check(entry, async () => (await exists(await securePath(root, entry)))
611
+ ? await readFile(await securePath(root, entry), 'utf8')
612
+ : undefined);
613
+ // The code without comments (R13): a word in a comment wires nothing.
614
+ const code = typeof text === 'string'
615
+ ? text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`])\/\/.*$/gm, '$1')
616
+ : undefined;
617
+ if (code !== undefined &&
618
+ /\bnew\s+(?:Application|ClusterRunner)\s*(?:<[^>]*>)?\s*\(/.test(code) &&
619
+ !/\bconfigFile\s*[:,}]/.test(code))
620
+ result.diagnostics.push({
621
+ code: 'RELOAD_NOT_WIRED',
622
+ severity: 'warning',
623
+ path: entry,
624
+ plugin: null,
625
+ hint: entry === 'src/main.ts'
626
+ ? 'src/main.ts passes no configFile to new Application (or ClusterRunner), so the app never reads dokebi.yaml again: options marked reloadable need a restart, and dokebi dev restarts on every change. Add configFile: the path of dokebi.yaml, as the generated main.ts does (D-0154)'
627
+ : `${entry} builds the workers' app with new Application and no configFile, so a worker cannot take the reload its primary asks for (the reload fails): pass configFile: the path of dokebi.yaml (process.env.DOKEBI_CONFIG || new URL('../../../dokebi.yaml', import.meta.url)), as @dokebi/cluster AI.md shows (D-0154)`,
628
+ });
629
+ }
630
+ // AI-M23: an Inertia app made before `app add --inertia` prepared the Vue type check.
631
+ if (pkg) {
632
+ const inertiaApps = await check('src/apps', () => inertiaAppNames(root));
633
+ // D-0129: a template-inertia without the dev file policy (no dist/vite-files.js) lets the
634
+ // Vite dev server of `dokebi dev` serve the whole project root, dokebi.yaml included; with
635
+ // devHosts (a tunnel) that is public, so it is an error then.
636
+ // Only an installed package is judged (a missing one is reported above).
637
+ const inertia = result.plugins.find((item) => item.installed?.id === 'dokebi-template-inertia')?.installed;
638
+ if (inertiaApps?.length && inertia) {
639
+ const policy = `node_modules/${inertia.packageName}/dist/vite-files.js`;
640
+ const guarded = await check(policy, async () => exists(await installedPath(root, policy)));
641
+ if (guarded === false) {
642
+ const section = config?.options['dokebi-template-inertia'];
643
+ const devHosts = section && typeof section === 'object' && !Array.isArray(section)
644
+ ? section.devHosts
645
+ : undefined;
646
+ const tunnel = Array.isArray(devHosts) && devHosts.length > 0;
647
+ debug('Inertia dev server without the file policy', { apps: inertiaApps, tunnel });
648
+ result.diagnostics.push({
649
+ code: 'DEV_FILES_UNGUARDED',
650
+ severity: tunnel ? 'error' : 'warning',
651
+ path: `node_modules/${inertia.packageName}`,
652
+ plugin: inertia.id,
653
+ details: { apps: inertiaApps, devHosts: tunnel ? devHosts.length : 0 },
654
+ hint: `the installed ${inertia.packageName} has no dev file policy (D-0129): its Vite dev server answers every file of the project root (GET /build/dokebi.yaml?raw returns the configuration with its secrets, /build/src/main.ts?raw the server code)${tunnel ? ', and devHosts makes that public through the tunnel' : ''}. Upgrade it (npx dokebi upgrade ${inertia.id}); until then do not run dokebi dev behind devHosts, and change any secret that was reachable that way`,
655
+ });
656
+ }
657
+ }
658
+ if (inertiaApps?.length &&
659
+ !(typeof typecheckScript === 'string' &&
660
+ /\b(?:vue-tsc|typecheck-vue\.mjs)\b/.test(typecheckScript))) {
661
+ // The first apps host (more than one is HOST_CONFLICT elsewhere): its scaffold folder.
662
+ const host = state?.plugins.find((plugin) => plugin.hosts?.includes('apps'));
663
+ const templates = `node_modules/${host?.packageName ?? '@dokebi/app'}/templates/inertia-build`;
664
+ debug('Inertia apps without a Vue type check', { apps: inertiaApps });
665
+ result.diagnostics.push({
666
+ code: 'VUE_TYPECHECK_MISSING',
667
+ severity: 'warning',
668
+ path: 'package.json',
669
+ plugin: null,
670
+ details: { apps: inertiaApps, templates },
671
+ hint: `npm run typecheck checks no .vue file. Add the Vue check that dokebi app add --inertia now prepares: the devDependencies vue-tsc and @typescript/typescript6 at the versions of ${templates}/build.json, ${templates}/tsconfig.vue.json and typecheck-vue.mjs copied to the project root, ${templates}/page.d.ts copied to src/infra/inertia/page.d.ts, and " && node typecheck-vue.mjs -p tsconfig.vue.json" after the tsc of the typecheck script. They are part of the framework scaffold and need no separate permission`,
672
+ });
673
+ }
674
+ }
240
675
  // D-0065: AGENTS.md carries the CLI-maintained installed list between two markers.
241
676
  await check(AGENTS_FILE, async () => {
242
677
  const path = await securePath(root, AGENTS_FILE);
@@ -259,12 +694,25 @@ export class ProjectInspector {
259
694
  plugin: null,
260
695
  hint: `move notes outside ${INSTALLED_START} … ${INSTALLED_END} and restore the rows (one per installed package: npx dokebi inspect --json); add, upgrade and remove rewrite them`,
261
696
  });
697
+ // D-0077 G: what is inside the project block is never judged, only that its markers can
698
+ // be found (one start, then one end, outside the installed list).
699
+ if (text !== null && projectBlockState(text) === 'invalid')
700
+ result.diagnostics.push({
701
+ code: 'GUIDE_PROJECT_BLOCK_INVALID',
702
+ severity: 'warning',
703
+ path: AGENTS_FILE,
704
+ plugin: null,
705
+ hint: `keep exactly one ${PROJECT_START} line and, after it, one ${PROJECT_END} line in AGENTS.md, outside the dokebi:installed block (remove the extra or misplaced marker, keep your text); until then add, upgrade and remove leave AGENTS.md alone`,
706
+ });
262
707
  return true;
263
708
  });
264
709
  await check('.dokebi/lock', async () => {
265
710
  if ((await exists(await securePath(root, '.dokebi/lock'))) &&
266
- !result.diagnostics.some((item) => item.code === 'PROJECT_LOCKED'))
267
- issue('PROJECT_LOCKED', '.dokebi/lock');
711
+ !result.diagnostics.some((item) => item.code === 'PROJECT_LOCKED')) {
712
+ const late = await lockHolder(root);
713
+ if (late)
714
+ result.diagnostics.push(lockDiagnostic(late, []));
715
+ }
268
716
  return true;
269
717
  });
270
718
  result.healthy = !result.diagnostics.some((item) => item.severity === 'error');
@@ -30,6 +30,11 @@ export interface ProjectState {
30
30
  apps?: string[];
31
31
  /** Modules in registration order (src/shared/modules/<name>), D-0032, for the modules host. */
32
32
  modules?: string[];
33
+ /**
34
+ * D-0155: the settings sections of the application (options.<section>), declared in
35
+ * src/shared/settings/<section>.ts with their schema beside it.
36
+ */
37
+ settings?: string[];
33
38
  }
34
39
  /** App and template names: lower-case letters, digits and hyphens, starting with a letter. */
35
40
  export declare const APP_NAME: RegExp;