@dokebi/cli 0.2.4 → 0.4.0

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 (81) hide show
  1. package/AI.md +181 -87
  2. package/README.md +55 -24
  3. package/catalog.json +2199 -0
  4. package/dist/app-urls.d.ts +21 -0
  5. package/dist/app-urls.js +94 -0
  6. package/dist/banner-art.d.ts +4 -0
  7. package/dist/banner-art.js +31 -0
  8. package/dist/banner.d.ts +25 -0
  9. package/dist/banner.js +202 -0
  10. package/dist/bin.d.ts +2 -1
  11. package/dist/bin.js +475 -46
  12. package/dist/catalog.d.ts +161 -0
  13. package/dist/catalog.js +110 -0
  14. package/dist/check/form-rules.d.ts +13 -0
  15. package/dist/check/form-rules.js +224 -0
  16. package/dist/check/index.d.ts +23 -0
  17. package/dist/check/index.js +70 -0
  18. package/dist/check/packages.d.ts +17 -0
  19. package/dist/check/packages.js +161 -0
  20. package/dist/check/problems.d.ts +19 -0
  21. package/dist/check/problems.js +22 -0
  22. package/dist/check/project-rules.d.ts +10 -0
  23. package/dist/check/project-rules.js +142 -0
  24. package/dist/check/source-rules.d.ts +28 -0
  25. package/dist/check/source-rules.js +273 -0
  26. package/dist/check/typescript.d.ts +46 -0
  27. package/dist/check/typescript.js +168 -0
  28. package/dist/commands.d.ts +18 -0
  29. package/dist/commands.js +372 -0
  30. package/dist/config-edit.d.ts +7 -0
  31. package/dist/config-edit.js +17 -0
  32. package/dist/configuration.d.ts +1 -0
  33. package/dist/configuration.js +2 -1
  34. package/dist/dev-infra.d.ts +62 -0
  35. package/dist/dev-infra.js +215 -0
  36. package/dist/env-file.d.ts +24 -0
  37. package/dist/env-file.js +100 -0
  38. package/dist/generator.d.ts +24 -1
  39. package/dist/generator.js +57 -47
  40. package/dist/guide.d.ts +29 -0
  41. package/dist/guide.js +90 -0
  42. package/dist/install-plan.d.ts +8 -0
  43. package/dist/install-plan.js +21 -4
  44. package/dist/layout.d.ts +5 -4
  45. package/dist/layout.js +8 -7
  46. package/dist/log-writer-control.d.ts +55 -0
  47. package/dist/log-writer-control.js +327 -0
  48. package/dist/log-writer.js +53 -12
  49. package/dist/manifest.d.ts +28 -0
  50. package/dist/manifest.js +77 -2
  51. package/dist/npm-client.d.ts +49 -0
  52. package/dist/npm-client.js +162 -41
  53. package/dist/output.d.ts +1 -1
  54. package/dist/output.js +14 -8
  55. package/dist/plugin-lifecycle.d.ts +7 -0
  56. package/dist/plugin-lifecycle.js +21 -3
  57. package/dist/plugins-command.d.ts +47 -0
  58. package/dist/plugins-command.js +189 -0
  59. package/dist/project-guide.d.ts +36 -1
  60. package/dist/project-guide.js +141 -18
  61. package/dist/project-inspector.d.ts +10 -0
  62. package/dist/project-inspector.js +51 -2
  63. package/dist/project-state.d.ts +13 -2
  64. package/dist/project-state.js +8 -6
  65. package/dist/project.d.ts +89 -7
  66. package/dist/project.js +856 -85
  67. package/dist/recipe-record.d.ts +24 -0
  68. package/dist/recipe-record.js +67 -0
  69. package/dist/recipe.d.ts +120 -0
  70. package/dist/recipe.js +793 -0
  71. package/dist/registry.d.ts +79 -0
  72. package/dist/registry.js +212 -0
  73. package/dist/rotating-log.d.ts +12 -0
  74. package/dist/rotating-log.js +19 -0
  75. package/dist/server.d.ts +94 -4
  76. package/dist/server.js +624 -64
  77. package/dist/verify.d.ts +62 -0
  78. package/dist/verify.js +380 -0
  79. package/dist/vite-host-process.d.ts +20 -0
  80. package/dist/vite-host-process.js +117 -0
  81. package/package.json +30 -6
package/dist/bin.js CHANGED
@@ -1,11 +1,88 @@
1
1
  #!/usr/bin/env node
2
+ import { writeBanner } from './banner.js';
3
+ import { loadCatalog } from './catalog.js';
4
+ import { check, checkText } from './check/index.js';
5
+ import { COMMAND_DETAILS, COMMANDS } from './commands.js';
2
6
  import { parseSetOption } from './config-edit.js';
3
7
  import { CliError } from './errors.js';
8
+ import { buildGuide, HELP_POINTER } from './guide.js';
4
9
  import { CLI_VERSION, envelope, failure } from './output.js';
10
+ import { pluginsDoc, pluginsInfo, pluginsList, pluginsSearch } from './plugins-command.js';
5
11
  import { Project } from './project.js';
6
12
  import { ProjectInspector } from './project-inspector.js';
13
+ import { listRecipes, runRecipe } from './recipe.js';
7
14
  import * as server from './server.js';
8
- const HELP = 'dokebi init <directory> [--core <package|local.tgz>] [--json]\ndokebi add <package|local.tgz> [--set <path>=<value>]... [--dry-run] [--json]\ndokebi upgrade <plugin-id> [--to <version|package|local.tgz>] [--dry-run] [--json]\ndokebi remove <plugin-id> [--dry-run] [--json]\ndokebi migrate [--dry-run] [--json]\ndokebi inspect [--json]\ndokebi doctor [--json]\ndokebi app add <name> [--domain <host>]... [--template <name>] [--inertia [--ssr]] [--dry-run] [--json]\ndokebi app remove <name> [--dry-run] [--json]\ndokebi app link <name> <domain> [--template <name>] [--dry-run] [--json]\ndokebi app unlink <domain> [--dry-run] [--json]\ndokebi app list [--json]\ndokebi module add <name> [--no-views] [--dry-run] [--json]\ndokebi module remove <name> [--dry-run] [--json]\ndokebi module list [--json]\ndokebi dev\ndokebi start [--foreground] [--wait-ms <ms>] [--log-max-mb <MB>] [--log-files <n>] [--json]\ndokebi stop [--timeout-ms <ms>] [--force] [--json]\ndokebi status [--json]\ndokebi logs [-n <lines>] [-f]\n';
15
+ import { verify } from './verify.js';
16
+ /** The usage lines only (their format is stable; tools parse them). */
17
+ const USAGE_LINES = COMMANDS.map((item) => item.usage).join('\n') + '\n';
18
+ 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`;
19
+ /** Names the top-level dispatcher knows (`app`, `module`, `plugins`, `recipe`, `config` take more). */
20
+ const TOP_LEVEL = [...new Set(COMMANDS.map((item) => item.name.split(' ')[0]))];
21
+ /** Levenshtein distance, small inputs only (command names). */
22
+ function distance(a, b) {
23
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
24
+ for (let i = 1; i <= a.length; i++) {
25
+ const current = [i];
26
+ for (let j = 1; j <= b.length; j++)
27
+ current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
28
+ previous = current;
29
+ }
30
+ return previous[b.length];
31
+ }
32
+ /** Close names for an unknown command: same prefix or at most 2 edits (up to 3, best first). */
33
+ export function suggest(input, names) {
34
+ const aliases = {
35
+ help: '--help',
36
+ version: '--version',
37
+ '-v': '--version',
38
+ };
39
+ const alias = aliases[input];
40
+ if (alias)
41
+ return [alias];
42
+ return names
43
+ .map((name) => ({ name, score: distance(input, name) }))
44
+ .filter((item) => item.score <= Math.min(2, Math.floor(item.name.length / 2)) ||
45
+ (input.length >= 3 && item.name.startsWith(input)))
46
+ .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name))
47
+ .slice(0, 3)
48
+ .map((item) => item.name);
49
+ }
50
+ function unknownCommand(input, names, prefix = '') {
51
+ const suggestions = suggest(input, names).map((name) => name.startsWith('-') ? name : `${prefix}${name}`);
52
+ const shown = `${prefix}${input}`;
53
+ return new CliError('UNKNOWN_COMMAND', suggestions.length
54
+ ? `Unknown command "${shown}". Did you mean ${suggestions.map((name) => `"dokebi ${name}"`).join(' or ')}?`
55
+ : `Unknown command "${shown}". Run dokebi --help for the commands.`, { details: { command: shown, suggestions }, phase: 'arguments' });
56
+ }
57
+ /** `dokebi <command> [<subcommand>] --help`: that command's usage and summary. */
58
+ function commandHelp(words) {
59
+ const [first, second] = words;
60
+ if (!first || first.startsWith('-'))
61
+ return undefined;
62
+ const exact = second && !second.startsWith('-') ? `${first} ${second}` : undefined;
63
+ const matched = COMMANDS.filter((item) => exact && COMMANDS.some((other) => other.name === exact)
64
+ ? item.name === exact
65
+ : item.name === first || item.name.startsWith(`${first} `));
66
+ if (!matched.length)
67
+ return undefined;
68
+ return `${matched
69
+ .map((item) => {
70
+ const details = COMMAND_DETAILS[item.name];
71
+ const width = Math.max(0, ...(details?.flags ?? []).map(([flag]) => flag.length));
72
+ return [
73
+ item.usage,
74
+ ` ${item.summary}`,
75
+ ...(details?.flags.length
76
+ ? [
77
+ ' flags:',
78
+ ...details.flags.map(([flag, text]) => ` ${flag.padEnd(width)} ${text}`),
79
+ ]
80
+ : []),
81
+ ...(details ? [` example:\n${details.example.replace(/^/gm, ' ')}`] : []),
82
+ ].join('\n');
83
+ })
84
+ .join('\n')}\n`;
85
+ }
9
86
  const raw = process.argv.slice(2);
10
87
  const machine = raw.includes('--json');
11
88
  const args = raw.filter((arg) => arg !== '--json');
@@ -16,6 +93,7 @@ function flags(input, allowed) {
16
93
  let dryRun = false;
17
94
  let to;
18
95
  const sets = [];
96
+ const add = {};
19
97
  for (let index = 0; index < input.length; index++) {
20
98
  const arg = input[index];
21
99
  if (arg === '--dry-run' && !dryRun)
@@ -23,47 +101,177 @@ function flags(input, allowed) {
23
101
  else if (arg === '--set' && allowed.set) {
24
102
  const value = input[++index];
25
103
  if (value === undefined || value.startsWith('--'))
26
- throw new CliError('USAGE', HELP);
104
+ throw new CliError('USAGE', USAGE_LINES);
27
105
  sets.push(parseSetOption(value));
28
106
  }
29
107
  else if (arg === '--to' && allowed.to && to === undefined) {
30
108
  const value = input[++index];
31
109
  if (!value || value.startsWith('-'))
32
- throw new CliError('USAGE', HELP);
110
+ throw new CliError('USAGE', USAGE_LINES);
33
111
  to = value;
34
112
  }
113
+ else if (arg === '--adapter' && allowed.add && add.adapter === undefined) {
114
+ const value = input[++index];
115
+ if (!value || value.startsWith('-'))
116
+ throw new CliError('USAGE', USAGE_LINES);
117
+ add.adapter = value;
118
+ }
119
+ else if (arg === '--no-first-app' && allowed.add && add.firstApp === undefined)
120
+ add.firstApp = false;
35
121
  else
36
- throw new CliError('USAGE', HELP);
122
+ throw new CliError('USAGE', USAGE_LINES);
37
123
  }
38
- return { dryRun, sets, to };
124
+ return { dryRun, sets, to, add };
125
+ }
126
+ /** Prints a result: the envelope with --json, else pretty JSON (or `text` when given). */
127
+ function print(name, result, text, ok = true) {
128
+ process.stdout.write(machine
129
+ ? JSON.stringify(envelope(name, result, ok)) + '\n'
130
+ : text !== undefined
131
+ ? text + (text.endsWith('\n') ? '' : '\n')
132
+ : JSON.stringify(result, null, 2) + '\n');
39
133
  }
40
134
  async function main() {
41
135
  if (raw.filter((arg) => arg === '--json').length > 1)
42
- throw new CliError('USAGE', HELP);
43
- if (command === '--help' || command === '--version') {
136
+ throw new CliError('USAGE', USAGE_LINES);
137
+ if (command !== '--help' &&
138
+ command !== '-h' &&
139
+ (args.includes('--help') || args.includes('-h'))) {
140
+ // `dokebi <command> [<subcommand>] --help`: that command only, exit 0.
141
+ const words = [command, ...args.filter((arg) => arg !== '--help' && arg !== '-h')];
142
+ const text = commandHelp(words);
143
+ if (!text)
144
+ throw unknownCommand(command, [...TOP_LEVEL, '--help', '--version']);
145
+ process.stdout.write(machine ? JSON.stringify(envelope(command, text)) + '\n' : text);
146
+ return;
147
+ }
148
+ if (command === '--help' || command === '-h' || command === '--version') {
44
149
  if (args.length)
45
- throw new CliError('USAGE', HELP);
46
- const value = command === '--help' ? HELP : CLI_VERSION;
150
+ throw new CliError('USAGE', USAGE_LINES);
151
+ const value = command === '--version' ? CLI_VERSION : HELP;
152
+ if (command !== '--version')
153
+ writeBanner(process.stderr, machine);
47
154
  process.stdout.write(machine
48
155
  ? JSON.stringify(envelope(command, value)) + '\n'
49
156
  : value + (value.endsWith('\n') ? '' : '\n'));
50
157
  return;
51
158
  }
52
- if (command === 'inspect' || command === 'doctor') {
159
+ if (command === 'guide') {
160
+ if (args.length)
161
+ throw new CliError('USAGE', USAGE_LINES);
162
+ const guide = buildGuide(await loadCatalog());
163
+ writeBanner(process.stderr, machine);
164
+ print(command, guide, guide.text);
165
+ return;
166
+ }
167
+ if (command === 'plugins') {
168
+ await pluginsCommand(args);
169
+ return;
170
+ }
171
+ if (command === 'recipe') {
172
+ await recipeCommand(args);
173
+ return;
174
+ }
175
+ if (command === 'verify') {
176
+ const options = serverFlags(args, { '--wait-ms': undefined }, []);
177
+ const waitMs = options.numbers.get('--wait-ms');
178
+ const result = await verify(process.cwd(), waitMs !== undefined ? { waitMs } : {});
179
+ 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')}`
183
+ : ''}`, result.ok);
184
+ if (!result.ok)
185
+ process.exitCode = 1;
186
+ return;
187
+ }
188
+ if (command === 'check') {
53
189
  if (args.length)
54
- throw new CliError('USAGE', HELP);
190
+ throw new CliError('USAGE', USAGE_LINES);
191
+ const result = await check(process.cwd());
192
+ print(command, result, checkText(result), result.ok);
193
+ if (!result.ok)
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ if (command === 'config') {
198
+ const action = args.shift();
199
+ if (action === 'secret') {
200
+ const dryRun = args.includes('--dry-run');
201
+ const rest = args.filter((arg) => arg !== '--dry-run');
202
+ const target = rest[0];
203
+ const dot = target?.indexOf('.') ?? -1;
204
+ if (rest.length !== 1 || !target || dot <= 0 || target.includes('='))
205
+ throw new CliError('USAGE', USAGE_LINES);
206
+ // The path is checked like a --set path (segments, no prototype keys).
207
+ const path = parseSetOption(`${target.slice(dot + 1)}=0`).path;
208
+ mutationStarted = true;
209
+ const result = await new Project().configSecret(process.cwd(), target.slice(0, dot), path, dryRun);
210
+ print('config secret', result);
211
+ return;
212
+ }
213
+ if (action !== 'set') {
214
+ if (action && !action.startsWith('-'))
215
+ throw unknownCommand(action, ['set', 'secret'], 'config ');
216
+ throw new CliError('USAGE', USAGE_LINES);
217
+ }
218
+ let dryRun = false;
219
+ const assignments = [];
220
+ for (const arg of args) {
221
+ if (arg === '--dry-run' && !dryRun)
222
+ dryRun = true;
223
+ else if (!arg.startsWith('-')) {
224
+ // <plugin-id>.<path>=<value>: the plugin id has no dot; the path is dot separated.
225
+ const dot = arg.indexOf('.');
226
+ const equals = arg.indexOf('=');
227
+ if (dot <= 0 || equals < dot)
228
+ throw new CliError('USAGE', USAGE_LINES);
229
+ assignments.push({ plugin: arg.slice(0, dot), set: parseSetOption(arg.slice(dot + 1)) });
230
+ }
231
+ else
232
+ throw new CliError('USAGE', USAGE_LINES);
233
+ }
234
+ if (!assignments.length)
235
+ throw new CliError('USAGE', USAGE_LINES);
236
+ mutationStarted = true;
237
+ const result = await new Project().configSet(process.cwd(), assignments, dryRun);
238
+ printWarnings(result.warnings);
239
+ print('config set', result);
240
+ return;
241
+ }
242
+ if (command === 'dev-infra') {
243
+ const dryRun = args.includes('--dry-run');
244
+ const options = serverFlags(args.filter((arg) => arg !== '--dry-run'), { '--db-port': [1, 65535] }, []);
245
+ mutationStarted = true;
246
+ const dbPort = options.numbers.get('--db-port');
247
+ const result = await new Project().devInfra(process.cwd(), {
248
+ dryRun,
249
+ ...(dbPort !== undefined ? { dbPort } : {}),
250
+ });
251
+ print(command, result);
252
+ return;
253
+ }
254
+ if (command === 'inspect' || command === 'doctor') {
255
+ const verbose = args.length === 1 && args[0] === '--verbose';
256
+ if (args.length && !verbose)
257
+ throw new CliError('USAGE', USAGE_LINES);
55
258
  const result = await new ProjectInspector().inspect(process.cwd(), {
56
259
  runChecks: command === 'doctor',
57
260
  });
58
261
  const ok = command === 'inspect' || result.healthy;
59
- process.stdout.write(JSON.stringify(machine ? envelope(command, result, ok) : result, null, 2) + '\n');
262
+ // --json is always complete (summary first); people get the short form unless --verbose.
263
+ process.stdout.write(machine
264
+ ? `${JSON.stringify(envelope(command, result, ok), null, 2)}\n`
265
+ : verbose
266
+ ? `${JSON.stringify(result, null, 2)}\n`
267
+ : inspectionText(result));
60
268
  if (!ok)
61
269
  process.exitCode = 1;
62
270
  return;
63
271
  }
64
272
  if (command === 'migrate') {
65
273
  if (args.length && (args.length !== 1 || args[0] !== '--dry-run'))
66
- throw new CliError('USAGE', HELP);
274
+ throw new CliError('USAGE', USAGE_LINES);
67
275
  mutationStarted = true;
68
276
  const result = await new Project().migrate(process.cwd(), args[0] === '--dry-run');
69
277
  process.stdout.write(JSON.stringify(machine ? envelope(command, result) : result, null, 2) + '\n');
@@ -81,22 +289,36 @@ async function main() {
81
289
  await moduleCommand(args);
82
290
  return;
83
291
  }
292
+ if (!['init', 'add', 'upgrade', 'remove'].includes(command))
293
+ throw unknownCommand(command, [...TOP_LEVEL, '--help', '--version']);
84
294
  const target = args.shift();
85
295
  if (!target || target.startsWith('-'))
86
- throw new CliError('USAGE', HELP);
296
+ throw new CliError('USAGE', USAGE_LINES);
87
297
  const project = new Project();
88
298
  let result;
89
299
  if (command === 'init') {
90
- if (args.length &&
91
- (args.length !== 2 || args[0] !== '--core' || !args[1] || args[1].startsWith('-')))
92
- throw new CliError('USAGE', HELP);
93
- mutationStarted = true;
94
- result = await project.init(target, args[1]);
300
+ let core;
301
+ let dryRun = false;
302
+ for (let index = 0; index < args.length; index++) {
303
+ const arg = args[index];
304
+ if (arg === '--core' && core === undefined) {
305
+ core = args[++index];
306
+ if (!core || core.startsWith('-'))
307
+ throw new CliError('USAGE', USAGE_LINES);
308
+ }
309
+ else if (arg === '--dry-run' && !dryRun)
310
+ dryRun = true;
311
+ else
312
+ throw new CliError('USAGE', USAGE_LINES);
313
+ }
314
+ writeBanner(process.stderr, machine);
315
+ mutationStarted = !dryRun;
316
+ result = await project.init(target, core, dryRun);
95
317
  }
96
318
  else if (command === 'add') {
97
- const options = flags(args, { set: true });
319
+ const options = flags(args, { set: true, add: true });
98
320
  mutationStarted = true;
99
- result = await project.add(process.cwd(), target, options.dryRun, options.sets);
321
+ result = await project.add(process.cwd(), target, options.dryRun, options.sets, options.add);
100
322
  }
101
323
  else if (command === 'upgrade') {
102
324
  const options = flags(args, { to: true });
@@ -109,12 +331,187 @@ async function main() {
109
331
  result = await project.remove(process.cwd(), target, options.dryRun);
110
332
  }
111
333
  else
112
- throw new CliError('USAGE', HELP);
113
- if (!machine)
114
- for (const warning of result.warnings ?? [])
115
- if (warning.code === 'SECRET_ON_COMMAND_LINE')
116
- process.stderr.write(`warning: ${warning.path} looks secret; command-line values can remain in shell history\n`);
334
+ throw new CliError('USAGE', USAGE_LINES);
335
+ printWarnings(result.warnings);
117
336
  process.stdout.write(JSON.stringify(machine ? envelope(command, result) : result, null, 2) + '\n');
337
+ // stdout stays the result document; the same next steps are printed for people on stderr.
338
+ if (!machine && result.nextSteps?.length)
339
+ process.stderr.write(`next steps:\n${result.nextSteps.map((step) => ` ${step.command}\n ${step.why}\n`).join('')}`);
340
+ }
341
+ /** The short human form of inspect/doctor: summary, each diagnostic and plugin on one line. */
342
+ function inspectionText(result) {
343
+ const { summary } = result;
344
+ const lines = [
345
+ `${summary.healthy ? 'healthy' : 'NOT healthy'}: ${summary.errors} errors, ${summary.warnings} warnings, ${summary.plugins} plugins, core ${result.coreVersion ?? '-'}`,
346
+ ...result.diagnostics.map((item) => `${item.severity === 'error' ? 'error ' : 'warning'} ${item.code} ${item.path}${item.plugin ? ` (${item.plugin})` : ''}${item.hint ? `\n hint: ${item.hint}` : ''}`),
347
+ ...result.plugins.map((item) => `plugin ${item.expected.id} ${item.installed?.version ?? 'MISSING'}${item.documentation ? ` · ${item.documentation}` : ''}`),
348
+ 'details: --verbose (or --json for the full document)',
349
+ ];
350
+ return `${lines.join('\n')}\n`;
351
+ }
352
+ /** Human output: warnings people should see go to stderr (the result stays on stdout). */
353
+ function printWarnings(warnings) {
354
+ if (machine)
355
+ return;
356
+ 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')
360
+ process.stderr.write(`warning: ${warning.path} is not a @dokebi package; install it only with the user's consent\n`);
361
+ }
362
+ /** `dokebi plugins list|search|info|doc` (D-0065). */
363
+ async function pluginsCommand(input) {
364
+ const action = input.shift();
365
+ const name = `plugins ${action}`;
366
+ if (action === 'list') {
367
+ let compatible = false;
368
+ let online = false;
369
+ for (const arg of input) {
370
+ if (arg === '--compatible' && !compatible)
371
+ compatible = true;
372
+ else if (arg === '--online' && !online)
373
+ online = true;
374
+ else
375
+ throw new CliError('USAGE', USAGE_LINES);
376
+ }
377
+ const result = await pluginsList(process.cwd(), { compatible, online });
378
+ print(name, result, result.packages
379
+ .map((item) => `${item.package}@${item.version}${item.installed ? ` (installed ${item.installed})` : ''}${item.compatible ? '' : ' [not for this core]'}\n ${item.summary}\n provides: ${item.provides.join(', ') || '-'} · ${item.install}`)
380
+ .join('\n') +
381
+ `\n${result.warnings.map((warning) => `warning: ${warning.code} ${warning.message}`).join('\n')}`);
382
+ }
383
+ else if (action === 'search') {
384
+ const words = input.filter((arg) => !arg.startsWith('-'));
385
+ if (!words.length || words.length !== input.length)
386
+ throw new CliError('USAGE', USAGE_LINES);
387
+ const result = await pluginsSearch(words.join(' '));
388
+ print(name, result, [
389
+ ...result.matches.map((item) => `${item.package}@${item.version}: ${item.summary}`),
390
+ ...result.recipes.map((recipe) => `recipe ${recipe.name}: ${recipe.summary}`),
391
+ ...result.absent.map((entry) => `ABSENT ${entry.capability}: ${entry.summary}. ${entry.instead}`),
392
+ ...(result.status === 'unknown' ? ['nothing found (not in the catalog)'] : []),
393
+ ].join('\n'));
394
+ }
395
+ else if (action === 'info' || action === 'doc') {
396
+ const key = input.shift();
397
+ if (!key || key.startsWith('-') || input.length)
398
+ throw new CliError('USAGE', USAGE_LINES);
399
+ if (action === 'info')
400
+ print(name, await pluginsInfo(process.cwd(), key));
401
+ else {
402
+ const result = await pluginsDoc(process.cwd(), key);
403
+ print(name, result, `# ${result.path} (${result.package} ${result.version})\n\n${result.content}`);
404
+ }
405
+ }
406
+ else if (action && !action.startsWith('-'))
407
+ throw unknownCommand(action, ['list', 'search', 'info', 'doc'], 'plugins ');
408
+ else
409
+ throw new CliError('USAGE', USAGE_LINES);
410
+ }
411
+ /** `dokebi recipe list|<name>` (D-0065). Missing answers: questions, exit 2, nothing installed. */
412
+ async function recipeCommand(input) {
413
+ const name = input.shift();
414
+ if (!name || name.startsWith('-'))
415
+ throw new CliError('USAGE', USAGE_LINES);
416
+ if (name === 'list') {
417
+ if (input.length)
418
+ throw new CliError('USAGE', USAGE_LINES);
419
+ const result = await listRecipes();
420
+ print('recipe list', result, result.recipes
421
+ .map((recipe) => `${recipe.name}: ${recipe.summary}\n questions: ${recipe.questions.join(', ')}`)
422
+ .join('\n'));
423
+ return;
424
+ }
425
+ const answers = {};
426
+ const flagOf = {
427
+ '--view': 'view',
428
+ '--db': 'db',
429
+ '--dev-infra': 'devInfra',
430
+ '--admin': 'admin',
431
+ };
432
+ let dir;
433
+ let defaults = false;
434
+ let answersFrom;
435
+ let dryRun = false;
436
+ let port;
437
+ let dbPort;
438
+ const number = (flag, value) => {
439
+ const parsed = Number(value);
440
+ if (value === undefined || !Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535)
441
+ throw new CliError('USAGE', `${flag} must be a port from 1 to 65535\n${USAGE_LINES}`);
442
+ return parsed;
443
+ };
444
+ for (let index = 0; index < input.length; index++) {
445
+ const arg = input[index];
446
+ const question = flagOf[arg];
447
+ if (question && answers[question] === undefined) {
448
+ const value = input[++index];
449
+ if (!value || value.startsWith('-'))
450
+ throw new CliError('USAGE', USAGE_LINES);
451
+ answers[question] = value;
452
+ }
453
+ else if (arg === '--dir' && dir === undefined) {
454
+ dir = input[++index];
455
+ if (!dir || dir.startsWith('-'))
456
+ throw new CliError('USAGE', USAGE_LINES);
457
+ }
458
+ else if (arg === '--port' && port === undefined)
459
+ port = number(arg, input[++index]);
460
+ else if (arg === '--db-port' && dbPort === undefined)
461
+ dbPort = number(arg, input[++index]);
462
+ else if (arg === '--defaults' && !defaults)
463
+ defaults = true;
464
+ else if (arg === '--answers-from' && answersFrom === undefined) {
465
+ const value = input[++index];
466
+ if (value !== 'user' && value !== 'defaults')
467
+ throw new CliError('USAGE', `--answers-from must be user (the user answered) or defaults (the user said to decide)\n${USAGE_LINES}`);
468
+ answersFrom = value;
469
+ }
470
+ else if (arg === '--dry-run' && !dryRun)
471
+ dryRun = true;
472
+ else
473
+ throw new CliError('USAGE', USAGE_LINES);
474
+ }
475
+ mutationStarted = !dryRun;
476
+ const result = await runRecipe({
477
+ name,
478
+ answers,
479
+ defaults,
480
+ ...(answersFrom !== undefined ? { answersFrom } : {}),
481
+ dryRun,
482
+ ...(dir !== undefined ? { dir } : {}),
483
+ ...(port !== undefined ? { port } : {}),
484
+ ...(dbPort !== undefined ? { dbPort } : {}),
485
+ }, new Project());
486
+ if (result.status === 'needs-answers') {
487
+ // Not an error of the command: the answers come from the user. Nothing was installed.
488
+ process.exitCode = 2;
489
+ if (machine)
490
+ process.stdout.write(JSON.stringify({
491
+ schemaVersion: 1,
492
+ cliVersion: CLI_VERSION,
493
+ command: `recipe ${name}`,
494
+ ok: false,
495
+ result,
496
+ error: {
497
+ code: 'ANSWERS_REQUIRED',
498
+ message: `Ask the user: ${result.missing.join(', ')} — then end your turn and wait for the answers`,
499
+ phase: 'arguments',
500
+ mutation: 'none',
501
+ details: { missing: result.missing },
502
+ },
503
+ }) + '\n');
504
+ else
505
+ process.stdout.write(`ANSWERS_REQUIRED — ask the user, then end your turn; install nothing before they answer (recipe ${name}: ${result.summary})\n\n${result.questions
506
+ .map((question) => `${question.flag} — ${question.question}${question.answer ? ` [answered: ${question.answer.value}]` : ''}${question.when ? ` (only when ${JSON.stringify(question.when)})` : ''}\n${question.options
507
+ .map((option) => ` ${option.value}${option.value === question.default ? ' (default)' : ''}${option.available ? '' : ' — NOT AVAILABLE'}: ${option.label}${option.why ? ` — ${option.why}` : ''}${option.note ? ` — NOTE: ${option.note}` : ''}${option.pros.length ? `\n + ${option.pros.join('\n + ')}` : ''}${option.cons.length ? `\n - ${option.cons.join('\n - ')}` : ''}`)
508
+ .join('\n')}\n default ${question.default}: ${question.defaultReason}`)
509
+ .join('\n\n')}\n\n${result.next}\n`);
510
+ return;
511
+ }
512
+ print(`recipe ${name}`, result);
513
+ if (!machine && result.report)
514
+ process.stderr.write(`${result.report}\n`);
118
515
  }
119
516
  /**
120
517
  * Reads `--name <number>` style flags of the server commands; anything else is a usage error.
@@ -131,11 +528,11 @@ function serverFlags(input, numbers, switches) {
131
528
  const text = input[++index];
132
529
  const value = Number(text);
133
530
  if (text === undefined || !Number.isSafeInteger(value) || value < min || value > max)
134
- throw new CliError('USAGE', `${arg} must be an integer from ${min} to ${max}\n${HELP}`);
531
+ throw new CliError('USAGE', `${arg} must be an integer from ${min} to ${max}\n${USAGE_LINES}`);
135
532
  result.numbers.set(arg, value);
136
533
  }
137
534
  else
138
- throw new CliError('USAGE', HELP);
535
+ throw new CliError('USAGE', USAGE_LINES);
139
536
  }
140
537
  return result;
141
538
  }
@@ -145,7 +542,7 @@ async function serverCommand(input) {
145
542
  const print = (result, text) => process.stdout.write(machine ? JSON.stringify(envelope(command, result)) + '\n' : text + '\n');
146
543
  if (command === 'dev') {
147
544
  if (input.length || machine)
148
- throw new CliError('USAGE', HELP);
545
+ throw new CliError('USAGE', USAGE_LINES);
149
546
  process.exitCode = await server.dev(root, (line) => process.stderr.write(`[dokebi dev] ${line}\n`));
150
547
  }
151
548
  else if (command === 'start') {
@@ -158,7 +555,7 @@ async function serverCommand(input) {
158
555
  if (options.switches.has('--foreground')) {
159
556
  // The foreground app writes to the terminal; rotation belongs to systemd/Docker there.
160
557
  if (machine || options.numbers.size)
161
- throw new CliError('USAGE', HELP);
558
+ throw new CliError('USAGE', USAGE_LINES);
162
559
  process.exitCode = await server.foreground(root);
163
560
  return;
164
561
  }
@@ -173,7 +570,7 @@ async function serverCommand(input) {
173
570
  if (!machine)
174
571
  for (const warning of result.warnings)
175
572
  process.stderr.write(`warning: ${warning}\n`);
176
- print(result, `started pid ${result.pid}${result.ready ? ` (ready in ${result.ms} ms)` : ''}\nlog: ${result.log} (rotated at ${Math.round(result.logMaxBytes / 1048576)} MB, ${result.logFiles} older files kept)`);
573
+ print(result, `started pid ${result.pid}${result.ready ? ` (ready in ${result.ms} ms)` : ''}${addressLines(result)}\nlog: ${result.log} (rotated at ${Math.round(result.logMaxBytes / 1048576)} MB, ${result.logFiles} older files kept)`);
177
574
  }
178
575
  else if (command === 'stop') {
179
576
  const options = serverFlags(input, { '--timeout-ms': undefined }, ['--force']);
@@ -182,14 +579,14 @@ async function serverCommand(input) {
182
579
  for (const warning of result.warnings ?? [])
183
580
  process.stderr.write(`warning: ${warning}\n`);
184
581
  print(result, result.stopped
185
- ? `stopped pid ${result.pid} in ${result.ms} ms${result.forced ? ' (killed)' : ''}`
582
+ ? `stopped pid ${result.pid} in ${result.ms} ms${result.forced ? ' (killed)' : ''}${result.proof === 'http' ? ' (no ps: the app proved its instance token over HTTP)' : result.proof === 'log-writer' ? ' (no ps: its log writer proved the instance token)' : ''}${result.via === 'log-writer' ? ' (signalled by its log writer: this shell may not signal it)' : ''}`
186
583
  : result.stale
187
584
  ? `not running (pid ${result.pid} had already exited; removed the pid file)`
188
585
  : 'not running');
189
586
  }
190
587
  else if (command === 'status') {
191
588
  if (input.length)
192
- throw new CliError('USAGE', HELP);
589
+ throw new CliError('USAGE', USAGE_LINES);
193
590
  const result = await server.status(root);
194
591
  const files = result.logFiles
195
592
  .map((file) => `\n ${file.file} (${(file.bytes / 1048576).toFixed(1)} MB)`)
@@ -201,7 +598,7 @@ async function serverCommand(input) {
201
598
  ? `\nlog writer diagnostics: ${result.logWriterLog}`
202
599
  : '';
203
600
  print(result, (result.running
204
- ? `running pid ${result.pid} · up ${result.uptimeSec} s · ${result.rssMb} MB${writer}`
601
+ ? `running pid ${result.pid} · up ${result.uptimeSec} s${result.rssMb !== undefined ? ` · ${result.rssMb} MB` : ''}${result.proof === 'http' ? ' · proof: http (no ps; the app answered its instance token)' : result.proof === 'log-writer' ? ' · proof: log-writer (no ps; its log writer proved the instance token)' : ''}${writer}`
205
602
  : result.stale
206
603
  ? `stopped: pid ${result.pid} exited without dokebi stop (crashed or killed); see dokebi logs`
207
604
  : 'stopped') +
@@ -212,7 +609,7 @@ async function serverCommand(input) {
212
609
  }
213
610
  else {
214
611
  if (machine)
215
- throw new CliError('USAGE', HELP);
612
+ throw new CliError('USAGE', USAGE_LINES);
216
613
  const options = serverFlags(input, { '-n': undefined }, ['-f', '--follow']);
217
614
  await server.logs(root, options.numbers.get('-n'), options.switches.has('-f') || options.switches.has('--follow'));
218
615
  }
@@ -224,13 +621,13 @@ async function app(input) {
224
621
  let result;
225
622
  if (action === 'list') {
226
623
  if (input.length)
227
- throw new CliError('USAGE', HELP);
624
+ throw new CliError('USAGE', USAGE_LINES);
228
625
  result = await project.appList(process.cwd());
229
626
  }
230
627
  else if (action === 'add' || action === 'remove') {
231
628
  const name = input.shift();
232
629
  if (!name || name.startsWith('-'))
233
- throw new CliError('USAGE', HELP);
630
+ throw new CliError('USAGE', USAGE_LINES);
234
631
  let dryRun = false;
235
632
  let inertia = false;
236
633
  let ssr = false;
@@ -254,11 +651,11 @@ async function app(input) {
254
651
  index++;
255
652
  }
256
653
  else
257
- throw new CliError('USAGE', HELP);
654
+ throw new CliError('USAGE', USAGE_LINES);
258
655
  }
259
656
  // Server rendering (D-0039) is a choice of an Inertia app.
260
657
  if (ssr && !inertia)
261
- throw new CliError('USAGE', HELP);
658
+ throw new CliError('USAGE', USAGE_LINES);
262
659
  mutationStarted = true;
263
660
  result =
264
661
  action === 'add'
@@ -275,7 +672,7 @@ async function app(input) {
275
672
  const first = input.shift();
276
673
  const domain = action === 'link' ? input.shift() : first;
277
674
  if (!first || first.startsWith('-') || !domain || domain.startsWith('-'))
278
- throw new CliError('USAGE', HELP);
675
+ throw new CliError('USAGE', USAGE_LINES);
279
676
  let dryRun = false;
280
677
  let template;
281
678
  for (let index = 0; index < input.length; index++) {
@@ -288,7 +685,7 @@ async function app(input) {
288
685
  index++;
289
686
  }
290
687
  else
291
- throw new CliError('USAGE', HELP);
688
+ throw new CliError('USAGE', USAGE_LINES);
292
689
  }
293
690
  mutationStarted = true;
294
691
  result =
@@ -299,8 +696,10 @@ async function app(input) {
299
696
  })
300
697
  : await project.appUnlink(process.cwd(), domain, dryRun);
301
698
  }
699
+ else if (action && !action.startsWith('-'))
700
+ throw unknownCommand(action, ['add', 'remove', 'link', 'unlink', 'list'], 'app ');
302
701
  else
303
- throw new CliError('USAGE', HELP);
702
+ throw new CliError('USAGE', USAGE_LINES);
304
703
  process.stdout.write(JSON.stringify(machine ? envelope(`app ${action}`, result) : result, null, 2) + '\n');
305
704
  }
306
705
  /** `dokebi module add|remove|list` (D-0032): feature modules in src/shared/modules/<name>. */
@@ -310,13 +709,13 @@ async function moduleCommand(input) {
310
709
  let result;
311
710
  if (action === 'list') {
312
711
  if (input.length)
313
- throw new CliError('USAGE', HELP);
712
+ throw new CliError('USAGE', USAGE_LINES);
314
713
  result = await project.moduleList(process.cwd());
315
714
  }
316
715
  else if (action === 'add' || action === 'remove') {
317
716
  const name = input.shift();
318
717
  if (!name || name.startsWith('-'))
319
- throw new CliError('USAGE', HELP);
718
+ throw new CliError('USAGE', USAGE_LINES);
320
719
  let dryRun = false;
321
720
  let views = true;
322
721
  for (const arg of input) {
@@ -325,7 +724,7 @@ async function moduleCommand(input) {
325
724
  else if (action === 'add' && arg === '--no-views' && views)
326
725
  views = false;
327
726
  else
328
- throw new CliError('USAGE', HELP);
727
+ throw new CliError('USAGE', USAGE_LINES);
329
728
  }
330
729
  mutationStarted = true;
331
730
  result =
@@ -333,8 +732,10 @@ async function moduleCommand(input) {
333
732
  ? await project.moduleAdd(process.cwd(), name, { views, dryRun })
334
733
  : await project.moduleRemove(process.cwd(), name, dryRun);
335
734
  }
735
+ else if (action && !action.startsWith('-'))
736
+ throw unknownCommand(action, ['add', 'remove', 'list'], 'module ');
336
737
  else
337
- throw new CliError('USAGE', HELP);
738
+ throw new CliError('USAGE', USAGE_LINES);
338
739
  process.stdout.write(JSON.stringify(machine ? envelope(`module ${action}`, result) : result, null, 2) + '\n');
339
740
  }
340
741
  await main().catch((error) => {
@@ -346,6 +747,17 @@ await main().catch((error) => {
346
747
  nextSteps(error));
347
748
  process.exitCode = 1;
348
749
  });
750
+ /**
751
+ * `urls:` (where the app answers: its loopback domains) and, when it differs, `listen:` (the
752
+ * bound address, which answers only the Host names of the app domains) — AI-M03.
753
+ */
754
+ function addressLines(result) {
755
+ const same = result.urls.length === result.listen.length &&
756
+ result.urls.every((url, index) => url === result.listen[index]);
757
+ return `${result.urls.length ? `\nurls: ${result.urls.join(' ')}` : ''}${result.listen.length && !same
758
+ ? `\nlisten: ${result.listen.join(' ')} (the bound address${result.urls.length ? '' : '; it answers only the Host names of the app domains, none of which is a loopback name such as localhost'})`
759
+ : ''}`;
760
+ }
349
761
  /** Human-readable follow-ups from error details: missing option fields and plugin hints. */
350
762
  function nextSteps(error) {
351
763
  if (!(error instanceof CliError) || !error.details || typeof error.details !== 'object')
@@ -359,6 +771,23 @@ function nextSteps(error) {
359
771
  const tail = error.details.tail;
360
772
  if (typeof tail === 'string' && tail)
361
773
  lines.add(`last log lines:\n${tail.trimEnd()}`);
774
+ const npmError = error.details.stderr;
775
+ if (error.code === 'NPM_FAILED' && typeof npmError === 'string' && npmError.trim())
776
+ lines.add(`npm stderr (end):\n${npmError.trimEnd()}`);
777
+ if (error.code === 'UNKNOWN_COMMAND')
778
+ lines.add('hint: dokebi --help lists the commands');
779
+ const hint = error.details.hint;
780
+ if (typeof hint === 'string')
781
+ lines.add(`hint: ${hint}`);
782
+ // A decision that belongs to the user (PREFLIGHT_FAILED with a taken port, AI-M03).
783
+ const { question, next, waitForUser } = error.details;
784
+ if (typeof question === 'string')
785
+ lines.add(`${waitForUser === true ? 'ask the user' : 'question'}: ${question}`);
786
+ if (typeof next === 'string')
787
+ lines.add(`next: ${next}`);
788
+ const manual = error.details.manualStop;
789
+ if (typeof manual === 'string')
790
+ lines.add(`manual stop: ${manual}`);
362
791
  if (error.code === 'WORKSPACE_SYNC_FAILED')
363
792
  lines.add('hint: run npm install at the workspace root');
364
793
  if (Array.isArray(details.diagnostics))