@ultimat3/cli 1.1.0 → 2.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 (138) hide show
  1. package/CLAUDE.md +724 -0
  2. package/README.md +41 -9
  3. package/package.json +25 -23
  4. package/src/api-routes.ts +16 -0
  5. package/src/app-auth.ts +32 -0
  6. package/src/app-entities.ts +18 -0
  7. package/src/app-env.ts +103 -0
  8. package/src/app-load.ts +20 -3
  9. package/src/bin.ts +4 -3
  10. package/src/budgets.ts +114 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +215 -0
  13. package/src/cmd-db.ts +332 -155
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +87 -17
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +64 -9
  18. package/src/cmd-env.ts +95 -0
  19. package/src/cmd-errors.ts +33 -13
  20. package/src/cmd-fix.ts +5 -1
  21. package/src/cmd-generate.ts +146 -111
  22. package/src/cmd-help.ts +16 -5
  23. package/src/cmd-i18n.ts +2 -0
  24. package/src/cmd-jobs.ts +47 -33
  25. package/src/cmd-mcp.ts +11 -2
  26. package/src/cmd-new.ts +13 -7
  27. package/src/cmd-planned.ts +55 -10
  28. package/src/cmd-policy.ts +1 -0
  29. package/src/cmd-registries.ts +3 -0
  30. package/src/cmd-secrets.ts +368 -0
  31. package/src/cmd-tasks.ts +1 -0
  32. package/src/cmd-test.ts +17 -23
  33. package/src/cmd-verify.ts +177 -23
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +251 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +112 -0
  39. package/src/db-snapshot.ts +24 -0
  40. package/src/dev-assets.ts +86 -20
  41. package/src/dev-cache.ts +122 -0
  42. package/src/dev-dashboard.ts +19 -4
  43. package/src/dev-hooks.ts +27 -2
  44. package/src/dev-n-plus-one.ts +191 -0
  45. package/src/dev-queue.ts +105 -19
  46. package/src/dev-render.ts +158 -26
  47. package/src/dev-roles-fixture.ts +67 -0
  48. package/src/dev-roles.ts +186 -78
  49. package/src/dev-runtime.ts +117 -40
  50. package/src/dev-services.ts +15 -0
  51. package/src/dev-storage.ts +245 -0
  52. package/src/dev-sync.ts +107 -0
  53. package/src/dev-traces.ts +11 -3
  54. package/src/dispatch.ts +4 -2
  55. package/src/document-styles.ts +54 -0
  56. package/src/drift.ts +37 -9
  57. package/src/error-catalog.ts +7 -18
  58. package/src/error-codes.ts +186 -0
  59. package/src/error-contract.ts +29 -7
  60. package/src/error-fixes.ts +114 -0
  61. package/src/errors.ts +205 -140
  62. package/src/fix-command.ts +268 -0
  63. package/src/flag-number.ts +56 -0
  64. package/src/framework-scope.ts +49 -0
  65. package/src/generate-kinds.ts +97 -0
  66. package/src/guards.ts +186 -0
  67. package/src/index.ts +87 -14
  68. package/src/island-bundle.ts +166 -0
  69. package/src/island-routes.ts +50 -0
  70. package/src/jobs-driver.ts +33 -0
  71. package/src/jobs-json.ts +24 -0
  72. package/src/jobs-report.ts +17 -4
  73. package/src/mcp-db-target.ts +52 -27
  74. package/src/mcp-errors.ts +120 -19
  75. package/src/mcp-host.ts +44 -25
  76. package/src/messages.ts +81 -2
  77. package/src/metrics-endpoint.ts +73 -0
  78. package/src/migrations.ts +37 -4
  79. package/src/otlp-export.ts +64 -0
  80. package/src/output.ts +46 -16
  81. package/src/parse.ts +41 -3
  82. package/src/policy-facts.ts +38 -6
  83. package/src/policy-fixture.ts +14 -7
  84. package/src/prerender.ts +111 -2
  85. package/src/registry.ts +21 -3
  86. package/src/runtime-overrides.ts +66 -0
  87. package/src/safe-url-label.ts +24 -0
  88. package/src/scaffold-fixture.ts +10 -0
  89. package/src/scaffold-typecheck.ts +16 -38
  90. package/src/serve.ts +202 -18
  91. package/src/source-files.ts +4 -0
  92. package/src/statement-loop.ts +74 -0
  93. package/src/style-csp.ts +18 -0
  94. package/src/sync-authenticator.ts +59 -0
  95. package/src/templates/action.ts +15 -30
  96. package/src/templates/admin-page.ts +103 -0
  97. package/src/templates/admin.ts +11 -7
  98. package/src/templates/backfill.ts +212 -0
  99. package/src/templates/entity.ts +72 -31
  100. package/src/templates/guard.ts +143 -0
  101. package/src/templates/index.ts +12 -1
  102. package/src/templates/island.ts +67 -0
  103. package/src/templates/job.ts +53 -13
  104. package/src/templates/naming.ts +17 -1
  105. package/src/templates/policy.ts +35 -28
  106. package/src/templates/query.ts +24 -5
  107. package/src/templates/resource.ts +19 -11
  108. package/src/templates/route.ts +90 -15
  109. package/src/templates/scaffold-app.ts +142 -45
  110. package/src/templates/scaffold-claude-agents.ts +149 -0
  111. package/src/templates/scaffold-claude-commands.ts +221 -0
  112. package/src/templates/scaffold-claude.ts +134 -0
  113. package/src/templates/scaffold-container.ts +46 -2
  114. package/src/templates/scaffold-db-package.ts +91 -0
  115. package/src/templates/scaffold-docs.ts +24 -5
  116. package/src/templates/scaffold-domain-package.ts +90 -0
  117. package/src/templates/scaffold-env.ts +87 -0
  118. package/src/templates/scaffold-i18n.ts +4 -1
  119. package/src/templates/scaffold-mcp-package.ts +49 -0
  120. package/src/templates/scaffold-package-shape.ts +25 -4
  121. package/src/templates/scaffold-repo.ts +116 -257
  122. package/src/templates/scaffold-roles.ts +68 -0
  123. package/src/templates/scaffold-ui-package.ts +56 -0
  124. package/src/templates/slice-foundation.ts +88 -0
  125. package/src/templates/wrap.ts +95 -0
  126. package/src/test-counts.ts +35 -0
  127. package/src/test-select.ts +30 -15
  128. package/src/test-shards.ts +21 -3
  129. package/src/test-workers.ts +47 -0
  130. package/src/ts-scan.ts +271 -13
  131. package/src/tsconfig-references.ts +78 -0
  132. package/src/verify-floor.ts +133 -0
  133. package/src/verify-step.ts +19 -0
  134. package/src/verify-test-run.ts +72 -0
  135. package/src/verify-tests.ts +160 -71
  136. package/src/version-loader.ts +20 -3
  137. package/src/workspace-checks.ts +87 -16
  138. package/src/write-line.ts +34 -0
package/src/cmd-errors.ts CHANGED
@@ -7,7 +7,8 @@ import type { ErrorExplanation } from '@ultimat3/mcp';
7
7
  import type { CliCommand, CommandContext } from './command';
8
8
  import type { ErrorCatalog } from './error-catalog';
9
9
  import { loadErrorCatalog } from './error-catalog';
10
- import { BadFlagError, ErrorCodeUnknownError } from './errors';
10
+ import { codeFixes, loadCodeFixes } from './error-fixes';
11
+ import { ErrorCodeUnknownError, MissingPositionalError } from './errors';
11
12
  import { explainErrorCode, explainEveryErrorCode } from './mcp-errors';
12
13
  import { msg } from './messages';
13
14
  import type { CommandResult, JsonValue } from './output';
@@ -15,12 +16,22 @@ import { nearest } from './parse';
15
16
 
16
17
  export const ERRORS_SUBCOMMANDS = ['explain', 'list'] as const;
17
18
 
18
- const asJson = (explanation: ErrorExplanation): JsonValue => ({
19
- code: explanation.code,
20
- cause: explanation.cause,
21
- fix: explanation.fix,
22
- docs: explanation.docs,
23
- });
19
+ /**
20
+ * `site` is the throw site as DATA, and it is why the `fix:` for a code whose fix is built at run
21
+ * time does not have to pretend to be a command. For those codes the location IS the answer, and a
22
+ * caller that has to regex `packages/x/src/y.ts:80` back out of an English sentence is a caller the
23
+ * machine-readable surface failed. `null` when the installed framework raises the code nowhere.
24
+ */
25
+ const asJson = (explanation: ErrorExplanation): JsonValue => {
26
+ const site = codeFixes().get(explanation.code)?.[0];
27
+ return {
28
+ code: explanation.code,
29
+ cause: explanation.cause,
30
+ fix: explanation.fix,
31
+ docs: explanation.docs,
32
+ site: site === undefined ? null : { at: site.at, line: site.line },
33
+ };
34
+ };
24
35
 
25
36
  /** The 3-line contract format, minus the leading blank code line `renderFinding` would add. */
26
37
  const detailLines = (explanation: ErrorExplanation): readonly string[] => [
@@ -79,19 +90,28 @@ export const errorsCommand: CliCommand = {
79
90
  summary: 'an X_* code, explained: cause, runnable fix, docs URL',
80
91
  usage: 'x errors [explain <CODE>|list] [--json]',
81
92
  subcommands: ERRORS_SUBCOMMANDS,
93
+ // `explain`, deliberately: the bare `x errors` then answers with `MissingPositionalError`,
94
+ // which names `<CODE>` and hands back a real invocation. `list` would silently print 200 rows
95
+ // to a caller who meant to explain one — see `MissingPositionalError`'s own note.
96
+ defaultSubcommand: 'explain',
82
97
  },
83
98
  // `async` is load-bearing: a synchronous throw would escape every caller that awaits the
84
99
  // promise this signature promises, including the dispatcher's own error path.
85
100
  async run(ctx: CommandContext): Promise<CommandResult> {
86
- const catalog = await loadErrorCatalog();
101
+ // Both loads, always, and before either subcommand branches: the catalog is which codes exist
102
+ // and the fix index is what each one instructs. `x errors list` answering 397 codes with the
103
+ // fallback line would be a complete-looking table of shrugs.
104
+ const [catalog] = await Promise.all([loadErrorCatalog(), loadCodeFixes()]);
87
105
  if (ctx.args.subcommand === 'list') return listAll(catalog);
88
106
  const code = ctx.args.positionals[0];
89
107
  if (code === undefined) {
90
- throw new BadFlagError({
91
- flag: 'code',
92
- command: 'errors',
93
- reason: 'x errors explain <CODE> needs a code',
94
- fix: 'x errors list --json',
108
+ // Not a `BadFlagError`: naming `--code` invented a flag that does not exist, so an agent
109
+ // reading the cause literally tried `x errors --code X_DB_DRIFT` and got a SECOND
110
+ // X_CLI_BAD_FLAG for an unknown flag.
111
+ throw new MissingPositionalError({
112
+ command: 'errors explain',
113
+ positional: 'CODE',
114
+ example: 'x errors list --json',
95
115
  });
96
116
  }
97
117
  return explainOne(code);
package/src/cmd-fix.ts CHANGED
@@ -92,10 +92,14 @@ const editCount = (cuts: readonly BoundaryCut[]): number =>
92
92
  export const fixCommand: CliCommand = {
93
93
  spec: {
94
94
  name: 'fix',
95
- summary: 'the minimal cut for an import that crossed a surface boundary',
95
+ // Says "plan" in the one line `x help` prints. The name is kept — five packages' `fix:` lines
96
+ // cite `x fix boundary <file>` and renaming a shipped command breaks every one of them — so
97
+ // the honest move is to stop the summary from promising a repair the command never performs.
98
+ summary: 'plan the minimal cut for an import that crossed a surface boundary (never rewrites)',
96
99
  usage: 'x fix boundary <file> [--json]',
97
100
  requiresApp: true,
98
101
  subcommands: FIX_SUBCOMMANDS,
102
+ defaultSubcommand: 'boundary',
99
103
  },
100
104
  async run(ctx: CommandContext): Promise<CommandResult> {
101
105
  const root = requireAppRoot('fix', ctx.cwd).dir;
@@ -11,12 +11,21 @@ import { appManifest, writeAppManifest } from './app-manifest';
11
11
  import { requireAppRoot } from './app-root';
12
12
  import type { CliCommand, CommandContext } from './command';
13
13
  import {
14
- BadFlagError,
15
14
  CliNotImplementedError,
16
15
  GenerateJsonInvalidError,
17
16
  ScaffoldPathEscapeError,
18
- UnknownCommandError,
19
17
  } from './errors';
18
+ // Re-exported below: `GENERATORS` and `Generator` are imported from this module by the tests, the
19
+ // scaffold fixture and `src/index.ts`, and moving where they are declared must not move where they
20
+ // are read from.
21
+ import type { Generator } from './generate-kinds';
22
+ import {
23
+ assertSurfaceSupported,
24
+ GENERATORS,
25
+ readKind,
26
+ readName,
27
+ readSurface,
28
+ } from './generate-kinds';
20
29
  import { mergeJsonDeep } from './json-merge';
21
30
  import { msg } from './messages';
22
31
  import type { CommandResult, Finding } from './output';
@@ -24,10 +33,15 @@ import { flagBool, flagList, flagString } from './parse';
24
33
  import type { GeneratedFile, Surface } from './templates';
25
34
  import {
26
35
  actionFiles,
36
+ adminPageFiles,
37
+ backfillFiles,
27
38
  CATALOG_ROOT,
28
39
  entityFiles,
40
+ guardFiles,
29
41
  i18nIndex,
42
+ islandFiles,
30
43
  jobFiles,
44
+ kebab,
31
45
  policyFiles,
32
46
  queryFiles,
33
47
  resolveLocales,
@@ -36,19 +50,8 @@ import {
36
50
  taskFiles,
37
51
  } from './templates';
38
52
 
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];
53
+ export type { Generator } from './generate-kinds';
54
+ export { GENERATORS } from './generate-kinds';
52
55
 
53
56
  export interface GenerateOptions {
54
57
  readonly kind: Generator;
@@ -60,6 +63,14 @@ export interface GenerateOptions {
60
63
  readonly admin?: boolean;
61
64
  /** Every locale a generated i18n catalog entry ships for. Defaults to `['en']`. */
62
65
  readonly locales?: readonly string[];
66
+ /**
67
+ * `island` and `admin:page`: the directory the generated files land in. Named rather than
68
+ * derived, because neither destination is derivable — `X_ISLAND_INVALID`'s cause already holds
69
+ * the path a page's `src` resolved to, and an app's admin is wherever its `defineAdmin` is.
70
+ */
71
+ readonly at?: string;
72
+ /** `admin:page` only: the permission the page's own work needs, on top of `admin:read`. */
73
+ readonly permission?: string;
63
74
  }
64
75
 
65
76
  const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
@@ -127,30 +138,13 @@ export function dedupe(files: readonly GeneratedFile[]): readonly GeneratedFile[
127
138
  return [...seen.values()];
128
139
  }
129
140
 
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
141
  /**
148
142
  * Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
149
143
  * on them, and nothing has to run a filesystem to review what a generator produces.
150
144
  */
151
145
  export function generate(options: GenerateOptions): readonly GeneratedFile[] {
152
146
  const surface: Surface = options.surface ?? 'app';
153
- assertSurfaceSupported(options.kind, surface);
147
+ assertSurfaceSupported(options.kind, surface, options.name);
154
148
  const surfaceDir = DEFAULT_SURFACE_DIR[surface];
155
149
  const feature = options.feature ?? options.name;
156
150
  const target = { surfaceDir, feature };
@@ -167,6 +161,8 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
167
161
  return dedupe(actionFiles(options.name, target));
168
162
  case 'mutator':
169
163
  return dedupe(actionFiles(options.name, { ...target, mutator: true }));
164
+ case 'backfill':
165
+ return dedupe(backfillFiles(options.name, target));
170
166
  case 'entity':
171
167
  return dedupe(entityFiles(options.name, target));
172
168
  case 'policy':
@@ -177,6 +173,22 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
177
173
  return dedupe(jobFiles(options.name, target));
178
174
  case 'task':
179
175
  return dedupe(taskFiles(options.name, target));
176
+ case 'island':
177
+ return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
178
+ // No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
179
+ // guard that lived anywhere else would need an app-side registration to be found.
180
+ case 'guard':
181
+ return dedupe(guardFiles(options.name));
182
+ case 'admin:page':
183
+ // A default permission, never none: an empty list is `X_ADMIN_PAGE_UNGUARDED` on sight.
184
+ return dedupe(
185
+ adminPageFiles(options.name, {
186
+ permission: options.permission ?? `${kebab(options.name)}:read`,
187
+ // The same `--at` `island` takes: an app's admin is wherever its `defineAdmin` is.
188
+ ...(options.at === undefined ? {} : { dir: options.at }),
189
+ ...(options.locales === undefined ? {} : { locales: options.locales }),
190
+ }),
191
+ );
180
192
  case 'route':
181
193
  // `--locales` reaches the route generator too: its catalog entry is the route's title and
182
194
  // description, and a locale asked for on the command line is a locale that gets a file.
@@ -213,7 +225,9 @@ export function containedPath(root: string, path: string): string {
213
225
  throw new ScaffoldPathEscapeError({
214
226
  path,
215
227
  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`,
228
+ // Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
229
+ // rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
230
+ fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
217
231
  });
218
232
  return target;
219
233
  }
@@ -230,20 +244,18 @@ export function containedPath(root: string, path: string): string {
230
244
  * reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard
231
245
  * its one call site already applies.
232
246
  */
233
- async function mergeJsonFile(
247
+ async function planJsonMerge(
234
248
  file: Extract<GeneratedFile, { merge: 'json' }>,
235
249
  absolute: string,
236
- ): Promise<{ written: boolean; conflict?: Finding }> {
250
+ ): Promise<WritePlan> {
237
251
  const generated = parseJsonObject(file.contents) ?? {};
238
- if (!existsSync(absolute)) {
239
- await Bun.write(absolute, prettyJson(generated));
240
- return { written: true };
241
- }
252
+ if (!existsSync(absolute))
253
+ return { kind: 'write', file, absolute, contents: prettyJson(generated) };
242
254
  const existing = parseJsonObject(await Bun.file(absolute).text());
243
255
  if (existing === undefined) {
244
256
  return {
245
- written: false,
246
- conflict: {
257
+ kind: 'conflict',
258
+ finding: {
247
259
  code: 'X_GENERATE_CONFLICT',
248
260
  cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
249
261
  fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
@@ -256,44 +268,82 @@ async function mergeJsonFile(
256
268
  // Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
257
269
  const { merged, gained } = mergeJsonDeep(existing, generated);
258
270
  // 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 };
271
+ if (!gained) return { kind: 'skip' };
272
+ return { kind: 'write', file, absolute, contents: prettyJson(merged) };
262
273
  }
263
274
 
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;
275
+ /**
276
+ * What one generated file would do, decided without doing it. The merge case computes its own
277
+ * bytes here rather than at the write, so the two passes below cannot disagree about a file.
278
+ */
279
+ type WritePlan =
280
+ | {
281
+ readonly kind: 'write';
282
+ readonly file: GeneratedFile;
283
+ readonly absolute: string;
284
+ readonly contents: string | Uint8Array;
281
285
  }
282
- if (!force && existsSync(absolute)) {
283
- conflicts.push({
286
+ | { readonly kind: 'skip' }
287
+ | { readonly kind: 'conflict'; readonly finding: Finding };
288
+
289
+ function planFile(file: GeneratedFile, absolute: string, force: boolean): WritePlan {
290
+ // A foundation file belongs to the slice, not to the generator that needs it: several generators
291
+ // emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
292
+ // overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
293
+ // `policy.ts` to regenerate one action would delete every rule they wrote. Regenerating a slice
294
+ // module is `x g entity|policy`.
295
+ if (file.merge === 'if-absent') {
296
+ return existsSync(absolute)
297
+ ? { kind: 'skip' }
298
+ : { kind: 'write', file, absolute, contents: file.contents };
299
+ }
300
+ if (!force && existsSync(absolute)) {
301
+ return {
302
+ kind: 'conflict',
303
+ finding: {
284
304
  code: 'X_GENERATE_CONFLICT',
285
305
  cause: `${file.path} already exists`,
286
306
  fix: `x g --force to overwrite, or pass a different name`,
287
307
  docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
288
308
  at: file.path,
289
- });
290
- continue;
291
- }
309
+ },
310
+ };
311
+ }
312
+ return { kind: 'write', file, absolute, contents: file.contents };
313
+ }
314
+
315
+ /**
316
+ * Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
317
+ * twice; a generator that lands four of seven files and then reports a conflict is worse, because
318
+ * the next run conflicts on the files the failed one wrote.
319
+ *
320
+ * Two passes, and the split is the point: the first decides — containment, existence, whether a
321
+ * catalog can be merged into — and touches nothing, the second writes only when the first found
322
+ * no conflict at all. Containment was already proven up front and the rest was not.
323
+ */
324
+ export async function writeFiles(
325
+ root: string,
326
+ files: readonly GeneratedFile[],
327
+ force: boolean,
328
+ ): Promise<WriteReport> {
329
+ const plans: WritePlan[] = [];
330
+ for (const file of files) {
331
+ const absolute = containedPath(root, file.path);
332
+ plans.push(
333
+ file.merge === 'json' ? await planJsonMerge(file, absolute) : planFile(file, absolute, force),
334
+ );
335
+ }
336
+ const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
337
+ if (conflicts.length > 0) return { written: [], conflicts };
338
+
339
+ const written: string[] = [];
340
+ for (const plan of plans) {
341
+ if (plan.kind !== 'write') continue;
292
342
  // 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);
343
+ await Bun.write(plan.absolute, plan.contents);
344
+ written.push(plan.file.path);
295
345
  }
296
- return { written, conflicts };
346
+ return { written, conflicts: [] };
297
347
  }
298
348
 
299
349
  const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
@@ -322,34 +372,15 @@ async function syncI18nIndex(root: string): Promise<void> {
322
372
  await Bun.write(indexAbsolute, i18nIndex(locales));
323
373
  }
324
374
 
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
375
  export const generateCommand: CliCommand = {
348
376
  spec: {
349
377
  name: 'g',
350
378
  aliases: ['generate'],
351
379
  summary: 'scaffold a primitive with its passing test',
352
- usage: 'x g resource|action|mutator|job|route|policy|entity|query|task <name> [--feature f]',
380
+ // Projected from `GENERATORS`, never restated: the literal that used to live here had already
381
+ // drifted — it omitted `backfill` — and a usage line that can disagree with the list it
382
+ // describes is exactly the second source of truth axiom 2 forbids.
383
+ usage: `x g ${GENERATORS.join('|')} <name> [--feature f]`,
353
384
  requiresApp: true,
354
385
  flags: [
355
386
  { name: 'feature', type: 'string', summary: 'feature slice to write into' },
@@ -357,6 +388,8 @@ export const generateCommand: CliCommand = {
357
388
  { name: 'live', type: 'boolean', summary: 'subscribable query' },
358
389
  { name: 'admin', type: 'boolean', summary: 'resource: also emit the admin override' },
359
390
  { name: 'locales', type: 'string', summary: 'comma-separated locales, default en' },
391
+ { name: 'at', type: 'string', summary: 'island, admin:page: directory to write into' },
392
+ { name: 'permission', type: 'string', summary: 'admin:page: the permission it needs' },
360
393
  { name: 'force', type: 'boolean', summary: 'overwrite existing files' },
361
394
  { name: 'dry-run', type: 'boolean', summary: 'print the file list, write nothing' },
362
395
  ],
@@ -364,23 +397,20 @@ export const generateCommand: CliCommand = {
364
397
  async run(ctx: CommandContext): Promise<CommandResult> {
365
398
  const root = requireAppRoot('g', ctx.cwd).dir;
366
399
  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
- }
400
+ const name = readName(ctx.args.positionals[1], kind);
375
401
  const featureFlag = flagString(ctx.args, 'feature');
376
402
  // Both flags are resolved before a single file is planned: a bad surface or a locale that is
377
403
  // really a path fails here, with nothing written and nothing to undo.
378
- const surface = readSurface(flagString(ctx.args, 'surface'), kind);
404
+ const surface = readSurface(flagString(ctx.args, 'surface'), kind, name);
379
405
  const locales = resolveLocales(flagList(ctx.args, 'locales'));
406
+ const at = flagString(ctx.args, 'at');
407
+ const permission = flagString(ctx.args, 'permission');
380
408
  const files = generate({
381
409
  kind,
382
410
  name,
383
411
  ...(featureFlag === undefined ? {} : { feature: featureFlag }),
412
+ ...(at === undefined ? {} : { at }),
413
+ ...(permission === undefined ? {} : { permission }),
384
414
  surface,
385
415
  live: flagBool(ctx.args, 'live'),
386
416
  admin: flagBool(ctx.args, 'admin'),
@@ -390,9 +420,9 @@ export const generateCommand: CliCommand = {
390
420
  return {
391
421
  ok: true,
392
422
  command: 'g',
393
- summary: msg('cli.generate.wrote', { count: files.length, kind, name }),
423
+ summary: msg('cli.generate.planned', { count: files.length, kind, name }),
394
424
  data: { files: files.map((file) => file.path), dryRun: true },
395
- lines: files.map((file) => ` + ${file.path}`),
425
+ lines: files.map((file) => msg('cli.file.added', { path: file.path })),
396
426
  };
397
427
  }
398
428
  const report = await writeFiles(root, files, flagBool(ctx.args, 'force'));
@@ -403,12 +433,16 @@ export const generateCommand: CliCommand = {
403
433
  // Facts, not prose: every `x g` run leaves the route/action/entity/job/policy table current,
404
434
  // the same guarantee `x manifest` makes on its own — an agent reading it after `x g` never
405
435
  // sees a resource that exists on disk but not in the manifest.
436
+ //
437
+ // REFRESHED, never introduced. An app that has not run `x manifest` has no committed contract
438
+ // to keep current, and writing one here hands the repo a generated file it never asked to
439
+ // maintain — `x g island` in such an app created `x.manifest.json` out of nothing.
406
440
  let buildId: string | undefined;
407
441
  // A module that would not load is omitted from the registries, so a manifest written over a
408
442
  // partial load would replace the compatibility contract with a subset of the app. The scaffold
409
443
  // stays on disk — only the projection is withheld, and the load failures travel as findings.
410
444
  const loadFailures: Finding[] = [];
411
- if (report.written.length > 0) {
445
+ if (report.written.length > 0 && existsSync(containedPath(root, MANIFEST_FILENAME))) {
412
446
  const { manifest, findings } = await appManifest(root);
413
447
  if (findings.length === 0) {
414
448
  await writeAppManifest(root, manifest);
@@ -416,18 +450,19 @@ export const generateCommand: CliCommand = {
416
450
  } else loadFailures.push(...findings);
417
451
  }
418
452
  const findings = [...report.conflicts, ...loadFailures];
453
+ // One list behind all three renderings. The manifest was printed as a `+` line while the count
454
+ // beside it came from `report.written` alone, so `x g island` said "wrote 2 file(s)" over three
455
+ // lines — and `--json` carried the shorter list, which is the drift `--json` exists to prevent.
456
+ const written = [...report.written, ...(buildId === undefined ? [] : [MANIFEST_FILENAME])];
419
457
  return {
420
458
  ok: findings.length === 0,
421
459
  command: 'g',
422
- summary: msg('cli.generate.wrote', { count: report.written.length, kind, name }),
460
+ summary: msg('cli.generate.wrote', { count: written.length, kind, name }),
423
461
  data: {
424
- files: report.written,
462
+ files: written,
425
463
  ...(buildId === undefined ? {} : { manifest: { buildId } }),
426
464
  },
427
- lines: [
428
- ...report.written.map((path) => ` + ${path}`),
429
- ...(buildId === undefined ? [] : [` + ${MANIFEST_FILENAME}`]),
430
- ],
465
+ lines: written.map((path) => msg('cli.file.added', { path })),
431
466
  findings,
432
467
  };
433
468
  },
package/src/cmd-help.ts CHANGED
@@ -19,6 +19,9 @@ export function renderHelp(specs: readonly CommandSpec[], topic: string | undefi
19
19
  (entry) => entry.name === topic || (entry.aliases ?? []).includes(topic ?? ''),
20
20
  );
21
21
  if (spec === undefined) {
22
+ // `cli.hint.help` is deliberately absent from this list: it is the command's own `summary`, and
23
+ // `renderHuman` prints every line and THEN the summary — so the catalogue ended with the same
24
+ // sentence twice, once bare and once marked `✓`. One string, one place that renders it.
22
25
  return [
23
26
  msg('cli.tagline'),
24
27
  '',
@@ -29,8 +32,6 @@ export function renderHelp(specs: readonly CommandSpec[], topic: string | undefi
29
32
  '',
30
33
  msg('cli.flags.heading'),
31
34
  ...GLOBAL_FLAGS.map(flagLine),
32
- '',
33
- msg('cli.hint.help'),
34
35
  ];
35
36
  }
36
37
  return [
@@ -51,6 +52,9 @@ const catalogue = (specs: readonly CommandSpec[]): JsonValue =>
51
52
  usage: spec.usage,
52
53
  aliases: [...(spec.aliases ?? [])],
53
54
  subcommands: [...(spec.subcommands ?? [])],
55
+ // `null` rather than absent: an agent reading this has to be able to tell "the bare form runs
56
+ // this one" from "the bare form is refused", and a missing key reads as neither.
57
+ defaultSubcommand: spec.defaultSubcommand ?? null,
54
58
  flags: [...(spec.flags ?? []), ...GLOBAL_FLAGS].map((flag) => ({
55
59
  name: flag.name,
56
60
  type: flag.type,
@@ -79,15 +83,22 @@ export function createHelpCommand(specs: () => readonly CommandSpec[]): CliComma
79
83
  };
80
84
  }
81
85
 
82
- export function createVersionCommand(version: string): CliCommand {
86
+ /**
87
+ * A resolver, not a string: `registry.ts` builds `COMMANDS` at module scope, and a manifest read
88
+ * done there runs before `main` in every process that imports `@ultimat3/cli` — including a
89
+ * compiled `apps/web/server.ts`, which never calls this command at all. Deferring the read into
90
+ * `run()` is what keeps that boot from depending on a `package.json` the binary does not carry.
91
+ */
92
+ export function createVersionCommand(version: () => string): CliCommand {
83
93
  return {
84
94
  spec: { name: 'version', summary: 'the CLI version', usage: 'x version [--json]' },
85
95
  async run(): Promise<CommandResult> {
96
+ const resolved = version();
86
97
  return {
87
98
  ok: true,
88
99
  command: 'version',
89
- summary: version,
90
- data: { version, bun: Bun.version },
100
+ summary: resolved,
101
+ data: { version: resolved, bun: Bun.version },
91
102
  };
92
103
  },
93
104
  };
package/src/cmd-i18n.ts CHANGED
@@ -201,6 +201,8 @@ export const i18nCommand: CliCommand = {
201
201
  usage: 'x i18n [check|add <locale>|sync <locale>] [--json]',
202
202
  requiresApp: true,
203
203
  subcommands: I18N_SUBCOMMANDS,
204
+ // The bare `x i18n` audits; `add` and `sync` write catalogs and must be asked for.
205
+ defaultSubcommand: 'check',
204
206
  },
205
207
  async run(ctx: CommandContext): Promise<CommandResult> {
206
208
  const root = requireAppRoot('i18n', ctx.cwd).dir;