@dokebi/cli 0.4.2 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/AI.md +279 -57
  2. package/README.md +35 -10
  3. package/catalog.json +2520 -258
  4. package/describe.schema.json +748 -0
  5. package/dist/api-app.d.ts +2 -0
  6. package/dist/api-app.js +35 -0
  7. package/dist/app-urls.js +3 -2
  8. package/dist/bin.js +638 -37
  9. package/dist/capability-guide.d.ts +30 -0
  10. package/dist/capability-guide.js +122 -0
  11. package/dist/catalog.d.ts +16 -2
  12. package/dist/catalog.js +19 -7
  13. package/dist/check/asset-rules.d.ts +6 -0
  14. package/dist/check/asset-rules.js +163 -0
  15. package/dist/check/browser-sources.d.ts +20 -0
  16. package/dist/check/browser-sources.js +89 -0
  17. package/dist/check/console-rules.d.ts +6 -0
  18. package/dist/check/console-rules.js +40 -0
  19. package/dist/check/error-rules.d.ts +21 -0
  20. package/dist/check/error-rules.js +212 -0
  21. package/dist/check/form-rules.d.ts +12 -0
  22. package/dist/check/form-rules.js +69 -6
  23. package/dist/check/formatter-rules.d.ts +2 -0
  24. package/dist/check/formatter-rules.js +71 -0
  25. package/dist/check/i18n-rules.d.ts +27 -0
  26. package/dist/check/i18n-rules.js +353 -0
  27. package/dist/check/index.d.ts +15 -1
  28. package/dist/check/index.js +89 -11
  29. package/dist/check/mail-rules.d.ts +4 -0
  30. package/dist/check/mail-rules.js +72 -0
  31. package/dist/check/module-rules.d.ts +13 -0
  32. package/dist/check/module-rules.js +104 -0
  33. package/dist/check/packages.d.ts +8 -0
  34. package/dist/check/packages.js +16 -0
  35. package/dist/check/pool-rules.d.ts +8 -0
  36. package/dist/check/pool-rules.js +164 -0
  37. package/dist/check/problems.d.ts +6 -1
  38. package/dist/check/problems.js +100 -2
  39. package/dist/check/project-rules.d.ts +23 -3
  40. package/dist/check/project-rules.js +265 -23
  41. package/dist/check/seal-rules.d.ts +6 -0
  42. package/dist/check/seal-rules.js +68 -0
  43. package/dist/check/security-rules.d.ts +10 -0
  44. package/dist/check/security-rules.js +450 -0
  45. package/dist/check/shared-state-rules.d.ts +15 -0
  46. package/dist/check/shared-state-rules.js +117 -0
  47. package/dist/check/source-rules.d.ts +26 -3
  48. package/dist/check/source-rules.js +249 -28
  49. package/dist/check/structure-rules.d.ts +23 -0
  50. package/dist/check/structure-rules.js +502 -0
  51. package/dist/check/timer-rules.d.ts +7 -0
  52. package/dist/check/timer-rules.js +543 -0
  53. package/dist/check/typescript.d.ts +35 -5
  54. package/dist/check/typescript.js +163 -17
  55. package/dist/check/vite-rules.d.ts +2 -0
  56. package/dist/check/vite-rules.js +136 -0
  57. package/dist/check/wait-rules.d.ts +16 -0
  58. package/dist/check/wait-rules.js +337 -0
  59. package/dist/commands.d.ts +7 -0
  60. package/dist/commands.js +381 -36
  61. package/dist/config-diff.d.ts +42 -0
  62. package/dist/config-diff.js +289 -0
  63. package/dist/config-edit.d.ts +35 -2
  64. package/dist/config-edit.js +145 -13
  65. package/dist/config-init.d.ts +15 -0
  66. package/dist/config-init.js +61 -0
  67. package/dist/config-paths.d.ts +22 -0
  68. package/dist/config-paths.js +82 -0
  69. package/dist/config-secrets.d.ts +46 -0
  70. package/dist/config-secrets.js +187 -0
  71. package/dist/config-show.d.ts +82 -0
  72. package/dist/config-show.js +563 -0
  73. package/dist/config-values.d.ts +8 -0
  74. package/dist/config-values.js +68 -0
  75. package/dist/configuration-scaffold.d.ts +42 -0
  76. package/dist/configuration-scaffold.js +258 -4
  77. package/dist/configuration.d.ts +63 -3
  78. package/dist/configuration.js +178 -4
  79. package/dist/deploy-scripts.d.ts +4 -0
  80. package/dist/deploy-scripts.js +361 -0
  81. package/dist/deploy-templates.d.ts +46 -0
  82. package/dist/deploy-templates.js +379 -0
  83. package/dist/deploy.d.ts +46 -0
  84. package/dist/deploy.js +592 -0
  85. package/dist/deployment.d.ts +2 -0
  86. package/dist/deployment.js +111 -0
  87. package/dist/describe-db.d.ts +22 -0
  88. package/dist/describe-db.js +176 -0
  89. package/dist/describe.d.ts +92 -0
  90. package/dist/describe.js +417 -0
  91. package/dist/dev-compose.d.ts +20 -0
  92. package/dist/dev-compose.js +154 -0
  93. package/dist/dev-guard.js +44 -0
  94. package/dist/dev-host.d.ts +13 -0
  95. package/dist/dev-host.js +55 -0
  96. package/dist/dev-infra.d.ts +72 -4
  97. package/dist/dev-infra.js +237 -17
  98. package/dist/dev-watch.d.ts +34 -0
  99. package/dist/dev-watch.js +192 -0
  100. package/dist/diagnostics.d.ts +10 -7
  101. package/dist/diagnostics.js +22 -4
  102. package/dist/drift.d.ts +50 -0
  103. package/dist/drift.js +218 -0
  104. package/dist/env-file.d.ts +25 -1
  105. package/dist/env-file.js +64 -6
  106. package/dist/errors.d.ts +7 -1
  107. package/dist/example-config.d.ts +2 -0
  108. package/dist/example-config.js +3 -0
  109. package/dist/first-admin.d.ts +43 -0
  110. package/dist/first-admin.js +175 -0
  111. package/dist/formatter-ignore.d.ts +37 -0
  112. package/dist/formatter-ignore.js +344 -0
  113. package/dist/generator.d.ts +45 -3
  114. package/dist/generator.js +205 -15
  115. package/dist/guide.d.ts +1 -1
  116. package/dist/guide.js +42 -21
  117. package/dist/install-module.d.ts +20 -2
  118. package/dist/install-module.js +45 -8
  119. package/dist/install-plan.js +5 -3
  120. package/dist/layout.d.ts +12 -1
  121. package/dist/layout.js +19 -6
  122. package/dist/log-writer-control.d.ts +2 -0
  123. package/dist/log-writer-control.js +17 -2
  124. package/dist/log-writer.js +43 -10
  125. package/dist/logging-config.d.ts +8 -3
  126. package/dist/logging-config.js +19 -5
  127. package/dist/manifest.d.ts +29 -1
  128. package/dist/manifest.js +84 -6
  129. package/dist/merge-install-defaults.d.ts +0 -1
  130. package/dist/merge-install-defaults.js +13 -47
  131. package/dist/npm-client.d.ts +40 -4
  132. package/dist/npm-client.js +152 -15
  133. package/dist/npm-paths.js +18 -4
  134. package/dist/openapi.d.ts +27 -0
  135. package/dist/openapi.js +649 -0
  136. package/dist/option-fixes.d.ts +39 -0
  137. package/dist/option-fixes.js +130 -0
  138. package/dist/output.d.ts +1 -1
  139. package/dist/output.js +5 -5
  140. package/dist/plugin-lifecycle.d.ts +34 -2
  141. package/dist/plugin-lifecycle.js +195 -16
  142. package/dist/plugin-options.d.ts +19 -4
  143. package/dist/plugin-options.js +138 -6
  144. package/dist/plugins-command.js +44 -7
  145. package/dist/process-group.d.ts +39 -0
  146. package/dist/process-group.js +132 -0
  147. package/dist/production-doctor.d.ts +3 -0
  148. package/dist/production-doctor.js +176 -0
  149. package/dist/project-guide.d.ts +19 -1
  150. package/dist/project-guide.js +119 -20
  151. package/dist/project-inspector.d.ts +15 -0
  152. package/dist/project-inspector.js +497 -33
  153. package/dist/project-state.d.ts +5 -0
  154. package/dist/project-state.js +8 -0
  155. package/dist/project.d.ts +203 -19
  156. package/dist/project.js +3006 -333
  157. package/dist/recipe-facts.d.ts +127 -0
  158. package/dist/recipe-facts.js +563 -0
  159. package/dist/recipe-login.d.ts +63 -0
  160. package/dist/recipe-login.js +464 -0
  161. package/dist/recipe-mail.d.ts +21 -0
  162. package/dist/recipe-mail.js +214 -0
  163. package/dist/recipe-members.d.ts +35 -0
  164. package/dist/recipe-members.js +178 -0
  165. package/dist/recipe-record.d.ts +82 -7
  166. package/dist/recipe-record.js +226 -22
  167. package/dist/recipe-reset.d.ts +2 -0
  168. package/dist/recipe-reset.js +117 -0
  169. package/dist/recipe-site.d.ts +48 -0
  170. package/dist/recipe-site.js +396 -0
  171. package/dist/recipe-step.d.ts +42 -0
  172. package/dist/recipe-step.js +89 -0
  173. package/dist/recipe.d.ts +56 -20
  174. package/dist/recipe.js +703 -131
  175. package/dist/registry.d.ts +98 -2
  176. package/dist/registry.js +171 -5
  177. package/dist/reloadable.d.ts +35 -0
  178. package/dist/reloadable.js +257 -0
  179. package/dist/remote-verify.d.ts +31 -0
  180. package/dist/remote-verify.js +204 -0
  181. package/dist/server.d.ts +43 -4
  182. package/dist/server.js +601 -52
  183. package/dist/settings.d.ts +48 -0
  184. package/dist/settings.js +168 -0
  185. package/dist/site-auth.d.ts +39 -0
  186. package/dist/site-auth.js +195 -0
  187. package/dist/template-fixes.d.ts +26 -0
  188. package/dist/template-fixes.js +285 -0
  189. package/dist/test-reporter.d.ts +49 -0
  190. package/dist/test-reporter.js +190 -0
  191. package/dist/transaction.d.ts +47 -0
  192. package/dist/transaction.js +241 -7
  193. package/dist/verify-tests.d.ts +98 -0
  194. package/dist/verify-tests.js +449 -0
  195. package/dist/verify.d.ts +103 -3
  196. package/dist/verify.js +272 -21
  197. package/dist/view-choice.d.ts +58 -0
  198. package/dist/view-choice.js +131 -0
  199. package/dist/vite-watch.js +4 -2
  200. package/package.json +8 -6
package/dist/bin.js CHANGED
@@ -1,18 +1,94 @@
1
1
  #!/usr/bin/env node
2
+ // dokebi-check: allow console-output the CLI prints its human and JSON output on stdout and stderr (D-0151)
3
+ // The modules of a command load when that command runs (`await import(…)` in its branch): the
4
+ // project, server, recipe, check and verify modules pull in yaml, semver and @dokebi/core, which
5
+ // doubled the start of `--help`, `--version` and `guide` (about 80 ms against 35 ms) although
6
+ // they need none of it. Only the light modules every command uses are imported here.
2
7
  import { writeBanner } from './banner.js';
3
8
  import { loadCatalog } from './catalog.js';
4
- import { check, checkText } from './check/index.js';
5
- import { COMMAND_DETAILS, COMMANDS } from './commands.js';
6
- import { parseSetOption } from './config-edit.js';
9
+ import { COMMAND_DETAILS, COMMANDS, commandHasFlag, DRY_RUN_COMMANDS, dryRunList, } from './commands.js';
7
10
  import { CliError } from './errors.js';
8
11
  import { buildGuide, HELP_POINTER } from './guide.js';
9
12
  import { CLI_VERSION, envelope, failure } from './output.js';
10
- import { pluginsDoc, pluginsInfo, pluginsList, pluginsSearch } from './plugins-command.js';
11
- import { Project } from './project.js';
12
- import { ProjectInspector } from './project-inspector.js';
13
- import { listRecipes, runRecipe } from './recipe.js';
14
- import * as server from './server.js';
15
- import { verify } from './verify.js';
13
+ /** A Project, with its module (and what it imports) loaded on first use. */
14
+ async function newProject() {
15
+ const { Project } = await import('./project.js');
16
+ return new Project();
17
+ }
18
+ /** `--set` and config paths are parsed by config-edit.js, which loads yaml. */
19
+ /** Bytes of a secret `config secret --stdin` reads at most. */
20
+ const MAX_SECRET_INPUT = 65_536;
21
+ /**
22
+ * The secret of `config secret --stdin` (D-0151 follow-up): piped stdin up to its end, or on a
23
+ * terminal one line typed without echo. One line; the final line break is not part of it. The
24
+ * value is never printed or logged.
25
+ */
26
+ async function readSecretInput() {
27
+ const stdin = process.stdin;
28
+ const tooLarge = () => new CliError('INVALID_SET', 'The secret on stdin is larger than 64 KiB', {
29
+ phase: 'arguments',
30
+ });
31
+ let text = '';
32
+ if (stdin.isTTY) {
33
+ process.stderr.write('Secret (input hidden, Enter to finish): ');
34
+ text = await new Promise((resolve, reject) => {
35
+ let typed = '';
36
+ const finish = (error) => {
37
+ stdin.setRawMode(false);
38
+ stdin.off('data', onData);
39
+ stdin.pause();
40
+ process.stderr.write('\n');
41
+ if (error)
42
+ reject(error);
43
+ else
44
+ resolve(typed);
45
+ };
46
+ const onData = (chunk) => {
47
+ for (const char of chunk) {
48
+ if (char === '\r' || char === '\n' || char === '\u0004') {
49
+ finish();
50
+ return;
51
+ }
52
+ if (char === '\u0003') {
53
+ finish(new CliError('USAGE', 'config secret --stdin was interrupted'));
54
+ return;
55
+ }
56
+ typed = char === '\u007f' || char === '\b' ? typed.slice(0, -1) : typed + char;
57
+ if (Buffer.byteLength(typed) > MAX_SECRET_INPUT) {
58
+ finish(tooLarge());
59
+ return;
60
+ }
61
+ }
62
+ };
63
+ stdin.setRawMode(true);
64
+ stdin.setEncoding('utf8');
65
+ stdin.on('data', onData);
66
+ stdin.resume();
67
+ });
68
+ }
69
+ else {
70
+ const chunks = [];
71
+ let size = 0;
72
+ for await (const chunk of stdin) {
73
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
74
+ size += buffer.length;
75
+ if (size > MAX_SECRET_INPUT)
76
+ throw tooLarge();
77
+ chunks.push(buffer);
78
+ }
79
+ text = Buffer.concat(chunks).toString('utf8');
80
+ }
81
+ const value = text.replace(/\r?\n$/, '');
82
+ if (!value || /[\r\n]/.test(value))
83
+ throw new CliError('INVALID_SET', 'config secret --stdin takes one line on stdin (the secret); it got none or several', {
84
+ phase: 'arguments',
85
+ details: { fix: 'pipe the value from a file or a password manager, or type it when asked' },
86
+ });
87
+ return value;
88
+ }
89
+ async function setParser() {
90
+ return (await import('./config-edit.js')).parseSetOption;
91
+ }
16
92
  /** The usage lines only (their format is stable; tools parse them). */
17
93
  const USAGE_LINES = COMMANDS.map((item) => item.usage).join('\n') + '\n';
18
94
  const HELP = `${HELP_POINTER}\n${COMMANDS.map((item) => `${item.usage}\n ${item.summary}`).join('\n')}\n\ndokebi <command> --help shows one command; dokebi --version prints the CLI version.\n`;
@@ -102,7 +178,7 @@ function flags(input, allowed) {
102
178
  const value = input[++index];
103
179
  if (value === undefined || value.startsWith('--'))
104
180
  throw new CliError('USAGE', USAGE_LINES);
105
- sets.push(parseSetOption(value));
181
+ sets.push(allowed.set(value));
106
182
  }
107
183
  else if (arg === '--to' && allowed.to && to === undefined) {
108
184
  const value = input[++index];
@@ -116,6 +192,12 @@ function flags(input, allowed) {
116
192
  throw new CliError('USAGE', USAGE_LINES);
117
193
  add.adapter = value;
118
194
  }
195
+ else if (arg === '--startup' && allowed.add && add.startup === undefined) {
196
+ const value = input[++index];
197
+ if (value !== 'required' && value !== 'optional')
198
+ throw new CliError('USAGE', USAGE_LINES);
199
+ add.startup = value;
200
+ }
119
201
  else if (arg === '--no-first-app' && allowed.add && add.firstApp === undefined)
120
202
  add.firstApp = false;
121
203
  else
@@ -156,7 +238,30 @@ async function main() {
156
238
  : value + (value.endsWith('\n') ? '' : '\n'));
157
239
  return;
158
240
  }
241
+ // AI-M04 F8: --dry-run on a command without it names the commands that take it.
242
+ if (args.includes('--dry-run')) {
243
+ const known = (name) => COMMANDS.some((item) => item.name === name);
244
+ const sub = args[0] && !args[0].startsWith('-') ? `${command} ${args[0]}` : undefined;
245
+ const name = sub && known(sub) ? sub : known(command) ? command : undefined;
246
+ if (name && !commandHasFlag(name, '--dry-run'))
247
+ throw new CliError('USAGE', `dokebi ${name} takes no --dry-run; only these commands do: ${dryRunList()}\n${USAGE_LINES}`, {
248
+ phase: 'arguments',
249
+ details: {
250
+ command: name,
251
+ dryRunCommands: DRY_RUN_COMMANDS,
252
+ hint: `dokebi ${name} --help shows its flags`,
253
+ },
254
+ });
255
+ }
159
256
  if (command === 'guide') {
257
+ if (args.includes('--sync')) {
258
+ if (args.some((arg) => arg !== '--sync' && arg !== '--dry-run'))
259
+ throw new CliError('USAGE', USAGE_LINES);
260
+ const dryRun = args.includes('--dry-run');
261
+ mutationStarted = !dryRun;
262
+ print(command, await (await newProject()).syncGuide(process.cwd(), dryRun));
263
+ return;
264
+ }
160
265
  if (args.length)
161
266
  throw new CliError('USAGE', USAGE_LINES);
162
267
  const guide = buildGuide(await loadCatalog());
@@ -172,22 +277,142 @@ async function main() {
172
277
  await recipeCommand(args);
173
278
  return;
174
279
  }
280
+ if (command === 'mail') {
281
+ const subcommand = args.shift();
282
+ if (subcommand !== 'preview')
283
+ throw unknownCommand(subcommand ?? '', ['preview'], 'mail ');
284
+ const name = args.shift();
285
+ if (!name || name.startsWith('-'))
286
+ throw new CliError('USAGE', USAGE_LINES);
287
+ let locale;
288
+ if (args[0] === '--locale' && args[1] && !args[1].startsWith('-')) {
289
+ args.shift();
290
+ locale = args.shift();
291
+ }
292
+ if (args.length)
293
+ throw new CliError('USAGE', USAGE_LINES);
294
+ const { describeProject } = await import('./describe.js');
295
+ const described = await describeProject(process.cwd(), {
296
+ mailPreview: { name, ...(locale ? { locale } : {}) },
297
+ });
298
+ const preview = described.app['mail-preview'];
299
+ if (!preview) {
300
+ const unavailable = described.unavailable.find((item) => item.plugin === 'cli-mail-preview' || item.plugin === 'dokebi-mail');
301
+ throw new CliError(unavailable?.code ?? 'MAIL_PREVIEW_FAILED', unavailable?.reason ?? 'Mail preview is unavailable');
302
+ }
303
+ print('mail preview', preview, JSON.stringify(preview, null, 2));
304
+ return;
305
+ }
306
+ if (command === 'verify' && args.includes('--remote')) {
307
+ // D-0184: a deployed site only; nothing local runs.
308
+ const at = args.indexOf('--remote');
309
+ const url = args[at + 1];
310
+ if (url === undefined || url.startsWith('-'))
311
+ throw new CliError('USAGE', USAGE_LINES);
312
+ const options = serverFlags(args.filter((_, index) => index !== at && index !== at + 1), { '--timeout-ms': [100, 120_000] }, []);
313
+ const { remoteVerify, remoteVerifyText } = await import('./remote-verify.js');
314
+ const timeoutMs = options.numbers.get('--timeout-ms');
315
+ const result = await remoteVerify(url, {
316
+ root: process.cwd(),
317
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
318
+ });
319
+ print('verify', result, remoteVerifyText(result), result.ok);
320
+ if (!result.ok)
321
+ process.exitCode = 1;
322
+ return;
323
+ }
324
+ if (command === 'deploy') {
325
+ await deployCommand(args);
326
+ return;
327
+ }
175
328
  if (command === 'verify') {
176
- const options = serverFlags(args, { '--wait-ms': undefined }, []);
329
+ const options = serverFlags(args, { '--wait-ms': undefined, '--test-timeout-ms': [1000, 3_600_000] }, []);
177
330
  const waitMs = options.numbers.get('--wait-ms');
178
- const result = await verify(process.cwd(), waitMs !== undefined ? { waitMs } : {});
331
+ const testTimeoutMs = options.numbers.get('--test-timeout-ms');
332
+ const [{ verify }, { testLines }, { TELL_USER_RELAY }] = await Promise.all([
333
+ import('./verify.js'),
334
+ import('./verify-tests.js'),
335
+ import('./recipe-record.js'),
336
+ ]);
337
+ const result = await verify(process.cwd(), {
338
+ ...(waitMs !== undefined ? { waitMs } : {}),
339
+ ...(testTimeoutMs !== undefined ? { testTimeoutMs } : {}),
340
+ });
179
341
  print(command, result, `${result.checks
180
- .map((check) => `${!check.ok ? 'FAIL' : check.skipped ? '-' : 'ok'} ${check.name} (${check.ms} ms)${check.skipped ? `: skipped, ${check.skipped}` : ''}${check.ok ? '' : `: ${check.code ?? ''} ${check.message ?? ''}`}`)
181
- .join('\n')}\n${result.ok ? 'verify ok' : 'verify FAILED'}${addressLines(result)}${result.tellUser
182
- ? `\nTell the user (your final message must include these lines):\n${result.tellUser.map((line) => `- ${line}`).join('\n')}`
342
+ .map((check) => `${!check.ok ? 'FAIL' : check.skipped ? '-' : 'ok'} ${check.name} (${check.ms} ms)${check.skipped ? `: skipped, ${check.skipped}` : ''}${check.ok ? '' : `: ${check.code ?? ''} ${check.message ?? ''}`}${check.name === 'test' && result.tests ? testLines(result.tests) : ''}`)
343
+ .join('\n')}\n${result.ok ? 'verify ok' : 'verify FAILED'}${addressLines(result)}${(result.warnings ?? [])
344
+ .map((warning) => `\nwarning: ${warning.code} (${warning.path}): ${warning.hint}`)
345
+ .join('')}${
346
+ // A taken port is the user's call (AI-M04 F5), as in the recipe preflight.
347
+ result.question
348
+ ? `\n${result.waitForUser ? 'ask the user' : 'question'}: ${result.question}\nnext: ${result.next}`
349
+ : ''}${result.tellUser
350
+ ? `\nTell the user exactly these lines. ${TELL_USER_RELAY.instruction}\n${result.tellUser.map((line) => `- ${line}`).join('\n')}`
351
+ : ''}${
352
+ // D-0105 §4: what the agent writes into its final message itself (not copied).
353
+ result.finalReport
354
+ ? `\nYour final message must also contain:\n${result.finalReport.map((line) => `- ${line}`).join('\n')}`
183
355
  : ''}`, result.ok);
184
356
  if (!result.ok)
185
357
  process.exitCode = 1;
186
358
  return;
187
359
  }
360
+ if (command === 'describe') {
361
+ // Read only (D-0077 B): the app's setup in describe mode; nothing listens or connects.
362
+ // D-0077 C: --openapi [--app <name>] prints OpenAPI 3.1 documents instead.
363
+ const appAt = args.indexOf('--app');
364
+ const appName = appAt < 0 ? undefined : args[appAt + 1];
365
+ if (appAt >= 0 && (appName === undefined || appName.startsWith('-')))
366
+ throw new CliError('USAGE', `--app needs an app name\n${USAGE_LINES}`);
367
+ const rest = appAt < 0 ? args : args.filter((_, index) => index !== appAt && index !== appAt + 1);
368
+ const options = serverFlags(rest, { '--timeout-ms': [1000, 600_000] }, [
369
+ '--with-db',
370
+ '--openapi',
371
+ ]);
372
+ if (appName !== undefined && !options.switches.has('--openapi'))
373
+ throw new CliError('USAGE', `--app is for --openapi\n${USAGE_LINES}`);
374
+ const timeoutMs = options.numbers.get('--timeout-ms');
375
+ const { describeProject, describeText } = await import('./describe.js');
376
+ const result = await describeProject(process.cwd(), {
377
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
378
+ // D-0077 F: --with-db also reads the tables (a separate step that connects).
379
+ withDb: options.switches.has('--with-db'),
380
+ });
381
+ if (options.switches.has('--openapi')) {
382
+ const { openApi, projectVersion } = await import('./openapi.js');
383
+ const version = projectVersion(process.cwd());
384
+ const documents = openApi(result, {
385
+ ...(appName === undefined ? {} : { app: appName }),
386
+ ...(version === undefined ? {} : { version }),
387
+ });
388
+ print(command, documents, JSON.stringify(documents, null, 2));
389
+ return;
390
+ }
391
+ print(command, result, describeText(result));
392
+ return;
393
+ }
394
+ if (command === 'drift') {
395
+ // D-0120: describe --with-db (it connects) against an expected file of the describe shape.
396
+ const at = args.indexOf('--expected');
397
+ const expected = at < 0 ? undefined : args[at + 1];
398
+ if (expected === undefined || expected.startsWith('-'))
399
+ throw new CliError('USAGE', `--expected needs a file\n${USAGE_LINES}`);
400
+ const options = serverFlags(args.filter((_, index) => index !== at && index !== at + 1), { '--timeout-ms': [1000, 600_000] }, []);
401
+ const timeoutMs = options.numbers.get('--timeout-ms');
402
+ const { drift, driftText } = await import('./drift.js');
403
+ const result = await drift(process.cwd(), {
404
+ expected,
405
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
406
+ });
407
+ print(command, result, driftText(result), result.ok);
408
+ if (!result.ok)
409
+ process.exitCode = 1;
410
+ return;
411
+ }
188
412
  if (command === 'check') {
189
413
  if (args.length)
190
414
  throw new CliError('USAGE', USAGE_LINES);
415
+ const { check, checkText } = await import('./check/index.js');
191
416
  const result = await check(process.cwd());
192
417
  print(command, result, checkText(result), result.ok);
193
418
  if (!result.ok)
@@ -196,23 +421,140 @@ async function main() {
196
421
  }
197
422
  if (command === 'config') {
198
423
  const action = args.shift();
424
+ const parseSetOption = await setParser();
425
+ if (action === 'show') {
426
+ // Read only: secrets are hidden in every mode (config-show.ts).
427
+ if (args.length > 1 || args[0]?.startsWith('-'))
428
+ throw new CliError('USAGE', USAGE_LINES);
429
+ const target = args[0];
430
+ const dot = target?.indexOf('.') ?? -1;
431
+ if (target !== undefined && (dot === 0 || target.includes('=')))
432
+ throw new CliError('USAGE', USAGE_LINES);
433
+ const section = target === undefined ? undefined : dot > 0 ? target.slice(0, dot) : target;
434
+ // The path is checked like a --set path (segments, no prototype keys).
435
+ const path = target && dot > 0 ? parseSetOption(`${target.slice(dot + 1)}=0`).path : [];
436
+ const { configShow, configShowText } = await import('./config-show.js');
437
+ const result = await configShow(process.cwd(), section === undefined ? undefined : { section, path });
438
+ print('config show', result, configShowText(result));
439
+ return;
440
+ }
441
+ if (action === 'diff') {
442
+ // Read only (AI-M45): option paths of dokebi.yaml against dokebi.example.yaml, no values.
443
+ // --write (AI-M46) copies the paths only in dokebi.yaml into the sample, without secrets.
444
+ let write = false;
445
+ let dryRun = false;
446
+ let prune = false;
447
+ let values = false;
448
+ const rest = [];
449
+ for (const arg of args) {
450
+ if (arg === '--write' && !write)
451
+ write = true;
452
+ else if (arg === '--dry-run' && !dryRun)
453
+ dryRun = true;
454
+ else if (arg === '--prune' && !prune)
455
+ prune = true;
456
+ else if (arg === '--values' && !values)
457
+ values = true;
458
+ else
459
+ rest.push(arg);
460
+ }
461
+ if ((dryRun || prune || values) && !write)
462
+ throw new CliError('USAGE', USAGE_LINES);
463
+ if (rest.length > 1 || rest[0]?.startsWith('-') || rest[0]?.includes('.'))
464
+ throw new CliError('USAGE', USAGE_LINES);
465
+ const { configDiff, configDiffText } = await import('./config-diff.js');
466
+ if (write) {
467
+ mutationStarted = true;
468
+ const result = await (await newProject()).configSample(process.cwd(), rest[0], dryRun, prune, values);
469
+ const listed = (label, paths) => paths.length
470
+ ? `${label} [${paths.length}]:\n${paths.map((path) => ` ${path}`).join('\n')}\n`
471
+ : '';
472
+ print('config diff', result, `${listed('copied into dokebi.example.yaml', result.written) || 'nothing to copy into dokebi.example.yaml\n'}${values ? listed('values written into dokebi.example.yaml', result.valuesWritten ?? []) || 'no value to write into dokebi.example.yaml\n' : ''}${listed('removed from dokebi.example.yaml', result.pruned)}${configDiffText(result.diff)}`);
473
+ return;
474
+ }
475
+ const result = await configDiff(process.cwd(), rest[0]);
476
+ print('config diff', result, configDiffText(result));
477
+ return;
478
+ }
479
+ if (action === 'init') {
480
+ // D-0163: a fresh copy of the project (a clone) has no dokebi.yaml; written from the sample.
481
+ const dryRun = args.includes('--dry-run');
482
+ if (args.some((arg) => arg !== '--dry-run'))
483
+ throw new CliError('USAGE', USAGE_LINES);
484
+ mutationStarted = true;
485
+ const result = await (await newProject()).configInit(process.cwd(), dryRun);
486
+ print('config init', result);
487
+ return;
488
+ }
489
+ if (action === 'secrets') {
490
+ // D-0184: read only; names, never values.
491
+ if (args.length)
492
+ throw new CliError('USAGE', USAGE_LINES);
493
+ const { configSecrets, configSecretsText } = await import('./config-secrets.js');
494
+ const result = await configSecrets(process.cwd());
495
+ const ok = result.missing.length === 0;
496
+ print('config secrets', result, configSecretsText(result), ok);
497
+ if (!ok)
498
+ process.exitCode = 1;
499
+ return;
500
+ }
199
501
  if (action === 'secret') {
200
502
  const dryRun = args.includes('--dry-run');
201
- const rest = args.filter((arg) => arg !== '--dry-run');
503
+ const rotate = args.includes('--rotate');
504
+ const fromStdin = args.includes('--stdin');
505
+ const rest = args.filter((arg) => arg !== '--dry-run' && arg !== '--rotate' && arg !== '--stdin');
202
506
  const target = rest[0];
203
507
  const dot = target?.indexOf('.') ?? -1;
204
508
  if (rest.length !== 1 || !target || dot <= 0 || target.includes('='))
205
509
  throw new CliError('USAGE', USAGE_LINES);
206
510
  // The path is checked like a --set path (segments, no prototype keys).
207
511
  const path = parseSetOption(`${target.slice(dot + 1)}=0`).path;
512
+ // --stdin: a secret someone else issued (a log receiver's token), never on the command line.
513
+ const value = fromStdin ? await readSecretInput() : undefined;
208
514
  mutationStarted = true;
209
- const result = await new Project().configSecret(process.cwd(), target.slice(0, dot), path, dryRun);
515
+ const result = await (await newProject()).configSecret(process.cwd(), target.slice(0, dot), path, dryRun, rotate, value);
210
516
  print('config secret', result);
211
517
  return;
212
518
  }
519
+ if (action === 'unset') {
520
+ // D-0153: <plugin-id>.<path>...; never a value (no `=`). A section alone (no path) only
521
+ // for one no installed plugin reads (an old section left in the files).
522
+ let dryRun = false;
523
+ const targets = [];
524
+ for (const arg of args) {
525
+ if (arg === '--dry-run' && !dryRun)
526
+ dryRun = true;
527
+ else if (!arg.startsWith('-')) {
528
+ if (arg.includes('='))
529
+ throw new CliError('USAGE', USAGE_LINES);
530
+ const dot = arg.indexOf('.');
531
+ if (dot === 0)
532
+ throw new CliError('USAGE', USAGE_LINES);
533
+ // The path is checked like a --set path (segments, no prototype keys).
534
+ if (dot < 0) {
535
+ parseSetOption(`${arg}=0`);
536
+ targets.push({ plugin: arg, path: [] });
537
+ }
538
+ else
539
+ targets.push({
540
+ plugin: arg.slice(0, dot),
541
+ path: parseSetOption(`${arg.slice(dot + 1)}=0`).path,
542
+ });
543
+ }
544
+ else
545
+ throw new CliError('USAGE', USAGE_LINES);
546
+ }
547
+ if (!targets.length)
548
+ throw new CliError('USAGE', USAGE_LINES);
549
+ mutationStarted = true;
550
+ const result = await (await newProject()).configUnset(process.cwd(), targets, dryRun);
551
+ printWarnings(result.warnings);
552
+ print('config unset', result);
553
+ return;
554
+ }
213
555
  if (action !== 'set') {
214
556
  if (action && !action.startsWith('-'))
215
- throw unknownCommand(action, ['set', 'secret'], 'config ');
557
+ throw unknownCommand(action, ['diff', 'init', 'secret', 'secrets', 'set', 'show', 'unset'], 'config ');
216
558
  throw new CliError('USAGE', USAGE_LINES);
217
559
  }
218
560
  let dryRun = false;
@@ -234,17 +576,32 @@ async function main() {
234
576
  if (!assignments.length)
235
577
  throw new CliError('USAGE', USAGE_LINES);
236
578
  mutationStarted = true;
237
- const result = await new Project().configSet(process.cwd(), assignments, dryRun);
579
+ const result = await (await newProject()).configSet(process.cwd(), assignments, dryRun);
238
580
  printWarnings(result.warnings);
239
581
  print('config set', result);
240
582
  return;
241
583
  }
242
584
  if (command === 'dev-infra') {
585
+ if (args[0] && !args[0].startsWith('-')) {
586
+ const action = args[0];
587
+ args.shift();
588
+ const files = [];
589
+ while (args.length) {
590
+ if (args.shift() !== '--file' || !args[0])
591
+ throw new CliError('USAGE', USAGE_LINES);
592
+ files.push(args[0]);
593
+ args.shift();
594
+ }
595
+ mutationStarted = action !== 'status';
596
+ const { runDevCompose } = await import('./dev-compose.js');
597
+ print(command, await runDevCompose(process.cwd(), action, files.length ? files : undefined));
598
+ return;
599
+ }
243
600
  const dryRun = args.includes('--dry-run');
244
601
  const options = serverFlags(args.filter((arg) => arg !== '--dry-run'), { '--db-port': [1, 65535] }, []);
245
602
  mutationStarted = true;
246
603
  const dbPort = options.numbers.get('--db-port');
247
- const result = await new Project().devInfra(process.cwd(), {
604
+ const result = await (await newProject()).devInfra(process.cwd(), {
248
605
  dryRun,
249
606
  ...(dbPort !== undefined ? { dbPort } : {}),
250
607
  });
@@ -252,12 +609,31 @@ async function main() {
252
609
  return;
253
610
  }
254
611
  if (command === 'inspect' || command === 'doctor') {
255
- const verbose = args.length === 1 && args[0] === '--verbose';
256
- if (args.length && !verbose)
612
+ const verbose = args.includes('--verbose');
613
+ // D-0184: doctor --production adds the checks of a server.
614
+ const production = command === 'doctor' && args.includes('--production');
615
+ if (args.some((arg) => arg !== '--verbose' && !(arg === '--production' && production)) ||
616
+ new Set(args).size !== args.length)
257
617
  throw new CliError('USAGE', USAGE_LINES);
618
+ const { ProjectInspector } = await import('./project-inspector.js');
258
619
  const result = await new ProjectInspector().inspect(process.cwd(), {
259
620
  runChecks: command === 'doctor',
260
621
  });
622
+ if (production) {
623
+ const { productionDiagnostics } = await import('./production-doctor.js');
624
+ result.diagnostics.push(...(await productionDiagnostics(process.cwd())));
625
+ result.healthy = !result.diagnostics.some((item) => item.severity === 'error');
626
+ const codes = {};
627
+ for (const item of result.diagnostics)
628
+ codes[item.code] = (codes[item.code] ?? 0) + 1;
629
+ result.summary = {
630
+ ...result.summary,
631
+ healthy: result.healthy,
632
+ errors: result.diagnostics.filter((item) => item.severity === 'error').length,
633
+ warnings: result.diagnostics.filter((item) => item.severity === 'warning').length,
634
+ codes,
635
+ };
636
+ }
261
637
  const ok = command === 'inspect' || result.healthy;
262
638
  // --json is always complete (summary first); people get the short form unless --verbose.
263
639
  process.stdout.write(machine
@@ -273,7 +649,7 @@ async function main() {
273
649
  if (args.length && (args.length !== 1 || args[0] !== '--dry-run'))
274
650
  throw new CliError('USAGE', USAGE_LINES);
275
651
  mutationStarted = true;
276
- const result = await new Project().migrate(process.cwd(), args[0] === '--dry-run');
652
+ const result = await (await newProject()).migrate(process.cwd(), args[0] === '--dry-run');
277
653
  process.stdout.write(JSON.stringify(machine ? envelope(command, result) : result, null, 2) + '\n');
278
654
  return;
279
655
  }
@@ -281,7 +657,7 @@ async function main() {
281
657
  await app(args);
282
658
  return;
283
659
  }
284
- if (['dev', 'start', 'stop', 'status', 'logs'].includes(command)) {
660
+ if (['dev', 'start', 'stop', 'reload', 'status', 'logs'].includes(command)) {
285
661
  await serverCommand(args);
286
662
  return;
287
663
  }
@@ -289,14 +665,19 @@ async function main() {
289
665
  await moduleCommand(args);
290
666
  return;
291
667
  }
668
+ if (command === 'settings') {
669
+ await settingsCommand(args);
670
+ return;
671
+ }
292
672
  if (!['init', 'add', 'upgrade', 'remove'].includes(command))
293
673
  throw unknownCommand(command, [...TOP_LEVEL, '--help', '--version']);
294
674
  const target = args.shift();
295
675
  if (!target || target.startsWith('-'))
296
676
  throw new CliError('USAGE', USAGE_LINES);
297
- const project = new Project();
677
+ const project = await newProject();
298
678
  let result;
299
679
  if (command === 'init') {
680
+ let deploy = false;
300
681
  let core;
301
682
  let dryRun = false;
302
683
  for (let index = 0; index < args.length; index++) {
@@ -306,6 +687,8 @@ async function main() {
306
687
  if (!core || core.startsWith('-'))
307
688
  throw new CliError('USAGE', USAGE_LINES);
308
689
  }
690
+ else if (arg === '--deploy' && !deploy)
691
+ deploy = true;
309
692
  else if (arg === '--dry-run' && !dryRun)
310
693
  dryRun = true;
311
694
  else
@@ -313,12 +696,18 @@ async function main() {
313
696
  }
314
697
  writeBanner(process.stderr, machine);
315
698
  mutationStarted = !dryRun;
316
- result = await project.init(target, core, dryRun);
699
+ result = await project.init(target, core, dryRun, deploy);
317
700
  }
318
701
  else if (command === 'add') {
319
- const options = flags(args, { set: true, add: true });
702
+ const options = flags(args, { set: await setParser(), add: true });
320
703
  mutationStarted = true;
321
704
  result = await project.add(process.cwd(), target, options.dryRun, options.sets, options.add);
705
+ // D-0098: the first app of the apps host is EJS; its view is recorded (chooser unstated).
706
+ const firstApp = result.firstApp;
707
+ if (firstApp) {
708
+ const { recordAppView } = await import('./view-choice.js');
709
+ result = await recordAppView(process.cwd(), result, firstApp, 'ejs', {}, options.dryRun);
710
+ }
322
711
  }
323
712
  else if (command === 'upgrade') {
324
713
  const options = flags(args, { to: true });
@@ -354,15 +743,16 @@ function printWarnings(warnings) {
354
743
  if (machine)
355
744
  return;
356
745
  for (const warning of warnings ?? [])
357
- if (warning.code === 'SECRET_ON_COMMAND_LINE')
358
- process.stderr.write(`warning: ${warning.path} looks secret; command-line values can remain in shell history\n`);
359
- else if (warning.code === 'THIRD_PARTY_PACKAGE')
746
+ if (warning.code === 'THIRD_PARTY_PACKAGE')
360
747
  process.stderr.write(`warning: ${warning.path} is not a @dokebi package; install it only with the user's consent\n`);
748
+ else if (warning.code === 'DEPRECATED_PLAN_FIELD')
749
+ process.stderr.write(`warning: ${warning.plugin} uses the deprecated install plan field ${warning.deprecated} (removed in 0.5); upgrade the plugin\n`);
361
750
  }
362
751
  /** `dokebi plugins list|search|info|doc` (D-0065). */
363
752
  async function pluginsCommand(input) {
364
753
  const action = input.shift();
365
754
  const name = `plugins ${action}`;
755
+ const { pluginsDoc, pluginsInfo, pluginsList, pluginsSearch } = await import('./plugins-command.js');
366
756
  if (action === 'list') {
367
757
  let compatible = false;
368
758
  let online = false;
@@ -413,6 +803,7 @@ async function recipeCommand(input) {
413
803
  const name = input.shift();
414
804
  if (!name || name.startsWith('-'))
415
805
  throw new CliError('USAGE', USAGE_LINES);
806
+ const { listRecipes, runRecipe } = await import('./recipe.js');
416
807
  if (name === 'list') {
417
808
  if (input.length)
418
809
  throw new CliError('USAGE', USAGE_LINES);
@@ -428,10 +819,18 @@ async function recipeCommand(input) {
428
819
  '--db': 'db',
429
820
  '--dev-infra': 'devInfra',
430
821
  '--admin': 'admin',
822
+ '--mail': 'mail',
823
+ '--apps': 'apps',
824
+ '--domains': 'domains',
825
+ '--realms': 'realms',
826
+ '--worker': 'worker',
431
827
  };
432
828
  let dir;
433
829
  let defaults = false;
434
830
  let answersFrom;
831
+ // D-0095: the given answers the agent chose itself, and its one-line reasons.
832
+ let aiChose;
833
+ const aiReasons = new Map();
435
834
  let dryRun = false;
436
835
  let port;
437
836
  let dbPort;
@@ -467,6 +866,21 @@ async function recipeCommand(input) {
467
866
  throw new CliError('USAGE', `--answers-from must be user (the user answered) or defaults (the user said to decide)\n${USAGE_LINES}`);
468
867
  answersFrom = value;
469
868
  }
869
+ else if (arg === '--ai-chose' && aiChose === undefined) {
870
+ const value = input[++index];
871
+ const ids = value?.split(',').map((item) => item.trim());
872
+ if (!value || value.startsWith('-') || !ids?.every(Boolean))
873
+ throw new CliError('USAGE', `--ai-chose takes the comma-separated question ids you chose (for example apps,worker)\n${USAGE_LINES}`);
874
+ aiChose = [...new Set(ids)];
875
+ }
876
+ else if (arg === '--ai-reason') {
877
+ const value = input[++index];
878
+ const at = value?.indexOf('=') ?? -1;
879
+ const id = at > 0 ? value.slice(0, at).trim() : '';
880
+ if (!value || value.startsWith('--') || !id || aiReasons.has(id))
881
+ throw new CliError('USAGE', `--ai-reason takes <id>=<why you chose it>, once per question id\n${USAGE_LINES}`);
882
+ aiReasons.set(id, value.slice(at + 1));
883
+ }
470
884
  else if (arg === '--dry-run' && !dryRun)
471
885
  dryRun = true;
472
886
  else
@@ -478,11 +892,13 @@ async function recipeCommand(input) {
478
892
  answers,
479
893
  defaults,
480
894
  ...(answersFrom !== undefined ? { answersFrom } : {}),
895
+ ...(aiChose !== undefined ? { aiChose } : {}),
896
+ ...(aiReasons.size ? { aiReasons: Object.fromEntries(aiReasons) } : {}),
481
897
  dryRun,
482
898
  ...(dir !== undefined ? { dir } : {}),
483
899
  ...(port !== undefined ? { port } : {}),
484
900
  ...(dbPort !== undefined ? { dbPort } : {}),
485
- }, new Project());
901
+ }, await newProject());
486
902
  if (result.status === 'needs-answers') {
487
903
  // Not an error of the command: the answers come from the user. Nothing was installed.
488
904
  process.exitCode = 2;
@@ -517,6 +933,72 @@ async function recipeCommand(input) {
517
933
  * Reads `--name <number>` style flags of the server commands; anything else is a usage error.
518
934
  * `numbers` maps each flag to its range (default 1 to 3600000).
519
935
  */
936
+ /** `dokebi deploy init|proxy` (D-0184). */
937
+ async function deployCommand(input) {
938
+ const action = input.shift();
939
+ if (action !== 'init' && action !== 'proxy')
940
+ throw action && !action.startsWith('-')
941
+ ? unknownCommand(action, ['init', 'proxy'], 'deploy ')
942
+ : new CliError('USAGE', USAGE_LINES);
943
+ const instances = [];
944
+ const domains = [];
945
+ let target;
946
+ let port;
947
+ let proxy;
948
+ let dryRun = false;
949
+ const value = (index) => {
950
+ const text = input[index];
951
+ if (text === undefined || text.startsWith('-'))
952
+ throw new CliError('USAGE', USAGE_LINES);
953
+ return text;
954
+ };
955
+ for (let index = 0; index < input.length; index++) {
956
+ const arg = input[index];
957
+ if (arg === '--dry-run' && !dryRun)
958
+ dryRun = true;
959
+ else if (arg === '--instance')
960
+ instances.push(value(++index));
961
+ else if (action === 'init' && arg === '--target' && target === undefined)
962
+ target = value(++index);
963
+ else if (action === 'init' && arg === '--port' && port === undefined) {
964
+ const number = Number(value(++index));
965
+ if (!Number.isSafeInteger(number) || number < 1024 || number > 65535)
966
+ throw new CliError('USAGE', `--port must be an integer from 1024 to 65535\n${USAGE_LINES}`);
967
+ port = number;
968
+ }
969
+ else if (action === 'proxy' && (arg === '--caddy' || arg === '--nginx') && !proxy)
970
+ proxy = arg === '--caddy' ? 'caddy' : 'nginx';
971
+ else if (action === 'proxy' && arg === '--domain')
972
+ domains.push(value(++index));
973
+ else
974
+ throw new CliError('USAGE', USAGE_LINES);
975
+ }
976
+ if (target !== undefined && target !== 'systemd' && target !== 'compose')
977
+ throw new CliError('USAGE', `--target is systemd or compose\n${USAGE_LINES}`);
978
+ const deploy = await import('./deploy.js');
979
+ mutationStarted = !dryRun;
980
+ if (action === 'init') {
981
+ const result = await deploy.deployInit(process.cwd(), {
982
+ target: (target ?? 'systemd'),
983
+ instances: instances.length ? instances : ['production'],
984
+ ...(port === undefined ? {} : { port }),
985
+ dryRun,
986
+ });
987
+ printWarnings(result.warnings);
988
+ print('deploy init', result);
989
+ return;
990
+ }
991
+ if (!proxy || !domains.length || instances.length > 1)
992
+ throw new CliError('USAGE', USAGE_LINES);
993
+ const result = await deploy.deployProxy(process.cwd(), {
994
+ proxy,
995
+ domains,
996
+ instance: instances[0] ?? 'production',
997
+ dryRun,
998
+ });
999
+ printWarnings(result.warnings);
1000
+ print('deploy proxy', result);
1001
+ }
520
1002
  function serverFlags(input, numbers, switches) {
521
1003
  const result = { numbers: new Map(), switches: new Set() };
522
1004
  for (let index = 0; index < input.length; index++) {
@@ -536,9 +1018,10 @@ function serverFlags(input, numbers, switches) {
536
1018
  }
537
1019
  return result;
538
1020
  }
539
- /** `dokebi dev|start|stop|status|logs` (D-0044): the project's process. */
1021
+ /** `dokebi dev|start|stop|reload|status|logs` (D-0044, D-0154): the project's process. */
540
1022
  async function serverCommand(input) {
541
1023
  const root = process.cwd();
1024
+ const server = await import('./server.js');
542
1025
  const print = (result, text) => process.stdout.write(machine ? JSON.stringify(envelope(command, result)) + '\n' : text + '\n');
543
1026
  if (command === 'dev') {
544
1027
  if (input.length || machine)
@@ -584,6 +1067,23 @@ async function serverCommand(input) {
584
1067
  ? `not running (pid ${result.pid} had already exited; removed the pid file)`
585
1068
  : 'not running');
586
1069
  }
1070
+ else if (command === 'reload') {
1071
+ const options = serverFlags(input, { '--timeout-ms': [1000, 3_600_000] }, []);
1072
+ const result = await server.reload(root, options.numbers.get('--timeout-ms'));
1073
+ if (!machine)
1074
+ for (const warning of result.warnings ?? [])
1075
+ process.stderr.write(`warning: ${warning}\n`);
1076
+ // Not running is the answer of status (exit 3), not an error.
1077
+ if (!result.running)
1078
+ process.exitCode = 3;
1079
+ print(result, !result.running
1080
+ ? 'not running (npx dokebi start)'
1081
+ : result.outcome === 'applied'
1082
+ ? `reloaded pid ${result.pid} in ${result.ms} ms${result.revision !== undefined ? ` (revision ${result.revision})` : ''}${result.paths?.length ? `: ${result.paths.join(', ')}` : ''}`
1083
+ : result.outcome === 'unchanged'
1084
+ ? `pid ${result.pid} read dokebi.yaml again: nothing changed`
1085
+ : `signalled pid ${result.pid}; its outcome is not in the log yet (npx dokebi logs)`);
1086
+ }
587
1087
  else if (command === 'status') {
588
1088
  if (input.length)
589
1089
  throw new CliError('USAGE', USAGE_LINES);
@@ -617,7 +1117,7 @@ async function serverCommand(input) {
617
1117
  /** `dokebi app add|remove|list` (D-0030): apps of @dokebi/app and their domains. */
618
1118
  async function app(input) {
619
1119
  const action = input.shift();
620
- const project = new Project();
1120
+ const project = await newProject();
621
1121
  let result;
622
1122
  if (action === 'list') {
623
1123
  if (input.length)
@@ -631,17 +1131,47 @@ async function app(input) {
631
1131
  let dryRun = false;
632
1132
  let inertia = false;
633
1133
  let ssr = false;
1134
+ let seal = false;
634
1135
  let template;
1136
+ /** `--view ejs|none` (M50): EJS pages, or no views (a JSON API). */
1137
+ let view;
635
1138
  const domains = [];
1139
+ // D-0098: who chose the view of this app (the same flags as the recipe's, D-0095).
1140
+ let answersFrom;
1141
+ let aiChose;
1142
+ const aiReasons = new Map();
636
1143
  for (let index = 0; index < input.length; index++) {
637
1144
  const arg = input[index];
638
1145
  const value = input[index + 1];
639
1146
  if (arg === '--dry-run' && !dryRun)
640
1147
  dryRun = true;
1148
+ else if (action === 'add' && arg === '--answers-from' && answersFrom === undefined) {
1149
+ if (value !== 'user' && value !== 'defaults')
1150
+ throw new CliError('USAGE', `--answers-from must be user (the user chose the view) or defaults (the user left it to you)\n${USAGE_LINES}`);
1151
+ answersFrom = value;
1152
+ index++;
1153
+ }
1154
+ else if (action === 'add' && arg === '--ai-chose' && aiChose === undefined) {
1155
+ const ids = value?.split(',').map((item) => item.trim());
1156
+ if (!value || value.startsWith('-') || !ids?.every(Boolean))
1157
+ throw new CliError('USAGE', `--ai-chose takes view on app add\n${USAGE_LINES}`);
1158
+ aiChose = [...new Set(ids)];
1159
+ index++;
1160
+ }
1161
+ else if (action === 'add' && arg === '--ai-reason') {
1162
+ const at = value?.indexOf('=') ?? -1;
1163
+ const id = at > 0 ? value.slice(0, at).trim() : '';
1164
+ if (!value || value.startsWith('--') || !id || aiReasons.has(id))
1165
+ throw new CliError('USAGE', `--ai-reason takes view=<why you chose it>, once\n${USAGE_LINES}`);
1166
+ aiReasons.set(id, value.slice(at + 1));
1167
+ index++;
1168
+ }
641
1169
  else if (action === 'add' && arg === '--inertia' && !inertia)
642
1170
  inertia = true;
643
1171
  else if (action === 'add' && arg === '--ssr' && !ssr)
644
1172
  ssr = true;
1173
+ else if (action === 'add' && arg === '--seal' && !seal)
1174
+ seal = true;
645
1175
  else if (action === 'add' && arg === '--domain' && value && !value.startsWith('-')) {
646
1176
  domains.push(value);
647
1177
  index++;
@@ -650,22 +1180,42 @@ async function app(input) {
650
1180
  template = value;
651
1181
  index++;
652
1182
  }
1183
+ else if (action === 'add' && arg === '--view' && view === undefined) {
1184
+ if (value !== 'ejs' && value !== 'none')
1185
+ throw new CliError('USAGE', `--view takes ejs (EJS pages) or none (no views, a JSON API); an Inertia app is --inertia\n${USAGE_LINES}`);
1186
+ view = value;
1187
+ index++;
1188
+ }
653
1189
  else
654
1190
  throw new CliError('USAGE', USAGE_LINES);
655
1191
  }
656
1192
  // Server rendering (D-0039) is a choice of an Inertia app.
657
1193
  if (ssr && !inertia)
658
1194
  throw new CliError('USAGE', USAGE_LINES);
1195
+ // One flag chooses the view; an app without views has no template.
1196
+ if (view !== undefined && inertia)
1197
+ throw new CliError('USAGE', `--view and --inertia both choose the view: give one\n${USAGE_LINES}`);
1198
+ if (view === 'none' && template !== undefined)
1199
+ throw new CliError('USAGE', `--view none makes an app without views: it takes no --template\n${USAGE_LINES}`);
1200
+ const viewSource = {
1201
+ ...(answersFrom !== undefined ? { answersFrom } : {}),
1202
+ ...(aiChose !== undefined ? { aiChose } : {}),
1203
+ ...(aiReasons.size ? { aiReasons: Object.fromEntries(aiReasons) } : {}),
1204
+ };
1205
+ const views = action === 'add' ? await import('./view-choice.js') : undefined;
1206
+ views?.checkViewSource(viewSource);
659
1207
  mutationStarted = true;
660
1208
  result =
661
- action === 'add'
662
- ? await project.appAdd(process.cwd(), name, {
1209
+ action === 'add' && views
1210
+ ? await views.recordAppView(process.cwd(), await project.appAdd(process.cwd(), name, {
663
1211
  domains,
664
1212
  dryRun,
665
1213
  inertia,
666
1214
  ssr,
1215
+ seal,
667
1216
  ...(template ? { template } : {}),
668
- })
1217
+ ...(view ? { view } : {}),
1218
+ }), name, views.appViewValue(inertia, ssr, view === 'none'), viewSource, dryRun)
669
1219
  : await project.appRemove(process.cwd(), name, dryRun);
670
1220
  }
671
1221
  else if (action === 'link' || action === 'unlink') {
@@ -674,12 +1224,15 @@ async function app(input) {
674
1224
  if (!first || first.startsWith('-') || !domain || domain.startsWith('-'))
675
1225
  throw new CliError('USAGE', USAGE_LINES);
676
1226
  let dryRun = false;
1227
+ let seal = false;
677
1228
  let template;
678
1229
  for (let index = 0; index < input.length; index++) {
679
1230
  const arg = input[index];
680
1231
  const value = input[index + 1];
681
1232
  if (arg === '--dry-run' && !dryRun)
682
1233
  dryRun = true;
1234
+ else if (action === 'link' && arg === '--seal' && !seal)
1235
+ seal = true;
683
1236
  else if (action === 'link' && arg === '--template' && value && !template) {
684
1237
  template = value;
685
1238
  index++;
@@ -692,6 +1245,7 @@ async function app(input) {
692
1245
  action === 'link'
693
1246
  ? await project.appLink(process.cwd(), first, domain, {
694
1247
  dryRun,
1248
+ seal,
695
1249
  ...(template ? { template } : {}),
696
1250
  })
697
1251
  : await project.appUnlink(process.cwd(), domain, dryRun);
@@ -705,7 +1259,7 @@ async function app(input) {
705
1259
  /** `dokebi module add|remove|list` (D-0032): feature modules in src/shared/modules/<name>. */
706
1260
  async function moduleCommand(input) {
707
1261
  const action = input.shift();
708
- const project = new Project();
1262
+ const project = await newProject();
709
1263
  let result;
710
1264
  if (action === 'list') {
711
1265
  if (input.length)
@@ -738,6 +1292,50 @@ async function moduleCommand(input) {
738
1292
  throw new CliError('USAGE', USAGE_LINES);
739
1293
  process.stdout.write(JSON.stringify(machine ? envelope(`module ${action}`, result) : result, null, 2) + '\n');
740
1294
  }
1295
+ /** `dokebi settings add|sync|remove|list` (D-0155): the settings sections of the application. */
1296
+ async function settingsCommand(input) {
1297
+ const action = input.shift();
1298
+ const project = await newProject();
1299
+ let result;
1300
+ if (action === 'list') {
1301
+ if (input.length)
1302
+ throw new CliError('USAGE', USAGE_LINES);
1303
+ result = await project.settingsList(process.cwd());
1304
+ }
1305
+ else if (action === 'sync') {
1306
+ let dryRun = false;
1307
+ for (const arg of input) {
1308
+ if (arg === '--dry-run' && !dryRun)
1309
+ dryRun = true;
1310
+ else
1311
+ throw new CliError('USAGE', USAGE_LINES);
1312
+ }
1313
+ mutationStarted = !dryRun;
1314
+ result = await project.settingsSync(process.cwd(), dryRun);
1315
+ }
1316
+ else if (action === 'add' || action === 'remove') {
1317
+ const section = input.shift();
1318
+ if (!section || section.startsWith('-'))
1319
+ throw new CliError('USAGE', USAGE_LINES);
1320
+ let dryRun = false;
1321
+ for (const arg of input) {
1322
+ if (arg === '--dry-run' && !dryRun)
1323
+ dryRun = true;
1324
+ else
1325
+ throw new CliError('USAGE', USAGE_LINES);
1326
+ }
1327
+ mutationStarted = !dryRun;
1328
+ result =
1329
+ action === 'add'
1330
+ ? await project.settingsAdd(process.cwd(), section, { dryRun })
1331
+ : await project.settingsRemove(process.cwd(), section, dryRun);
1332
+ }
1333
+ else if (action && !action.startsWith('-'))
1334
+ throw unknownCommand(action, ['add', 'sync', 'remove', 'list'], 'settings ');
1335
+ else
1336
+ throw new CliError('USAGE', USAGE_LINES);
1337
+ process.stdout.write(JSON.stringify(machine ? envelope(`settings ${action}`, result) : result, null, 2) + '\n');
1338
+ }
741
1339
  await main().catch((error) => {
742
1340
  if (machine)
743
1341
  process.stdout.write(JSON.stringify(failure(command, error, mutationStarted)) + '\n');
@@ -788,6 +1386,9 @@ function nextSteps(error) {
788
1386
  const hint = error.details.hint;
789
1387
  if (typeof hint === 'string')
790
1388
  lines.add(`hint: ${hint}`);
1389
+ const fix = error.details.fix;
1390
+ if (typeof fix === 'string')
1391
+ lines.add(`fix: ${fix}`);
791
1392
  // A decision that belongs to the user (PREFLIGHT_FAILED with a taken port, AI-M03).
792
1393
  const { question, next, waitForUser } = error.details;
793
1394
  if (typeof question === 'string')