@ultimat3/cli 1.0.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
@@ -0,0 +1,434 @@
1
+ // `x g <primitive> <name>` — scaffolding with tests that pass on the first run. A generator that
2
+ // emits a TODO has moved the work, not done it; every file this writes typechecks, and every
3
+ // primitive arrives with the test that pins its distant invariants (policy, idempotency, budget).
4
+
5
+ // `resolve`/`sep` and not `join`: only resolving the assembled path can prove it stayed inside the
6
+ // app root, and `node:path` is the only API that resolves one. `node:fs` for the exists check.
7
+ import { existsSync } from 'node:fs';
8
+ import { resolve, sep } from 'node:path';
9
+ import { MANIFEST_FILENAME } from '@ultimat3/manifest';
10
+ import { appManifest, writeAppManifest } from './app-manifest';
11
+ import { requireAppRoot } from './app-root';
12
+ import type { CliCommand, CommandContext } from './command';
13
+ import {
14
+ BadFlagError,
15
+ CliNotImplementedError,
16
+ GenerateJsonInvalidError,
17
+ ScaffoldPathEscapeError,
18
+ UnknownCommandError,
19
+ } from './errors';
20
+ import { mergeJsonDeep } from './json-merge';
21
+ import { msg } from './messages';
22
+ import type { CommandResult, Finding } from './output';
23
+ import { flagBool, flagList, flagString } from './parse';
24
+ import type { GeneratedFile, Surface } from './templates';
25
+ import {
26
+ actionFiles,
27
+ CATALOG_ROOT,
28
+ entityFiles,
29
+ i18nIndex,
30
+ jobFiles,
31
+ policyFiles,
32
+ queryFiles,
33
+ resolveLocales,
34
+ resourceFiles,
35
+ routeFiles,
36
+ taskFiles,
37
+ } from './templates';
38
+
39
+ export const GENERATORS = [
40
+ 'resource',
41
+ 'action',
42
+ 'mutator',
43
+ 'job',
44
+ 'route',
45
+ 'policy',
46
+ 'entity',
47
+ 'query',
48
+ 'task',
49
+ ] as const;
50
+
51
+ export type Generator = (typeof GENERATORS)[number];
52
+
53
+ export interface GenerateOptions {
54
+ readonly kind: Generator;
55
+ readonly name: string;
56
+ readonly feature?: string;
57
+ readonly surface?: Surface;
58
+ readonly live?: boolean;
59
+ /** `resource` only: also emit the per-entity admin override. */
60
+ readonly admin?: boolean;
61
+ /** Every locale a generated i18n catalog entry ships for. Defaults to `['en']`. */
62
+ readonly locales?: readonly string[];
63
+ }
64
+
65
+ const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
66
+ site: 'apps/web/site',
67
+ app: 'apps/web/app',
68
+ };
69
+
70
+ /** `undefined` when `text` does not parse as a JSON object — the one shape every catalog, whether
71
+ * generated or hand-edited on disk, must hold. */
72
+ function parseJsonObject(text: string): Record<string, unknown> | undefined {
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(text);
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
80
+ ? (parsed as Record<string, unknown>)
81
+ : undefined;
82
+ }
83
+
84
+ /** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
85
+ * the keys a run actually changed, never a reordering. */
86
+ function prettyJson(value: Record<string, unknown>): string {
87
+ const sorted = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
88
+ return `${JSON.stringify(sorted, null, 2)}\n`;
89
+ }
90
+
91
+ /**
92
+ * Two generators can legitimately produce the same shared file. A plain file (errors.ts) keeps
93
+ * first-write-wins; a `merge: 'json'` catalog instead merges every contributor's keys into one
94
+ * file — `resourceFiles` and `routeFiles` both target the same locale's catalog, and a plain
95
+ * overwrite would drop whichever generator ran first. Later entries fill keys the earlier one
96
+ * lacks; the first occurrence wins a clash, the same rule `writeFiles` applies against the copy
97
+ * already on disk. Exported so `x new` (`cmd-new.ts`) and the scaffold fixture resolve a shared
98
+ * catalog the identical way — one merge rule, not three hand-copied ones.
99
+ *
100
+ * A `merge: 'json'` file's `contents` are the generator's own output, not user data — one that
101
+ * fails to parse as a JSON object is a bug in the template that produced it, so it throws here
102
+ * rather than being silently treated as `{}` and merged into (or written as) a catalog with
103
+ * attribution to nobody. `writeFiles`/`mergeJsonFile` never see a malformed *generated* payload in
104
+ * practice: every production caller (`generate()` below, `cmd-new.ts`'s `planNewApp()`, the
105
+ * scaffold fixture) runs its file list through this function first.
106
+ */
107
+ export function dedupe(files: readonly GeneratedFile[]): readonly GeneratedFile[] {
108
+ const seen = new Map<string, GeneratedFile>();
109
+ for (const file of files) {
110
+ if (file.merge === 'json' && parseJsonObject(file.contents) === undefined) {
111
+ throw new GenerateJsonInvalidError({ path: file.path });
112
+ }
113
+ const prior = seen.get(file.path);
114
+ if (prior === undefined) {
115
+ seen.set(file.path, file);
116
+ } else if (prior.merge === 'json' && file.merge === 'json') {
117
+ // Both sides already proved parseable above — the fallback only guards a future change to
118
+ // that invariant, it never fires today. Deep: two generators contributing to one nested
119
+ // catalog share top-level keys (`app`, `admin`), and a shallow spread drops one of them.
120
+ const later = parseJsonObject(file.contents) ?? {};
121
+ const earlier = parseJsonObject(prior.contents) ?? {};
122
+ const { merged } = mergeJsonDeep(earlier, later);
123
+ seen.set(file.path, { ...prior, contents: prettyJson(merged) });
124
+ }
125
+ // else: not mergeable — first write wins, exactly as it always has.
126
+ }
127
+ return [...seen.values()];
128
+ }
129
+
130
+ /**
131
+ * A resource slice is app-surface by construction: it ships a live query, a form with a signal,
132
+ * two actions and a policy, and `site/` is the 0kb, never-hydrated surface that may not import
133
+ * `app/`. The documented shape is the slice in `app/` plus a separate public route
134
+ * (`docs/architecture/15-adding-a-feature.md`), so the flag is refused before anything is written
135
+ * rather than emitting a slice that fails the app's own budget and boundary gates.
136
+ */
137
+ function assertSurfaceSupported(kind: Generator, surface: Surface): void {
138
+ if (kind !== 'resource' || surface !== 'site') return;
139
+ throw new BadFlagError({
140
+ flag: 'surface',
141
+ command: 'g resource',
142
+ reason: 'a resource slice is app-surface — site/ ships 0kb JS and may not import app/',
143
+ fix: 'x g resource <name> && x g route <name> --surface site',
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
149
+ * on them, and nothing has to run a filesystem to review what a generator produces.
150
+ */
151
+ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
152
+ const surface: Surface = options.surface ?? 'app';
153
+ assertSurfaceSupported(options.kind, surface);
154
+ const surfaceDir = DEFAULT_SURFACE_DIR[surface];
155
+ const feature = options.feature ?? options.name;
156
+ const target = { surfaceDir, feature };
157
+ switch (options.kind) {
158
+ case 'resource':
159
+ return dedupe(
160
+ resourceFiles(options.name, {
161
+ ...target,
162
+ admin: options.admin === true,
163
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
164
+ }),
165
+ );
166
+ case 'action':
167
+ return dedupe(actionFiles(options.name, target));
168
+ case 'mutator':
169
+ return dedupe(actionFiles(options.name, { ...target, mutator: true }));
170
+ case 'entity':
171
+ return dedupe(entityFiles(options.name, target));
172
+ case 'policy':
173
+ return dedupe(policyFiles(options.name, target));
174
+ case 'query':
175
+ return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
176
+ case 'job':
177
+ return dedupe(jobFiles(options.name, target));
178
+ case 'task':
179
+ return dedupe(taskFiles(options.name, target));
180
+ case 'route':
181
+ // `--locales` reaches the route generator too: its catalog entry is the route's title and
182
+ // description, and a locale asked for on the command line is a locale that gets a file.
183
+ return dedupe(
184
+ routeFiles(options.name, {
185
+ surface,
186
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
187
+ }),
188
+ );
189
+ default:
190
+ throw new CliNotImplementedError({
191
+ feature: `generator "${String(options.kind)}"`,
192
+ fix: `x g ${GENERATORS.join('|')}`,
193
+ });
194
+ }
195
+ }
196
+
197
+ export interface WriteReport {
198
+ readonly written: readonly string[];
199
+ readonly conflicts: readonly Finding[];
200
+ }
201
+
202
+ /**
203
+ * `GeneratedFile.path` is documented as relative-POSIX, not enforced as it: `join` would happily
204
+ * walk out of the app on a `..` segment or ignore the root entirely on an absolute path. Proven
205
+ * before the write, once per file, because after the write there is nothing left to prove.
206
+ */
207
+ export function containedPath(root: string, path: string): string {
208
+ const base = resolve(root);
209
+ const target = resolve(base, path);
210
+ if (target !== base && !target.startsWith(`${base}${sep}`))
211
+ // The default `fix` names the scaffold gate's own test, which repairs nothing for someone
212
+ // running `x g`: the fix here is the generate command, re-run as a dry run.
213
+ throw new ScaffoldPathEscapeError({
214
+ path,
215
+ dir: base,
216
+ fix: `name the file relative to the app root with no ".." segment, then re-run: x g ${GENERATORS.join('|')} <name> --dry-run`,
217
+ });
218
+ return target;
219
+ }
220
+
221
+ /**
222
+ * A `merge: 'json'` catalog is never a conflict on existence and never subject to `--force`: an
223
+ * existing key on disk always wins, because it may hold a human translation, and only genuinely
224
+ * new keys are added — so a second, third… generator run keeps growing the same file instead of
225
+ * fighting over it. A file that exists but does not parse as a JSON object cannot be merged into
226
+ * without risking silent data loss, so that alone is reported rather than clobbered or thrown past.
227
+ *
228
+ * Typed to the `merge: 'json'` variant alone, not the general `GeneratedFile` union: a
229
+ * byte-carrying file has no `contents: string` to merge, and this is what stops one from ever
230
+ * reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard
231
+ * its one call site already applies.
232
+ */
233
+ async function mergeJsonFile(
234
+ file: Extract<GeneratedFile, { merge: 'json' }>,
235
+ absolute: string,
236
+ ): Promise<{ written: boolean; conflict?: Finding }> {
237
+ const generated = parseJsonObject(file.contents) ?? {};
238
+ if (!existsSync(absolute)) {
239
+ await Bun.write(absolute, prettyJson(generated));
240
+ return { written: true };
241
+ }
242
+ const existing = parseJsonObject(await Bun.file(absolute).text());
243
+ if (existing === undefined) {
244
+ return {
245
+ written: false,
246
+ conflict: {
247
+ code: 'X_GENERATE_CONFLICT',
248
+ cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
249
+ fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
250
+ docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
251
+ at: file.path,
252
+ },
253
+ };
254
+ }
255
+ // An existing key wins because it may hold a human translation; only the new keys are added.
256
+ // Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
257
+ const { merged, gained } = mergeJsonDeep(existing, generated);
258
+ // Every key the generator wants is already there — leave the file untouched and unclaimed.
259
+ if (!gained) return { written: false };
260
+ await Bun.write(absolute, prettyJson(merged));
261
+ return { written: true };
262
+ }
263
+
264
+ /** Never clobbers. A generator that overwrites is a generator nobody runs twice. */
265
+ export async function writeFiles(
266
+ root: string,
267
+ files: readonly GeneratedFile[],
268
+ force: boolean,
269
+ ): Promise<WriteReport> {
270
+ const written: string[] = [];
271
+ const conflicts: Finding[] = [];
272
+ // Containment first, for every file: a run that stopped at the offender would already have put
273
+ // the earlier files on disk, so the whole set is proven before any of it lands.
274
+ const targets = files.map((file) => ({ file, absolute: containedPath(root, file.path) }));
275
+ for (const { file, absolute } of targets) {
276
+ if (file.merge === 'json') {
277
+ const result = await mergeJsonFile(file, absolute);
278
+ if (result.written) written.push(file.path);
279
+ if (result.conflict !== undefined) conflicts.push(result.conflict);
280
+ continue;
281
+ }
282
+ if (!force && existsSync(absolute)) {
283
+ conflicts.push({
284
+ code: 'X_GENERATE_CONFLICT',
285
+ cause: `${file.path} already exists`,
286
+ fix: `x g --force to overwrite, or pass a different name`,
287
+ docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
288
+ at: file.path,
289
+ });
290
+ continue;
291
+ }
292
+ // Bun.write creates missing parent directories, so a generator never needs an mkdir step.
293
+ await Bun.write(absolute, file.contents);
294
+ written.push(file.path);
295
+ }
296
+ return { written, conflicts };
297
+ }
298
+
299
+ const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
300
+
301
+ /**
302
+ * `packages/i18n/src/index.ts` is the one module the app imports catalogs through, and it is
303
+ * written once, at `x new` time, importing whichever locales existed then. A later `x g
304
+ * ... --locales=es` lands `packages/i18n/catalogs/es.json` on disk, but nothing would otherwise
305
+ * teach the index about it — the catalog file would exist with real keys in it and the app could
306
+ * still never select that locale. Every run that wrote at least one file re-derives the FULL
307
+ * locale set from `packages/i18n/catalogs/` — not just the locales this invocation asked for —
308
+ * and rewrites the index to match. Bypasses `writeFiles` on purpose: this file is a projection of
309
+ * the catalog directory, never app-authored content a conflict check should protect. An app with
310
+ * no i18n package (deleted, or never scaffolded) is left alone.
311
+ */
312
+ async function syncI18nIndex(root: string): Promise<void> {
313
+ const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
314
+ if (!existsSync(indexAbsolute)) return;
315
+ const catalogDir = containedPath(root, CATALOG_ROOT);
316
+ const locales: string[] = [];
317
+ if (existsSync(catalogDir)) {
318
+ for await (const entry of new Bun.Glob('*.json').scan({ cwd: catalogDir, absolute: false })) {
319
+ locales.push(entry.replace(/\.json$/, ''));
320
+ }
321
+ }
322
+ await Bun.write(indexAbsolute, i18nIndex(locales));
323
+ }
324
+
325
+ function readKind(raw: string | undefined): Generator {
326
+ const kinds: readonly string[] = GENERATORS;
327
+ if (raw !== undefined && kinds.includes(raw)) return raw as Generator;
328
+ throw new UnknownCommandError({
329
+ path: `g ${raw ?? ''}`.trim(),
330
+ known: GENERATORS,
331
+ suggestion: 'g resource',
332
+ });
333
+ }
334
+
335
+ /** Two surfaces, spelled exactly. A typo that fell through to `app` would scaffold the wrong one. */
336
+ function readSurface(raw: string | undefined, kind: Generator): Surface {
337
+ if (raw === undefined || raw === 'app') return 'app';
338
+ if (raw === 'site') return 'site';
339
+ throw new BadFlagError({
340
+ flag: 'surface',
341
+ command: 'g',
342
+ reason: `"${raw}" is not a surface (site, app)`,
343
+ fix: `x g ${kind} <name> --surface app`,
344
+ });
345
+ }
346
+
347
+ export const generateCommand: CliCommand = {
348
+ spec: {
349
+ name: 'g',
350
+ aliases: ['generate'],
351
+ summary: 'scaffold a primitive with its passing test',
352
+ usage: 'x g resource|action|mutator|job|route|policy|entity|query|task <name> [--feature f]',
353
+ requiresApp: true,
354
+ flags: [
355
+ { name: 'feature', type: 'string', summary: 'feature slice to write into' },
356
+ { name: 'surface', type: 'string', summary: 'site | app', default: 'app' },
357
+ { name: 'live', type: 'boolean', summary: 'subscribable query' },
358
+ { name: 'admin', type: 'boolean', summary: 'resource: also emit the admin override' },
359
+ { name: 'locales', type: 'string', summary: 'comma-separated locales, default en' },
360
+ { name: 'force', type: 'boolean', summary: 'overwrite existing files' },
361
+ { name: 'dry-run', type: 'boolean', summary: 'print the file list, write nothing' },
362
+ ],
363
+ },
364
+ async run(ctx: CommandContext): Promise<CommandResult> {
365
+ const root = requireAppRoot('g', ctx.cwd).dir;
366
+ const kind = readKind(ctx.args.positionals[0]);
367
+ const name = ctx.args.positionals[1];
368
+ if (name === undefined) {
369
+ throw new UnknownCommandError({
370
+ path: `g ${kind}`,
371
+ known: GENERATORS,
372
+ suggestion: `g ${kind} <name>`,
373
+ });
374
+ }
375
+ const featureFlag = flagString(ctx.args, 'feature');
376
+ // Both flags are resolved before a single file is planned: a bad surface or a locale that is
377
+ // really a path fails here, with nothing written and nothing to undo.
378
+ const surface = readSurface(flagString(ctx.args, 'surface'), kind);
379
+ const locales = resolveLocales(flagList(ctx.args, 'locales'));
380
+ const files = generate({
381
+ kind,
382
+ name,
383
+ ...(featureFlag === undefined ? {} : { feature: featureFlag }),
384
+ surface,
385
+ live: flagBool(ctx.args, 'live'),
386
+ admin: flagBool(ctx.args, 'admin'),
387
+ locales,
388
+ });
389
+ if (flagBool(ctx.args, 'dry-run')) {
390
+ return {
391
+ ok: true,
392
+ command: 'g',
393
+ summary: msg('cli.generate.wrote', { count: files.length, kind, name }),
394
+ data: { files: files.map((file) => file.path), dryRun: true },
395
+ lines: files.map((file) => ` + ${file.path}`),
396
+ };
397
+ }
398
+ const report = await writeFiles(root, files, flagBool(ctx.args, 'force'));
399
+ // A locale's catalog existing on disk and the app being able to select it are two different
400
+ // facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
401
+ // this same invocation just wrote never gets projected against a stale catalog registration.
402
+ if (report.written.length > 0) await syncI18nIndex(root);
403
+ // Facts, not prose: every `x g` run leaves the route/action/entity/job/policy table current,
404
+ // the same guarantee `x manifest` makes on its own — an agent reading it after `x g` never
405
+ // sees a resource that exists on disk but not in the manifest.
406
+ let buildId: string | undefined;
407
+ // A module that would not load is omitted from the registries, so a manifest written over a
408
+ // partial load would replace the compatibility contract with a subset of the app. The scaffold
409
+ // stays on disk — only the projection is withheld, and the load failures travel as findings.
410
+ const loadFailures: Finding[] = [];
411
+ if (report.written.length > 0) {
412
+ const { manifest, findings } = await appManifest(root);
413
+ if (findings.length === 0) {
414
+ await writeAppManifest(root, manifest);
415
+ buildId = manifest.buildId;
416
+ } else loadFailures.push(...findings);
417
+ }
418
+ const findings = [...report.conflicts, ...loadFailures];
419
+ return {
420
+ ok: findings.length === 0,
421
+ command: 'g',
422
+ summary: msg('cli.generate.wrote', { count: report.written.length, kind, name }),
423
+ data: {
424
+ files: report.written,
425
+ ...(buildId === undefined ? {} : { manifest: { buildId } }),
426
+ },
427
+ lines: [
428
+ ...report.written.map((path) => ` + ${path}`),
429
+ ...(buildId === undefined ? [] : [` + ${MANIFEST_FILENAME}`]),
430
+ ],
431
+ findings,
432
+ };
433
+ },
434
+ };
@@ -0,0 +1,94 @@
1
+ // `x help` — the command catalogue, generated from the same specs the parser uses, so help can
2
+ // never describe a flag that does not exist. Built as a factory over the spec list to keep the
3
+ // registry acyclic.
4
+
5
+ import type { CliCommand, CommandContext } from './command';
6
+ import { msg } from './messages';
7
+ import type { CommandResult, JsonValue } from './output';
8
+ import type { CommandSpec, FlagSpec } from './parse';
9
+ import { GLOBAL_FLAGS } from './parse';
10
+
11
+ const flagLine = (flag: FlagSpec): string => {
12
+ const short = flag.short === undefined ? ' ' : `-${flag.short}, `;
13
+ const name = flag.type === 'string' ? `--${flag.name} <value>` : `--${flag.name}`;
14
+ return ` ${short}${name.padEnd(24)} ${flag.summary}`;
15
+ };
16
+
17
+ export function renderHelp(specs: readonly CommandSpec[], topic: string | undefined): string[] {
18
+ const spec = specs.find(
19
+ (entry) => entry.name === topic || (entry.aliases ?? []).includes(topic ?? ''),
20
+ );
21
+ if (spec === undefined) {
22
+ return [
23
+ msg('cli.tagline'),
24
+ '',
25
+ msg('cli.usage'),
26
+ '',
27
+ msg('cli.commands.heading'),
28
+ ...specs.map((entry) => ` ${entry.name.padEnd(10)} ${entry.summary}`),
29
+ '',
30
+ msg('cli.flags.heading'),
31
+ ...GLOBAL_FLAGS.map(flagLine),
32
+ '',
33
+ msg('cli.hint.help'),
34
+ ];
35
+ }
36
+ return [
37
+ `${spec.name} — ${spec.summary}`,
38
+ '',
39
+ spec.usage,
40
+ ...(spec.subcommands === undefined ? [] : ['', `subcommands: ${spec.subcommands.join(' | ')}`]),
41
+ '',
42
+ msg('cli.flags.heading'),
43
+ ...[...(spec.flags ?? []), ...GLOBAL_FLAGS].map(flagLine),
44
+ ];
45
+ }
46
+
47
+ const catalogue = (specs: readonly CommandSpec[]): JsonValue =>
48
+ specs.map((spec) => ({
49
+ name: spec.name,
50
+ summary: spec.summary,
51
+ usage: spec.usage,
52
+ aliases: [...(spec.aliases ?? [])],
53
+ subcommands: [...(spec.subcommands ?? [])],
54
+ flags: [...(spec.flags ?? []), ...GLOBAL_FLAGS].map((flag) => ({
55
+ name: flag.name,
56
+ type: flag.type,
57
+ summary: flag.summary,
58
+ })),
59
+ }));
60
+
61
+ export function createHelpCommand(specs: () => readonly CommandSpec[]): CliCommand {
62
+ return {
63
+ spec: {
64
+ name: 'help',
65
+ summary: 'this catalogue, or the usage for one command',
66
+ usage: 'x help [command] [--json]',
67
+ },
68
+ async run(ctx: CommandContext): Promise<CommandResult> {
69
+ const all = specs();
70
+ const topic = ctx.args.positionals[0];
71
+ return {
72
+ ok: true,
73
+ command: 'help',
74
+ summary: msg('cli.hint.help'),
75
+ lines: renderHelp(all, topic),
76
+ data: topic === undefined ? catalogue(all) : catalogue(all.filter((s) => s.name === topic)),
77
+ };
78
+ },
79
+ };
80
+ }
81
+
82
+ export function createVersionCommand(version: string): CliCommand {
83
+ return {
84
+ spec: { name: 'version', summary: 'the CLI version', usage: 'x version [--json]' },
85
+ async run(): Promise<CommandResult> {
86
+ return {
87
+ ok: true,
88
+ command: 'version',
89
+ summary: version,
90
+ data: { version, bun: Bun.version },
91
+ };
92
+ },
93
+ };
94
+ }