@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
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,120 @@ 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
+ }
175
306
  if (command === 'verify') {
176
- const options = serverFlags(args, { '--wait-ms': undefined }, []);
307
+ const options = serverFlags(args, { '--wait-ms': undefined, '--test-timeout-ms': [1000, 3_600_000] }, []);
177
308
  const waitMs = options.numbers.get('--wait-ms');
178
- const result = await verify(process.cwd(), waitMs !== undefined ? { waitMs } : {});
309
+ const testTimeoutMs = options.numbers.get('--test-timeout-ms');
310
+ const [{ verify }, { testLines }, { TELL_USER_RELAY }] = await Promise.all([
311
+ import('./verify.js'),
312
+ import('./verify-tests.js'),
313
+ import('./recipe-record.js'),
314
+ ]);
315
+ const result = await verify(process.cwd(), {
316
+ ...(waitMs !== undefined ? { waitMs } : {}),
317
+ ...(testTimeoutMs !== undefined ? { testTimeoutMs } : {}),
318
+ });
179
319
  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')}`
320
+ .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) : ''}`)
321
+ .join('\n')}\n${result.ok ? 'verify ok' : 'verify FAILED'}${addressLines(result)}${(result.warnings ?? [])
322
+ .map((warning) => `\nwarning: ${warning.code} (${warning.path}): ${warning.hint}`)
323
+ .join('')}${
324
+ // A taken port is the user's call (AI-M04 F5), as in the recipe preflight.
325
+ result.question
326
+ ? `\n${result.waitForUser ? 'ask the user' : 'question'}: ${result.question}\nnext: ${result.next}`
327
+ : ''}${result.tellUser
328
+ ? `\nTell the user exactly these lines. ${TELL_USER_RELAY.instruction}\n${result.tellUser.map((line) => `- ${line}`).join('\n')}`
329
+ : ''}${
330
+ // D-0105 §4: what the agent writes into its final message itself (not copied).
331
+ result.finalReport
332
+ ? `\nYour final message must also contain:\n${result.finalReport.map((line) => `- ${line}`).join('\n')}`
183
333
  : ''}`, result.ok);
184
334
  if (!result.ok)
185
335
  process.exitCode = 1;
186
336
  return;
187
337
  }
338
+ if (command === 'describe') {
339
+ // Read only (D-0077 B): the app's setup in describe mode; nothing listens or connects.
340
+ // D-0077 C: --openapi [--app <name>] prints OpenAPI 3.1 documents instead.
341
+ const appAt = args.indexOf('--app');
342
+ const appName = appAt < 0 ? undefined : args[appAt + 1];
343
+ if (appAt >= 0 && (appName === undefined || appName.startsWith('-')))
344
+ throw new CliError('USAGE', `--app needs an app name\n${USAGE_LINES}`);
345
+ const rest = appAt < 0 ? args : args.filter((_, index) => index !== appAt && index !== appAt + 1);
346
+ const options = serverFlags(rest, { '--timeout-ms': [1000, 600_000] }, [
347
+ '--with-db',
348
+ '--openapi',
349
+ ]);
350
+ if (appName !== undefined && !options.switches.has('--openapi'))
351
+ throw new CliError('USAGE', `--app is for --openapi\n${USAGE_LINES}`);
352
+ const timeoutMs = options.numbers.get('--timeout-ms');
353
+ const { describeProject, describeText } = await import('./describe.js');
354
+ const result = await describeProject(process.cwd(), {
355
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
356
+ // D-0077 F: --with-db also reads the tables (a separate step that connects).
357
+ withDb: options.switches.has('--with-db'),
358
+ });
359
+ if (options.switches.has('--openapi')) {
360
+ const { openApi, projectVersion } = await import('./openapi.js');
361
+ const version = projectVersion(process.cwd());
362
+ const documents = openApi(result, {
363
+ ...(appName === undefined ? {} : { app: appName }),
364
+ ...(version === undefined ? {} : { version }),
365
+ });
366
+ print(command, documents, JSON.stringify(documents, null, 2));
367
+ return;
368
+ }
369
+ print(command, result, describeText(result));
370
+ return;
371
+ }
372
+ if (command === 'drift') {
373
+ // D-0120: describe --with-db (it connects) against an expected file of the describe shape.
374
+ const at = args.indexOf('--expected');
375
+ const expected = at < 0 ? undefined : args[at + 1];
376
+ if (expected === undefined || expected.startsWith('-'))
377
+ throw new CliError('USAGE', `--expected needs a file\n${USAGE_LINES}`);
378
+ const options = serverFlags(args.filter((_, index) => index !== at && index !== at + 1), { '--timeout-ms': [1000, 600_000] }, []);
379
+ const timeoutMs = options.numbers.get('--timeout-ms');
380
+ const { drift, driftText } = await import('./drift.js');
381
+ const result = await drift(process.cwd(), {
382
+ expected,
383
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
384
+ });
385
+ print(command, result, driftText(result), result.ok);
386
+ if (!result.ok)
387
+ process.exitCode = 1;
388
+ return;
389
+ }
188
390
  if (command === 'check') {
189
391
  if (args.length)
190
392
  throw new CliError('USAGE', USAGE_LINES);
393
+ const { check, checkText } = await import('./check/index.js');
191
394
  const result = await check(process.cwd());
192
395
  print(command, result, checkText(result), result.ok);
193
396
  if (!result.ok)
@@ -196,23 +399,128 @@ async function main() {
196
399
  }
197
400
  if (command === 'config') {
198
401
  const action = args.shift();
402
+ const parseSetOption = await setParser();
403
+ if (action === 'show') {
404
+ // Read only: secrets are hidden in every mode (config-show.ts).
405
+ if (args.length > 1 || args[0]?.startsWith('-'))
406
+ throw new CliError('USAGE', USAGE_LINES);
407
+ const target = args[0];
408
+ const dot = target?.indexOf('.') ?? -1;
409
+ if (target !== undefined && (dot === 0 || target.includes('=')))
410
+ throw new CliError('USAGE', USAGE_LINES);
411
+ const section = target === undefined ? undefined : dot > 0 ? target.slice(0, dot) : target;
412
+ // The path is checked like a --set path (segments, no prototype keys).
413
+ const path = target && dot > 0 ? parseSetOption(`${target.slice(dot + 1)}=0`).path : [];
414
+ const { configShow, configShowText } = await import('./config-show.js');
415
+ const result = await configShow(process.cwd(), section === undefined ? undefined : { section, path });
416
+ print('config show', result, configShowText(result));
417
+ return;
418
+ }
419
+ if (action === 'diff') {
420
+ // Read only (AI-M45): option paths of dokebi.yaml against dokebi.example.yaml, no values.
421
+ // --write (AI-M46) copies the paths only in dokebi.yaml into the sample, without secrets.
422
+ let write = false;
423
+ let dryRun = false;
424
+ let prune = false;
425
+ let values = false;
426
+ const rest = [];
427
+ for (const arg of args) {
428
+ if (arg === '--write' && !write)
429
+ write = true;
430
+ else if (arg === '--dry-run' && !dryRun)
431
+ dryRun = true;
432
+ else if (arg === '--prune' && !prune)
433
+ prune = true;
434
+ else if (arg === '--values' && !values)
435
+ values = true;
436
+ else
437
+ rest.push(arg);
438
+ }
439
+ if ((dryRun || prune || values) && !write)
440
+ throw new CliError('USAGE', USAGE_LINES);
441
+ if (rest.length > 1 || rest[0]?.startsWith('-') || rest[0]?.includes('.'))
442
+ throw new CliError('USAGE', USAGE_LINES);
443
+ const { configDiff, configDiffText } = await import('./config-diff.js');
444
+ if (write) {
445
+ mutationStarted = true;
446
+ const result = await (await newProject()).configSample(process.cwd(), rest[0], dryRun, prune, values);
447
+ const listed = (label, paths) => paths.length
448
+ ? `${label} [${paths.length}]:\n${paths.map((path) => ` ${path}`).join('\n')}\n`
449
+ : '';
450
+ 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)}`);
451
+ return;
452
+ }
453
+ const result = await configDiff(process.cwd(), rest[0]);
454
+ print('config diff', result, configDiffText(result));
455
+ return;
456
+ }
457
+ if (action === 'init') {
458
+ // D-0163: a fresh copy of the project (a clone) has no dokebi.yaml; written from the sample.
459
+ const dryRun = args.includes('--dry-run');
460
+ if (args.some((arg) => arg !== '--dry-run'))
461
+ throw new CliError('USAGE', USAGE_LINES);
462
+ mutationStarted = true;
463
+ const result = await (await newProject()).configInit(process.cwd(), dryRun);
464
+ print('config init', result);
465
+ return;
466
+ }
199
467
  if (action === 'secret') {
200
468
  const dryRun = args.includes('--dry-run');
201
- const rest = args.filter((arg) => arg !== '--dry-run');
469
+ const rotate = args.includes('--rotate');
470
+ const fromStdin = args.includes('--stdin');
471
+ const rest = args.filter((arg) => arg !== '--dry-run' && arg !== '--rotate' && arg !== '--stdin');
202
472
  const target = rest[0];
203
473
  const dot = target?.indexOf('.') ?? -1;
204
474
  if (rest.length !== 1 || !target || dot <= 0 || target.includes('='))
205
475
  throw new CliError('USAGE', USAGE_LINES);
206
476
  // The path is checked like a --set path (segments, no prototype keys).
207
477
  const path = parseSetOption(`${target.slice(dot + 1)}=0`).path;
478
+ // --stdin: a secret someone else issued (a log receiver's token), never on the command line.
479
+ const value = fromStdin ? await readSecretInput() : undefined;
208
480
  mutationStarted = true;
209
- const result = await new Project().configSecret(process.cwd(), target.slice(0, dot), path, dryRun);
481
+ const result = await (await newProject()).configSecret(process.cwd(), target.slice(0, dot), path, dryRun, rotate, value);
210
482
  print('config secret', result);
211
483
  return;
212
484
  }
485
+ if (action === 'unset') {
486
+ // D-0153: <plugin-id>.<path>...; never a value (no `=`). A section alone (no path) only
487
+ // for one no installed plugin reads (an old section left in the files).
488
+ let dryRun = false;
489
+ const targets = [];
490
+ for (const arg of args) {
491
+ if (arg === '--dry-run' && !dryRun)
492
+ dryRun = true;
493
+ else if (!arg.startsWith('-')) {
494
+ if (arg.includes('='))
495
+ throw new CliError('USAGE', USAGE_LINES);
496
+ const dot = arg.indexOf('.');
497
+ if (dot === 0)
498
+ throw new CliError('USAGE', USAGE_LINES);
499
+ // The path is checked like a --set path (segments, no prototype keys).
500
+ if (dot < 0) {
501
+ parseSetOption(`${arg}=0`);
502
+ targets.push({ plugin: arg, path: [] });
503
+ }
504
+ else
505
+ targets.push({
506
+ plugin: arg.slice(0, dot),
507
+ path: parseSetOption(`${arg.slice(dot + 1)}=0`).path,
508
+ });
509
+ }
510
+ else
511
+ throw new CliError('USAGE', USAGE_LINES);
512
+ }
513
+ if (!targets.length)
514
+ throw new CliError('USAGE', USAGE_LINES);
515
+ mutationStarted = true;
516
+ const result = await (await newProject()).configUnset(process.cwd(), targets, dryRun);
517
+ printWarnings(result.warnings);
518
+ print('config unset', result);
519
+ return;
520
+ }
213
521
  if (action !== 'set') {
214
522
  if (action && !action.startsWith('-'))
215
- throw unknownCommand(action, ['set', 'secret'], 'config ');
523
+ throw unknownCommand(action, ['set', 'secret', 'show', 'unset'], 'config ');
216
524
  throw new CliError('USAGE', USAGE_LINES);
217
525
  }
218
526
  let dryRun = false;
@@ -234,17 +542,32 @@ async function main() {
234
542
  if (!assignments.length)
235
543
  throw new CliError('USAGE', USAGE_LINES);
236
544
  mutationStarted = true;
237
- const result = await new Project().configSet(process.cwd(), assignments, dryRun);
545
+ const result = await (await newProject()).configSet(process.cwd(), assignments, dryRun);
238
546
  printWarnings(result.warnings);
239
547
  print('config set', result);
240
548
  return;
241
549
  }
242
550
  if (command === 'dev-infra') {
551
+ if (args[0] && !args[0].startsWith('-')) {
552
+ const action = args[0];
553
+ args.shift();
554
+ const files = [];
555
+ while (args.length) {
556
+ if (args.shift() !== '--file' || !args[0])
557
+ throw new CliError('USAGE', USAGE_LINES);
558
+ files.push(args[0]);
559
+ args.shift();
560
+ }
561
+ mutationStarted = action !== 'status';
562
+ const { runDevCompose } = await import('./dev-compose.js');
563
+ print(command, await runDevCompose(process.cwd(), action, files.length ? files : undefined));
564
+ return;
565
+ }
243
566
  const dryRun = args.includes('--dry-run');
244
567
  const options = serverFlags(args.filter((arg) => arg !== '--dry-run'), { '--db-port': [1, 65535] }, []);
245
568
  mutationStarted = true;
246
569
  const dbPort = options.numbers.get('--db-port');
247
- const result = await new Project().devInfra(process.cwd(), {
570
+ const result = await (await newProject()).devInfra(process.cwd(), {
248
571
  dryRun,
249
572
  ...(dbPort !== undefined ? { dbPort } : {}),
250
573
  });
@@ -255,6 +578,7 @@ async function main() {
255
578
  const verbose = args.length === 1 && args[0] === '--verbose';
256
579
  if (args.length && !verbose)
257
580
  throw new CliError('USAGE', USAGE_LINES);
581
+ const { ProjectInspector } = await import('./project-inspector.js');
258
582
  const result = await new ProjectInspector().inspect(process.cwd(), {
259
583
  runChecks: command === 'doctor',
260
584
  });
@@ -273,7 +597,7 @@ async function main() {
273
597
  if (args.length && (args.length !== 1 || args[0] !== '--dry-run'))
274
598
  throw new CliError('USAGE', USAGE_LINES);
275
599
  mutationStarted = true;
276
- const result = await new Project().migrate(process.cwd(), args[0] === '--dry-run');
600
+ const result = await (await newProject()).migrate(process.cwd(), args[0] === '--dry-run');
277
601
  process.stdout.write(JSON.stringify(machine ? envelope(command, result) : result, null, 2) + '\n');
278
602
  return;
279
603
  }
@@ -281,7 +605,7 @@ async function main() {
281
605
  await app(args);
282
606
  return;
283
607
  }
284
- if (['dev', 'start', 'stop', 'status', 'logs'].includes(command)) {
608
+ if (['dev', 'start', 'stop', 'reload', 'status', 'logs'].includes(command)) {
285
609
  await serverCommand(args);
286
610
  return;
287
611
  }
@@ -289,14 +613,19 @@ async function main() {
289
613
  await moduleCommand(args);
290
614
  return;
291
615
  }
616
+ if (command === 'settings') {
617
+ await settingsCommand(args);
618
+ return;
619
+ }
292
620
  if (!['init', 'add', 'upgrade', 'remove'].includes(command))
293
621
  throw unknownCommand(command, [...TOP_LEVEL, '--help', '--version']);
294
622
  const target = args.shift();
295
623
  if (!target || target.startsWith('-'))
296
624
  throw new CliError('USAGE', USAGE_LINES);
297
- const project = new Project();
625
+ const project = await newProject();
298
626
  let result;
299
627
  if (command === 'init') {
628
+ let deploy = false;
300
629
  let core;
301
630
  let dryRun = false;
302
631
  for (let index = 0; index < args.length; index++) {
@@ -306,6 +635,8 @@ async function main() {
306
635
  if (!core || core.startsWith('-'))
307
636
  throw new CliError('USAGE', USAGE_LINES);
308
637
  }
638
+ else if (arg === '--deploy' && !deploy)
639
+ deploy = true;
309
640
  else if (arg === '--dry-run' && !dryRun)
310
641
  dryRun = true;
311
642
  else
@@ -313,12 +644,18 @@ async function main() {
313
644
  }
314
645
  writeBanner(process.stderr, machine);
315
646
  mutationStarted = !dryRun;
316
- result = await project.init(target, core, dryRun);
647
+ result = await project.init(target, core, dryRun, deploy);
317
648
  }
318
649
  else if (command === 'add') {
319
- const options = flags(args, { set: true, add: true });
650
+ const options = flags(args, { set: await setParser(), add: true });
320
651
  mutationStarted = true;
321
652
  result = await project.add(process.cwd(), target, options.dryRun, options.sets, options.add);
653
+ // D-0098: the first app of the apps host is EJS; its view is recorded (chooser unstated).
654
+ const firstApp = result.firstApp;
655
+ if (firstApp) {
656
+ const { recordAppView } = await import('./view-choice.js');
657
+ result = await recordAppView(process.cwd(), result, firstApp, 'ejs', {}, options.dryRun);
658
+ }
322
659
  }
323
660
  else if (command === 'upgrade') {
324
661
  const options = flags(args, { to: true });
@@ -354,15 +691,16 @@ function printWarnings(warnings) {
354
691
  if (machine)
355
692
  return;
356
693
  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')
694
+ if (warning.code === 'THIRD_PARTY_PACKAGE')
360
695
  process.stderr.write(`warning: ${warning.path} is not a @dokebi package; install it only with the user's consent\n`);
696
+ else if (warning.code === 'DEPRECATED_PLAN_FIELD')
697
+ process.stderr.write(`warning: ${warning.plugin} uses the deprecated install plan field ${warning.deprecated} (removed in 0.5); upgrade the plugin\n`);
361
698
  }
362
699
  /** `dokebi plugins list|search|info|doc` (D-0065). */
363
700
  async function pluginsCommand(input) {
364
701
  const action = input.shift();
365
702
  const name = `plugins ${action}`;
703
+ const { pluginsDoc, pluginsInfo, pluginsList, pluginsSearch } = await import('./plugins-command.js');
366
704
  if (action === 'list') {
367
705
  let compatible = false;
368
706
  let online = false;
@@ -413,6 +751,7 @@ async function recipeCommand(input) {
413
751
  const name = input.shift();
414
752
  if (!name || name.startsWith('-'))
415
753
  throw new CliError('USAGE', USAGE_LINES);
754
+ const { listRecipes, runRecipe } = await import('./recipe.js');
416
755
  if (name === 'list') {
417
756
  if (input.length)
418
757
  throw new CliError('USAGE', USAGE_LINES);
@@ -428,10 +767,18 @@ async function recipeCommand(input) {
428
767
  '--db': 'db',
429
768
  '--dev-infra': 'devInfra',
430
769
  '--admin': 'admin',
770
+ '--mail': 'mail',
771
+ '--apps': 'apps',
772
+ '--domains': 'domains',
773
+ '--realms': 'realms',
774
+ '--worker': 'worker',
431
775
  };
432
776
  let dir;
433
777
  let defaults = false;
434
778
  let answersFrom;
779
+ // D-0095: the given answers the agent chose itself, and its one-line reasons.
780
+ let aiChose;
781
+ const aiReasons = new Map();
435
782
  let dryRun = false;
436
783
  let port;
437
784
  let dbPort;
@@ -467,6 +814,21 @@ async function recipeCommand(input) {
467
814
  throw new CliError('USAGE', `--answers-from must be user (the user answered) or defaults (the user said to decide)\n${USAGE_LINES}`);
468
815
  answersFrom = value;
469
816
  }
817
+ else if (arg === '--ai-chose' && aiChose === undefined) {
818
+ const value = input[++index];
819
+ const ids = value?.split(',').map((item) => item.trim());
820
+ if (!value || value.startsWith('-') || !ids?.every(Boolean))
821
+ throw new CliError('USAGE', `--ai-chose takes the comma-separated question ids you chose (for example apps,worker)\n${USAGE_LINES}`);
822
+ aiChose = [...new Set(ids)];
823
+ }
824
+ else if (arg === '--ai-reason') {
825
+ const value = input[++index];
826
+ const at = value?.indexOf('=') ?? -1;
827
+ const id = at > 0 ? value.slice(0, at).trim() : '';
828
+ if (!value || value.startsWith('--') || !id || aiReasons.has(id))
829
+ throw new CliError('USAGE', `--ai-reason takes <id>=<why you chose it>, once per question id\n${USAGE_LINES}`);
830
+ aiReasons.set(id, value.slice(at + 1));
831
+ }
470
832
  else if (arg === '--dry-run' && !dryRun)
471
833
  dryRun = true;
472
834
  else
@@ -478,11 +840,13 @@ async function recipeCommand(input) {
478
840
  answers,
479
841
  defaults,
480
842
  ...(answersFrom !== undefined ? { answersFrom } : {}),
843
+ ...(aiChose !== undefined ? { aiChose } : {}),
844
+ ...(aiReasons.size ? { aiReasons: Object.fromEntries(aiReasons) } : {}),
481
845
  dryRun,
482
846
  ...(dir !== undefined ? { dir } : {}),
483
847
  ...(port !== undefined ? { port } : {}),
484
848
  ...(dbPort !== undefined ? { dbPort } : {}),
485
- }, new Project());
849
+ }, await newProject());
486
850
  if (result.status === 'needs-answers') {
487
851
  // Not an error of the command: the answers come from the user. Nothing was installed.
488
852
  process.exitCode = 2;
@@ -536,9 +900,10 @@ function serverFlags(input, numbers, switches) {
536
900
  }
537
901
  return result;
538
902
  }
539
- /** `dokebi dev|start|stop|status|logs` (D-0044): the project's process. */
903
+ /** `dokebi dev|start|stop|reload|status|logs` (D-0044, D-0154): the project's process. */
540
904
  async function serverCommand(input) {
541
905
  const root = process.cwd();
906
+ const server = await import('./server.js');
542
907
  const print = (result, text) => process.stdout.write(machine ? JSON.stringify(envelope(command, result)) + '\n' : text + '\n');
543
908
  if (command === 'dev') {
544
909
  if (input.length || machine)
@@ -584,6 +949,23 @@ async function serverCommand(input) {
584
949
  ? `not running (pid ${result.pid} had already exited; removed the pid file)`
585
950
  : 'not running');
586
951
  }
952
+ else if (command === 'reload') {
953
+ const options = serverFlags(input, { '--timeout-ms': [1000, 3_600_000] }, []);
954
+ const result = await server.reload(root, options.numbers.get('--timeout-ms'));
955
+ if (!machine)
956
+ for (const warning of result.warnings ?? [])
957
+ process.stderr.write(`warning: ${warning}\n`);
958
+ // Not running is the answer of status (exit 3), not an error.
959
+ if (!result.running)
960
+ process.exitCode = 3;
961
+ print(result, !result.running
962
+ ? 'not running (npx dokebi start)'
963
+ : result.outcome === 'applied'
964
+ ? `reloaded pid ${result.pid} in ${result.ms} ms${result.revision !== undefined ? ` (revision ${result.revision})` : ''}${result.paths?.length ? `: ${result.paths.join(', ')}` : ''}`
965
+ : result.outcome === 'unchanged'
966
+ ? `pid ${result.pid} read dokebi.yaml again: nothing changed`
967
+ : `signalled pid ${result.pid}; its outcome is not in the log yet (npx dokebi logs)`);
968
+ }
587
969
  else if (command === 'status') {
588
970
  if (input.length)
589
971
  throw new CliError('USAGE', USAGE_LINES);
@@ -617,7 +999,7 @@ async function serverCommand(input) {
617
999
  /** `dokebi app add|remove|list` (D-0030): apps of @dokebi/app and their domains. */
618
1000
  async function app(input) {
619
1001
  const action = input.shift();
620
- const project = new Project();
1002
+ const project = await newProject();
621
1003
  let result;
622
1004
  if (action === 'list') {
623
1005
  if (input.length)
@@ -631,17 +1013,47 @@ async function app(input) {
631
1013
  let dryRun = false;
632
1014
  let inertia = false;
633
1015
  let ssr = false;
1016
+ let seal = false;
634
1017
  let template;
1018
+ /** `--view ejs|none` (M50): EJS pages, or no views (a JSON API). */
1019
+ let view;
635
1020
  const domains = [];
1021
+ // D-0098: who chose the view of this app (the same flags as the recipe's, D-0095).
1022
+ let answersFrom;
1023
+ let aiChose;
1024
+ const aiReasons = new Map();
636
1025
  for (let index = 0; index < input.length; index++) {
637
1026
  const arg = input[index];
638
1027
  const value = input[index + 1];
639
1028
  if (arg === '--dry-run' && !dryRun)
640
1029
  dryRun = true;
1030
+ else if (action === 'add' && arg === '--answers-from' && answersFrom === undefined) {
1031
+ if (value !== 'user' && value !== 'defaults')
1032
+ 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}`);
1033
+ answersFrom = value;
1034
+ index++;
1035
+ }
1036
+ else if (action === 'add' && arg === '--ai-chose' && aiChose === undefined) {
1037
+ const ids = value?.split(',').map((item) => item.trim());
1038
+ if (!value || value.startsWith('-') || !ids?.every(Boolean))
1039
+ throw new CliError('USAGE', `--ai-chose takes view on app add\n${USAGE_LINES}`);
1040
+ aiChose = [...new Set(ids)];
1041
+ index++;
1042
+ }
1043
+ else if (action === 'add' && arg === '--ai-reason') {
1044
+ const at = value?.indexOf('=') ?? -1;
1045
+ const id = at > 0 ? value.slice(0, at).trim() : '';
1046
+ if (!value || value.startsWith('--') || !id || aiReasons.has(id))
1047
+ throw new CliError('USAGE', `--ai-reason takes view=<why you chose it>, once\n${USAGE_LINES}`);
1048
+ aiReasons.set(id, value.slice(at + 1));
1049
+ index++;
1050
+ }
641
1051
  else if (action === 'add' && arg === '--inertia' && !inertia)
642
1052
  inertia = true;
643
1053
  else if (action === 'add' && arg === '--ssr' && !ssr)
644
1054
  ssr = true;
1055
+ else if (action === 'add' && arg === '--seal' && !seal)
1056
+ seal = true;
645
1057
  else if (action === 'add' && arg === '--domain' && value && !value.startsWith('-')) {
646
1058
  domains.push(value);
647
1059
  index++;
@@ -650,22 +1062,42 @@ async function app(input) {
650
1062
  template = value;
651
1063
  index++;
652
1064
  }
1065
+ else if (action === 'add' && arg === '--view' && view === undefined) {
1066
+ if (value !== 'ejs' && value !== 'none')
1067
+ throw new CliError('USAGE', `--view takes ejs (EJS pages) or none (no views, a JSON API); an Inertia app is --inertia\n${USAGE_LINES}`);
1068
+ view = value;
1069
+ index++;
1070
+ }
653
1071
  else
654
1072
  throw new CliError('USAGE', USAGE_LINES);
655
1073
  }
656
1074
  // Server rendering (D-0039) is a choice of an Inertia app.
657
1075
  if (ssr && !inertia)
658
1076
  throw new CliError('USAGE', USAGE_LINES);
1077
+ // One flag chooses the view; an app without views has no template.
1078
+ if (view !== undefined && inertia)
1079
+ throw new CliError('USAGE', `--view and --inertia both choose the view: give one\n${USAGE_LINES}`);
1080
+ if (view === 'none' && template !== undefined)
1081
+ throw new CliError('USAGE', `--view none makes an app without views: it takes no --template\n${USAGE_LINES}`);
1082
+ const viewSource = {
1083
+ ...(answersFrom !== undefined ? { answersFrom } : {}),
1084
+ ...(aiChose !== undefined ? { aiChose } : {}),
1085
+ ...(aiReasons.size ? { aiReasons: Object.fromEntries(aiReasons) } : {}),
1086
+ };
1087
+ const views = action === 'add' ? await import('./view-choice.js') : undefined;
1088
+ views?.checkViewSource(viewSource);
659
1089
  mutationStarted = true;
660
1090
  result =
661
- action === 'add'
662
- ? await project.appAdd(process.cwd(), name, {
1091
+ action === 'add' && views
1092
+ ? await views.recordAppView(process.cwd(), await project.appAdd(process.cwd(), name, {
663
1093
  domains,
664
1094
  dryRun,
665
1095
  inertia,
666
1096
  ssr,
1097
+ seal,
667
1098
  ...(template ? { template } : {}),
668
- })
1099
+ ...(view ? { view } : {}),
1100
+ }), name, views.appViewValue(inertia, ssr, view === 'none'), viewSource, dryRun)
669
1101
  : await project.appRemove(process.cwd(), name, dryRun);
670
1102
  }
671
1103
  else if (action === 'link' || action === 'unlink') {
@@ -674,12 +1106,15 @@ async function app(input) {
674
1106
  if (!first || first.startsWith('-') || !domain || domain.startsWith('-'))
675
1107
  throw new CliError('USAGE', USAGE_LINES);
676
1108
  let dryRun = false;
1109
+ let seal = false;
677
1110
  let template;
678
1111
  for (let index = 0; index < input.length; index++) {
679
1112
  const arg = input[index];
680
1113
  const value = input[index + 1];
681
1114
  if (arg === '--dry-run' && !dryRun)
682
1115
  dryRun = true;
1116
+ else if (action === 'link' && arg === '--seal' && !seal)
1117
+ seal = true;
683
1118
  else if (action === 'link' && arg === '--template' && value && !template) {
684
1119
  template = value;
685
1120
  index++;
@@ -692,6 +1127,7 @@ async function app(input) {
692
1127
  action === 'link'
693
1128
  ? await project.appLink(process.cwd(), first, domain, {
694
1129
  dryRun,
1130
+ seal,
695
1131
  ...(template ? { template } : {}),
696
1132
  })
697
1133
  : await project.appUnlink(process.cwd(), domain, dryRun);
@@ -705,7 +1141,7 @@ async function app(input) {
705
1141
  /** `dokebi module add|remove|list` (D-0032): feature modules in src/shared/modules/<name>. */
706
1142
  async function moduleCommand(input) {
707
1143
  const action = input.shift();
708
- const project = new Project();
1144
+ const project = await newProject();
709
1145
  let result;
710
1146
  if (action === 'list') {
711
1147
  if (input.length)
@@ -738,6 +1174,50 @@ async function moduleCommand(input) {
738
1174
  throw new CliError('USAGE', USAGE_LINES);
739
1175
  process.stdout.write(JSON.stringify(machine ? envelope(`module ${action}`, result) : result, null, 2) + '\n');
740
1176
  }
1177
+ /** `dokebi settings add|sync|remove|list` (D-0155): the settings sections of the application. */
1178
+ async function settingsCommand(input) {
1179
+ const action = input.shift();
1180
+ const project = await newProject();
1181
+ let result;
1182
+ if (action === 'list') {
1183
+ if (input.length)
1184
+ throw new CliError('USAGE', USAGE_LINES);
1185
+ result = await project.settingsList(process.cwd());
1186
+ }
1187
+ else if (action === 'sync') {
1188
+ let dryRun = false;
1189
+ for (const arg of input) {
1190
+ if (arg === '--dry-run' && !dryRun)
1191
+ dryRun = true;
1192
+ else
1193
+ throw new CliError('USAGE', USAGE_LINES);
1194
+ }
1195
+ mutationStarted = !dryRun;
1196
+ result = await project.settingsSync(process.cwd(), dryRun);
1197
+ }
1198
+ else if (action === 'add' || action === 'remove') {
1199
+ const section = input.shift();
1200
+ if (!section || section.startsWith('-'))
1201
+ throw new CliError('USAGE', USAGE_LINES);
1202
+ let dryRun = false;
1203
+ for (const arg of input) {
1204
+ if (arg === '--dry-run' && !dryRun)
1205
+ dryRun = true;
1206
+ else
1207
+ throw new CliError('USAGE', USAGE_LINES);
1208
+ }
1209
+ mutationStarted = !dryRun;
1210
+ result =
1211
+ action === 'add'
1212
+ ? await project.settingsAdd(process.cwd(), section, { dryRun })
1213
+ : await project.settingsRemove(process.cwd(), section, dryRun);
1214
+ }
1215
+ else if (action && !action.startsWith('-'))
1216
+ throw unknownCommand(action, ['add', 'sync', 'remove', 'list'], 'settings ');
1217
+ else
1218
+ throw new CliError('USAGE', USAGE_LINES);
1219
+ process.stdout.write(JSON.stringify(machine ? envelope(`settings ${action}`, result) : result, null, 2) + '\n');
1220
+ }
741
1221
  await main().catch((error) => {
742
1222
  if (machine)
743
1223
  process.stdout.write(JSON.stringify(failure(command, error, mutationStarted)) + '\n');
@@ -788,6 +1268,9 @@ function nextSteps(error) {
788
1268
  const hint = error.details.hint;
789
1269
  if (typeof hint === 'string')
790
1270
  lines.add(`hint: ${hint}`);
1271
+ const fix = error.details.fix;
1272
+ if (typeof fix === 'string')
1273
+ lines.add(`fix: ${fix}`);
791
1274
  // A decision that belongs to the user (PREFLIGHT_FAILED with a taken port, AI-M03).
792
1275
  const { question, next, waitForUser } = error.details;
793
1276
  if (typeof question === 'string')