@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/errors.ts CHANGED
@@ -1,136 +1,8 @@
1
- // The X_* codes owned by @ultimat3/cli. Every one names the exact command that resolves it,
2
- // because the CLI is the surface an agent reads first — a failure here has to be actionable
3
- // without a doc lookup or a second round-trip.
4
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
-
6
- /** Codes this package declares and owns. */
7
- export const CLI_OWNED_ERROR_CODES = [
8
- 'X_CLI_UNKNOWN_COMMAND',
9
- 'X_CLI_BAD_FLAG',
10
- 'X_VERIFY_FAILED',
11
- 'X_NOT_IN_APP',
12
- 'X_BUN_VERSION',
13
- 'X_TEST_NO_FILES',
14
- 'X_TEST_SHARD_FAILED',
15
- 'X_SCAFFOLD_PATH_ESCAPE',
16
- 'X_GENERATE_JSON_INVALID',
17
- 'X_APP_PACKAGE_INVALID',
18
- 'X_ERROR_CODE_UNKNOWN',
19
- 'X_DECLARATION_UNKNOWN',
20
- 'X_JOB_UNKNOWN',
21
- 'X_FIX_TARGET_UNKNOWN',
22
- 'X_ERROR_FIX_INVALID',
23
- 'X_ERROR_CODE_UNDOCUMENTED',
24
- 'X_ERROR_CODE_UNREGISTERED',
25
- // Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
26
- // `x errors explain X_TYPECHECK_FAILED` refused a code `x verify` had just printed. A finding
27
- // carries an `X_*` code to the same reader a throw does; the registry is what makes that code
28
- // explainable, unique and documented-or-fail, so a code the CLI emits is a code the CLI owns.
29
- 'X_CLI_UNEXPECTED',
30
- 'X_TYPECHECK_FAILED',
31
- 'X_LINT_FAILED',
32
- 'X_TEST_FAILED',
33
- 'X_FILE_TOO_LONG',
34
- 'X_PACKAGE_SHAPE',
35
- 'X_RELEASE_VERSION_SKEW',
36
- 'X_MANIFEST_STALE',
37
- 'X_BUDGET_UNMEASURED',
38
- 'X_BUILD_FAILED',
39
- 'X_BUILD_ENTRY_MISSING',
40
- 'X_DEPLOY_FAILED',
41
- // The two the container's own environment can get wrong. A PaaS injects `PORT` and a supervisor
42
- // injects `ROLE`; both arrive as strings from outside the app, so both are validated at boot
43
- // rather than defaulted — a web role that quietly bound 3000 when the platform said 8080 fails
44
- // its health check with nothing in the log that names the cause.
45
- 'X_ROLE_UNKNOWN',
46
- 'X_PORT_INVALID',
47
- 'X_GENERATE_CONFLICT',
48
- 'X_PORT_IN_USE',
49
- 'X_DB_GEN_FAILED',
50
- 'X_DB_MIGRATE_FAILED',
51
- 'X_DB_BRANCH_FAILED',
52
- 'X_DB_STUDIO_FAILED',
53
- // The five app-surface boundary codes. `@ultimat3/render` owns the *rule* (`checkSurfaceBoundary`)
54
- // and the CLI owns the diagnostic, because `x verify` and `x fix boundary` are the two commands
55
- // that report it — see `app-boundaries.ts`, which holds the one rule-to-code table.
56
- 'X_BOUNDARY_SITE_TO_APP',
57
- 'X_BOUNDARY_SHARED_LEAF',
58
- 'X_BOUNDARY_APP_TO_API',
59
- 'X_BOUNDARY_ROUTE_TO_DB',
60
- 'X_BOUNDARY_SERVICE_TO_HTTP',
61
- ] as const;
62
-
63
- /**
64
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — `CliNotImplementedError` and every planned command
65
- * throw it, and none of them may declare a title for it. The CLI is the process that imports every
66
- * package (`error-catalog.ts`), so a title declared twice here is the one that would win by load
67
- * order rather than by ownership.
68
- */
69
- export const CLI_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
70
-
71
- /** Every code the CLI can throw: the ones it owns plus the one it borrows. */
72
- export const CLI_ERROR_CODES = [...CLI_OWNED_ERROR_CODES, ...CLI_BORROWED_ERROR_CODES] as const;
73
-
74
- export type CliOwnedErrorCode = (typeof CLI_OWNED_ERROR_CODES)[number];
75
- export type CliErrorCode = (typeof CLI_ERROR_CODES)[number];
76
-
77
- /**
78
- * Registered titles, so `x errors list` enumerates the CLI's codes alongside every other
79
- * package's instead of leaving a hole an agent has to read source to fill. Typed over
80
- * `CliOwnedErrorCode`, so adding a code without a title is a build error.
81
- */
82
- export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
83
- X_CLI_UNKNOWN_COMMAND: 'not a command in the registry',
84
- X_CLI_BAD_FLAG: 'unknown flag, missing value, or a value the command refuses',
85
- X_VERIFY_FAILED: 'at least one x verify step failed',
86
- X_NOT_IN_APP: 'the command needs an app root and found none',
87
- X_BUN_VERSION: 'Bun is older than the framework floor',
88
- X_TEST_NO_FILES: 'the test selection matched no files',
89
- X_TEST_SHARD_FAILED: 'a test shard exited non-zero',
90
- X_SCAFFOLD_PATH_ESCAPE: 'a generated path resolves outside the directory it is written into',
91
- X_GENERATE_JSON_INVALID: "a generator's own merge: 'json' output does not parse as a JSON object",
92
- X_APP_PACKAGE_INVALID: "the app's package.json supplies no name and version",
93
- X_ERROR_CODE_UNKNOWN: 'no package registered this error code',
94
- X_DECLARATION_UNKNOWN: 'no declaration with this name is registered',
95
- X_JOB_UNKNOWN: 'the queue holds no job with this id',
96
- X_FIX_TARGET_UNKNOWN: 'the named file is not one of the app source files',
97
- X_ERROR_FIX_INVALID: "an error's fix line is not a runnable instruction",
98
- X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
99
- X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
100
- X_CLI_UNEXPECTED: 'the CLI itself failed',
101
- X_TYPECHECK_FAILED: 'tsc failed',
102
- X_LINT_FAILED: 'Biome failed',
103
- X_TEST_FAILED: 'a test type failed',
104
- X_FILE_TOO_LONG: 'a source file is over 500 lines',
105
- X_PACKAGE_SHAPE: 'a workspace package is missing a contract file',
106
- X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
107
- X_MANIFEST_STALE: 'openapi.json is stale',
108
- X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
109
- X_BUILD_FAILED: 'x build failed',
110
- X_BUILD_ENTRY_MISSING: "the build target's entry file is not in the app",
111
- X_DEPLOY_FAILED: 'a deploy step failed',
112
- X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
113
- X_PORT_INVALID: 'PORT is not a TCP port number',
114
- X_GENERATE_CONFLICT: 'a generator would overwrite a file',
115
- X_PORT_IN_USE: 'the dev port is taken',
116
- X_DB_GEN_FAILED: 'x db gen failed',
117
- X_DB_MIGRATE_FAILED: 'x db migrate failed',
118
- X_DB_BRANCH_FAILED: 'an x db branch step failed',
119
- X_DB_STUDIO_FAILED: 'x db studio failed',
120
- X_BOUNDARY_SITE_TO_APP: 'site/ imported app/',
121
- X_BOUNDARY_SHARED_LEAF: 'shared/ imported a surface',
122
- X_BOUNDARY_APP_TO_API: 'app/ imported api/ at runtime',
123
- X_BOUNDARY_ROUTE_TO_DB: 'a route touched the database',
124
- X_BOUNDARY_SERVICE_TO_HTTP: 'a service imported HTTP',
125
- };
126
-
127
- // One unconditional call, so a second package claiming one of the CLI's codes throws
128
- // X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
129
- registerErrorCodes(
130
- Object.fromEntries(Object.entries(CLI_ERROR_TITLES).map(([code, title]) => [code, { title }])),
131
- );
132
-
133
- export const docsFor = (code: CliErrorCode): string => `https://ultimate.dev/errors/${code}`;
1
+ // The error classes @ultimat3/cli throws. One class per condition, each naming the exact command
2
+ // that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
3
+ // so a package importing a class does not pull the table and vice versa.
4
+ import { UltimateError } from '@ultimat3/core';
5
+ import { docsFor } from './error-codes';
134
6
 
135
7
  /** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
136
8
  export class UnknownCommandError extends UltimateError {
@@ -160,6 +32,49 @@ export class BadFlagError extends UltimateError {
160
32
  }
161
33
  }
162
34
 
35
+ /**
36
+ * A required POSITIONAL argument that was not given. Its own class rather than a `BadFlagError`,
37
+ * because the cause then names a flag that does not exist — `x errors --json` reported
38
+ * `--code on "x errors"` and sent an agent straight into a second `X_CLI_BAD_FLAG` for the
39
+ * `--code` flag it had just been told about — and rather than `X_CLI_UNKNOWN_COMMAND`, which said
40
+ * "x g route is not a command" about a command form that is. `example` is a REAL invocation:
41
+ * `x g route <name>` pasted into a shell is a redirect, not a command.
42
+ */
43
+ export class MissingPositionalError extends UltimateError {
44
+ constructor(input: { command: string; positional: string; example: string }) {
45
+ super({
46
+ code: 'X_CLI_BAD_FLAG',
47
+ cause: `"x ${input.command}" needs a <${input.positional}> positional and got none`,
48
+ fix: input.example,
49
+ docs: docsFor('X_CLI_BAD_FLAG'),
50
+ });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * A command that declares subcommands, invoked with none and declaring no `defaultSubcommand`.
56
+ *
57
+ * `X_CLI_BAD_FLAG` is the code a missing positional already takes (`MissingPositionalError`), and a
58
+ * subcommand is one — a second code for "you left out an argument" is the synonym the registry
59
+ * exists to prevent. Its own class because the cause must not name a flag: the parser answered
60
+ * `subcommands[0]` before this existed, so `x db` ran `gen` and wrote a migration file nobody asked
61
+ * for. Help is the fix because which of six was meant is exactly what the caller did not say.
62
+ *
63
+ * `x help <command>`, never `x <command> --help`: the parser resolves the subcommand AFTER the
64
+ * flag loop, so `x db --help` throws THIS error again — a fix line that reproduces its own
65
+ * failure, verbatim, forever. `x help db` prints the subcommand list and the flags.
66
+ */
67
+ export class MissingSubcommandError extends UltimateError {
68
+ constructor(input: { command: string; known: readonly string[] }) {
69
+ super({
70
+ code: 'X_CLI_BAD_FLAG',
71
+ cause: `"x ${input.command}" takes a subcommand and got none (one of: ${input.known.join(', ')})`,
72
+ fix: `x help ${input.command}`,
73
+ docs: docsFor('X_CLI_BAD_FLAG'),
74
+ });
75
+ }
76
+ }
77
+
163
78
  /** At least one `x verify` step failed. The step findings carry the per-step fixes. */
164
79
  export class VerifyFailedError extends UltimateError {
165
80
  constructor(input: { failed: readonly string[] }) {
@@ -211,7 +126,7 @@ export class NoTestFilesError extends UltimateError {
211
126
  super({
212
127
  code: 'X_TEST_NO_FILES',
213
128
  cause: `no *.test.ts files${where} under ${input.root}`,
214
- fix: parts.length === 0 ? 'x test --cwd <repo root>' : 'x test',
129
+ fix: parts.length === 0 ? 'x test --json # run it from the repo root' : 'x test',
215
130
  docs: docsFor('X_TEST_NO_FILES'),
216
131
  });
217
132
  }
@@ -282,7 +197,7 @@ export class AppPackageInvalidError extends UltimateError {
282
197
  super({
283
198
  code: 'X_APP_PACKAGE_INVALID',
284
199
  cause: `${input.path} ${input.problem}, so the manifest has no app name or version to gate on`,
285
- fix: 'bun pm pkg set name=<app> version=0.1.0',
200
+ fix: 'bun pm pkg set name=my-app version=0.1.0',
286
201
  docs: docsFor('X_APP_PACKAGE_INVALID'),
287
202
  });
288
203
  }
@@ -374,12 +289,28 @@ export class BuildEntryMissingError extends UltimateError {
374
289
  super({
375
290
  code: 'X_BUILD_ENTRY_MISSING',
376
291
  cause: `x build --target ${input.target} builds from ${input.entry}, and the app does not have it`,
377
- fix: `x new <name> writes ${input.entry} — copy it from a fresh scaffold into this app`,
292
+ fix: `x new scratch-app --dry-run --json # its file list carries ${input.entry}; copy that file into this app`,
378
293
  docs: docsFor('X_BUILD_ENTRY_MISSING'),
379
294
  });
380
295
  }
381
296
  }
382
297
 
298
+ /**
299
+ * A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
300
+ * entry point like any other, and the target's own logs are what says which line. The fix builds
301
+ * exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
302
+ */
303
+ export class IslandBuildFailedError extends UltimateError {
304
+ constructor(input: { file: string; logs: string }) {
305
+ super({
306
+ code: 'X_BUILD_FAILED',
307
+ cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
308
+ fix: `bun build --target browser ${input.file}`,
309
+ docs: docsFor('X_BUILD_FAILED'),
310
+ });
311
+ }
312
+ }
313
+
383
314
  /**
384
315
  * `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
385
316
  * otherwise start, serve nothing and report healthy — the one failure a rolling deploy cannot see.
@@ -389,28 +320,73 @@ export class RoleUnknownError extends UltimateError {
389
320
  super({
390
321
  code: 'X_ROLE_UNKNOWN',
391
322
  cause: `ROLE="${input.role}" is not a role (known: ${input.known.join(', ')})`,
392
- fix: `docker run -e ROLE=web <image> # one of: ${input.known.join(', ')}`,
323
+ fix: `docker run -e ROLE=web my-app:latest # one of: ${input.known.join(', ')}`,
393
324
  docs: docsFor('X_ROLE_UNKNOWN'),
394
325
  });
395
326
  }
396
327
  }
397
328
 
329
+ /**
330
+ * The enqueue side and the claim side are looking at two different queues.
331
+ *
332
+ * `startServices` builds the drivers and captures them; `loadApp` imports the app's modules after
333
+ * it, and a module calling `setJobDriver()` at import time moves the ambient slot without touching
334
+ * the captured object. The worker then claims from what was captured while every
335
+ * `handle.enqueue()` publishes to what is ambient — jobs that are accepted, acknowledged, visible
336
+ * in `/_x` and never run. Refused at boot, because the alternative is a deployment that only ever
337
+ * looks healthy.
338
+ */
339
+ export class RuntimeDriverSplitError extends UltimateError {
340
+ constructor(input: { driver: string; ambient: string; captured: string }) {
341
+ super({
342
+ code: 'X_RUNTIME_DRIVER_SPLIT',
343
+ // Both names are printed even when they are the same string — two `memory` drivers are two
344
+ // queues, and "they match" is exactly the reading that makes this bug invisible.
345
+ cause: `an app module installed a ${input.driver} driver (ambient: "${input.ambient}") that is not the object this boot captured ("${input.captured}"), so enqueues and claims would use different queues`,
346
+ fix: `pass the driver to the boot instead of installing it from an app module: runRole({ root, env, runtime: { ${input.driver}: yourDriver } })`,
347
+ docs: docsFor('X_RUNTIME_DRIVER_SPLIT'),
348
+ });
349
+ }
350
+ }
351
+
398
352
  /**
399
353
  * Every PaaS injects `PORT` and expects the process to bind exactly it. Defaulting past a value
400
354
  * that will not parse is how a deploy comes up on 3000, fails the platform's health probe, and
401
355
  * reports nothing an operator can act on.
402
356
  */
403
357
  export class PortInvalidError extends UltimateError {
404
- constructor(input: { value: string }) {
358
+ /** `name` so the scrape port reports itself; the code stays one, because the fault is one. */
359
+ constructor(input: { value: string; name?: string }) {
360
+ const name = input.name ?? 'PORT';
405
361
  super({
406
362
  code: 'X_PORT_INVALID',
407
- cause: `PORT="${input.value}" is not a TCP port number between 0 and 65535`,
408
- fix: 'docker run -e PORT=3000 <image>',
363
+ cause: `${name}="${input.value}" is not a TCP port number between 0 and 65535`,
364
+ fix: `docker run -e ${name}=${name === 'PORT' ? 3000 : 9090} my-app:latest`,
409
365
  docs: docsFor('X_PORT_INVALID'),
410
366
  });
411
367
  }
412
368
  }
413
369
 
370
+ /**
371
+ * `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
372
+ * writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
373
+ * present", both read as a working environment declaration to whoever runs the command next.
374
+ *
375
+ * `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
376
+ * `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
377
+ * than minting a synonym.
378
+ */
379
+ export class EnvSchemaMissingError extends UltimateError {
380
+ constructor(input: { subcommand: string }) {
381
+ super({
382
+ code: 'X_CONFIG_INVALID',
383
+ cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
384
+ fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
385
+ docs: docsFor('X_CONFIG_INVALID'),
386
+ });
387
+ }
388
+ }
389
+
414
390
  /** An interface-complete command path whose remote/native half is not written yet. */
415
391
  export class CliNotImplementedError extends UltimateError {
416
392
  constructor(input: { feature: string; fix: string }) {
@@ -422,3 +398,92 @@ export class CliNotImplementedError extends UltimateError {
422
398
  });
423
399
  }
424
400
  }
401
+
402
+ /**
403
+ * The process could not obtain a storage disk to write to.
404
+ *
405
+ * Thrown at boot rather than at the first upload, and with a `fix` naming the two real options —
406
+ * a writable volume or an object store — because the failure it replaces was a bare `EROFS` from
407
+ * inside Bun's `mkdirSync`, with no code, no fix, and no mention of storage. A hardened container
408
+ * (`readOnlyRootFilesystem: true`) CrashLooped 22 times on it before anyone could tell what the
409
+ * process wanted.
410
+ */
411
+ export class StorageUnwritableError extends UltimateError {
412
+ constructor(cause: string, fix: string) {
413
+ super({ code: 'X_STORAGE_UNWRITABLE', cause, fix, docs: docsFor('X_STORAGE_UNWRITABLE') });
414
+ }
415
+ }
416
+
417
+ /**
418
+ * A non-local boot that fell through to the embedded disk with no `STORAGE_SIGNING_SECRET`. The
419
+ * key it would sign with is a string published in this repo, and `acceptSignedUpload` trusts a
420
+ * signed `maxBytes`/`contentType` over the app's own `uploadPolicy` — so anyone holding it mints
421
+ * an unlimited upload of any type, for any key, including another org's.
422
+ *
423
+ * `X_ENV_MISSING`, the code `@ultimat3/storage` already refuses this with, rather than a CLI twin:
424
+ * two codes for one condition is what `cmd-doctor.ts` says out loud about the PWA pair. What this
425
+ * adds is the sentence storage cannot write — that the disk itself was a fallback nobody chose.
426
+ * The fix names object storage first, because that is the answer for most deployments; the volume
427
+ * rung is behind the `#`, so the line still runs verbatim.
428
+ */
429
+ export class LocalDiskUnsafeError extends UltimateError {
430
+ constructor(input: { environment: string; root: string }) {
431
+ super({
432
+ code: 'X_ENV_MISSING',
433
+ cause:
434
+ `no S3_ENDPOINT/S3_BUCKET, so this ${input.environment} process fell back to the embedded ` +
435
+ `disk at ${input.root} — and with no STORAGE_SIGNING_SECRET it would sign upload grants ` +
436
+ 'with the development key published in @ultimat3/storage',
437
+ fix: 'export S3_ENDPOINT=https://s3.example.com S3_BUCKET=my-app-uploads # or keep the disk on a mounted volume: export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
438
+ docs: docsFor('X_ENV_MISSING'),
439
+ });
440
+ }
441
+ }
442
+
443
+ /**
444
+ * `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
445
+ * guessing one and opening a decrypted file in it is the last place a surprise belongs.
446
+ */
447
+ export class SecretsEditorMissingError extends UltimateError {
448
+ constructor(input: { vars: readonly string[] }) {
449
+ super({
450
+ code: 'X_SECRETS_EDITOR_MISSING',
451
+ cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
452
+ fix: 'EDITOR=nano x secrets edit',
453
+ docs: docsFor('X_SECRETS_EDITOR_MISSING'),
454
+ });
455
+ }
456
+ }
457
+
458
+ /**
459
+ * The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
460
+ * and the committed file is left exactly as it was: resealing a buffer whose editor failed would
461
+ * commit whatever half-written state the crash left behind.
462
+ */
463
+ export class SecretsEditFailedError extends UltimateError {
464
+ constructor(input: { editor: string; code: number }) {
465
+ super({
466
+ code: 'X_SECRETS_EDIT_FAILED',
467
+ cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
468
+ fix: 'x secrets edit',
469
+ docs: docsFor('X_SECRETS_EDIT_FAILED'),
470
+ });
471
+ }
472
+ }
473
+
474
+ /**
475
+ * `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
476
+ * package's own code for exactly that, and a second name for "a generator would clobber something"
477
+ * is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
478
+ * the committed file it opens is then ciphertext nobody can read again.
479
+ */
480
+ export class SecretsExistsError extends UltimateError {
481
+ constructor(input: { path: string; fix: string }) {
482
+ super({
483
+ code: 'X_GENERATE_CONFLICT',
484
+ cause: `${input.path} already exists, and x secrets init would replace it`,
485
+ fix: input.fix,
486
+ docs: docsFor('X_GENERATE_CONFLICT'),
487
+ });
488
+ }
489
+ }
@@ -0,0 +1,268 @@
1
+ // The half of the error contract that a text rule cannot decide: a `fix:` may cite `x <command>`
2
+ // and that command may not exist. Six shipped fix lines named `x db status`, `x logs tail`,
3
+ // `x trace`, `x metrics`, `x auth whoami` and `x ai prompts` — every one of them passed the
4
+ // `errors` step, because the step checks that a fix NAMES a command, never that the build ships it.
5
+ //
6
+ // It reads THREE words for the same reason it reads two: `x db branch ls --json` shipped as a fix
7
+ // while `x db branch` had no `ls`, because a rule stopping at the subcommand never saw the word
8
+ // that decided what ran.
9
+
10
+ import type { CommandSpec } from './parse';
11
+ import { GLOBAL_FLAGS } from './parse';
12
+
13
+ /**
14
+ * The rule is CONDITIONAL, and that is the whole design.
15
+ *
16
+ * *If* a fix cites `x <something>`, that something must resolve. It does NOT say every fix must
17
+ * name a command — axiom 4 asks for an executable instruction, and
18
+ * `set OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318` or
19
+ * `counter('orders_total', { maxSeries: 4000 })` are executable and correctly cite nothing. A
20
+ * universal rule would push an author towards citing a command that does not really fix it, which
21
+ * is a worse error than one with no command in it.
22
+ */
23
+ // Digits are part of a name, not a boundary: `x i18n check` read through `[a-z-]*` alone cites
24
+ // `x i`, which is not a command — a false finding on three of the framework's own fix lines.
25
+ //
26
+ // The THIRD slot also matches a `<placeholder>`, and only the third. A slot with a closed set is a
27
+ // slot where the reader has nothing to substitute, so `x db branch <name>` — two shipped fix lines
28
+ // in `@ultimat3/mcp` — is `X_CLI_UNKNOWN_COMMAND` when run and resolved clean while a placeholder
29
+ // was invisible to the reader. Second and fourth slots are open positionals (`x new my-app`,
30
+ // `x db branch drop <name>`), where a placeholder is exactly right.
31
+ const CITATION =
32
+ /(?:^|[\s;|&("'`])x\s+([a-z][a-z\d-]*)(?:\s+([a-z][a-z\d-]*))?(?:\s+([a-z][a-z\d-]*|<[^>]*>))?/g;
33
+
34
+ /**
35
+ * A long flag, `--` stripped. `--no-<name>` is the parser's negation of a boolean, so it resolves
36
+ * against `<name>` — reporting `no-example` as an unknown flag would be a finding about a working
37
+ * invocation. A `-j` short form is deliberately not read: one letter is too weak a signal in prose.
38
+ */
39
+ const FLAG = /(?:^|\s)--(?:no-)?([a-z][a-z\d-]*)/g;
40
+
41
+ /**
42
+ * Where a citation's argument list ends. `;`, `|` and `&` start a second shell word, `#` starts a
43
+ * comment, and a backtick or a quote closes the span the citation was written in — past any of
44
+ * them a `--flag` belongs to something else.
45
+ */
46
+ const ARGUMENT_END = /[;|&#`'"]/;
47
+
48
+ /** One `x …` citation, as written. `sub` is the next bare word, which may not be a subcommand. */
49
+ export interface FixCitation {
50
+ readonly command: string;
51
+ readonly sub: string | undefined;
52
+ /** The bare word after `sub`. Judged only against a declared `subcommandPositionals` set. */
53
+ readonly positional: string | undefined;
54
+ /** Long flags written after it, in order, `--` and any `no-` stripped. */
55
+ readonly flags: readonly string[];
56
+ }
57
+
58
+ /**
59
+ * Every `x <command> [<word>] [--flag …]` a fix line cites.
60
+ *
61
+ * Read off the STATIC form of the fix — the caller blanks `${…}` first — because a command name
62
+ * assembled at run time is not a name this can resolve, and guessing at one would report findings
63
+ * nobody can act on. `x` alone, or `x --json`, cites nothing: the regex needs a bare lowercase
64
+ * word after the space.
65
+ *
66
+ * The flag list stops at the NEXT citation as well as at `ARGUMENT_END`: one fix line routinely
67
+ * names two commands (`x db migrate, then confirm with x db query "…" --json`), and charging the
68
+ * second command's flags to the first would report a finding on the wrong half of the sentence.
69
+ */
70
+ export function fixCitations(fix: string): readonly FixCitation[] {
71
+ const matches = [...fix.matchAll(CITATION)].filter((match) => match[1] !== undefined);
72
+ return matches.map((match, index) => {
73
+ const start = match.index + match[0].length;
74
+ const next = matches[index + 1]?.index ?? fix.length;
75
+ const tail = fix.slice(start, next);
76
+ const stop = ARGUMENT_END.exec(tail)?.index;
77
+ const args = stop === undefined ? tail : tail.slice(0, stop);
78
+ return {
79
+ command: match[1] as string,
80
+ sub: match[2],
81
+ positional: match[3],
82
+ flags: [...args.matchAll(FLAG)].map((flag) => flag[1] as string),
83
+ };
84
+ });
85
+ }
86
+
87
+ /** Long flags a spec accepts: its own, plus the four every command takes. */
88
+ const declaredFlags = (spec: CommandSpec): ReadonlySet<string> =>
89
+ new Set([...GLOBAL_FLAGS, ...(spec.flags ?? [])].map((flag) => flag.name));
90
+
91
+ export interface CommandCatalog {
92
+ /** Every spec the registry holds, planned ones included — `x help` lists those too. */
93
+ readonly specs: readonly CommandSpec[];
94
+ /** Names that parse but exit `X_NOT_IMPLEMENTED`. Citing one is the bug this check closes. */
95
+ readonly planned: ReadonlySet<string>;
96
+ /** `"<command> <subcommand>"` pairs that parse and exit `X_NOT_IMPLEMENTED`. */
97
+ readonly plannedSubcommands: ReadonlySet<string>;
98
+ }
99
+
100
+ /**
101
+ * What a caller accepts from a citation. A `fix:` hands its reader a command to RUN, so a planned
102
+ * one is a defect; a doc page may legitimately *say* a command is planned, and a rule that refused
103
+ * that would delete `wiki/CLI-Reference.md`'s planned table one true row at a time.
104
+ *
105
+ * `allowPlanned` covers `PLANNED_SUBCOMMANDS` as well as `PLANNED_COMMANDS` — `x db studio` is the
106
+ * single entry in the first table, and four pages name it as planned.
107
+ */
108
+ export interface CitationRules {
109
+ readonly allowPlanned?: boolean;
110
+ }
111
+
112
+ /**
113
+ * One citation that did not resolve, split so a caller can key on WHAT failed.
114
+ *
115
+ * `subject` is the invocation spelled the way it would be typed — `x db query`, `x env check --fix`
116
+ * — and it is deliberately stable under a doc edit that only moves the sentence around it. That is
117
+ * what lets `scripts/doc-commands-allow.ts` allow one page to name one non-command (the pages that
118
+ * say "there is no `x serve` command" are saying something TRUE) without waiving the rule for the
119
+ * rest of that page.
120
+ */
121
+ export interface CitationFault {
122
+ readonly subject: string;
123
+ readonly reason: string;
124
+ }
125
+
126
+ /**
127
+ * A second word is judged as a subcommand ONLY when the spec declares subcommands at all, or
128
+ * against a declared closed set of positionals. `x new my-app` and `x g route posts` take open
129
+ * positionals, and reporting `my-app` as an unknown subcommand would be a finding about a working
130
+ * example.
131
+ */
132
+ function wordFault(
133
+ spec: CommandSpec,
134
+ word: string,
135
+ catalog: CommandCatalog,
136
+ rules: CitationRules,
137
+ ): CitationFault | undefined {
138
+ const subject = `x ${spec.name} ${word}`;
139
+ if (spec.subcommands !== undefined) {
140
+ if (!spec.subcommands.includes(word)) {
141
+ return {
142
+ subject,
143
+ reason: `and ${spec.name} has no such subcommand (${spec.subcommands.join(', ')})`,
144
+ };
145
+ }
146
+ if (catalog.plannedSubcommands.has(`${spec.name} ${word}`) && rules.allowPlanned !== true) {
147
+ return { subject, reason: 'which is planned and exits X_NOT_IMPLEMENTED' };
148
+ }
149
+ return undefined;
150
+ }
151
+ const choices = spec.positionalChoices;
152
+ if (choices === undefined || choices.includes(word)) return undefined;
153
+ return {
154
+ subject,
155
+ reason: `and ${word} is not one of ${spec.name}'s positionals (${choices.join(', ')})`,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * The third word, judged ONLY where the subcommand declares a closed set. `x jobs show <id>` and
161
+ * `x db gen "add publish_at"` take open positionals, so a universal third-word rule would report
162
+ * findings about working invocations — the same conditionality `wordFault` applies to the second.
163
+ */
164
+ function positionalFault(spec: CommandSpec, sub: string, word: string): CitationFault | undefined {
165
+ const choices = spec.subcommandPositionals?.[sub];
166
+ if (choices === undefined || choices.includes(word)) return undefined;
167
+ // A placeholder is judged the same as a wrong word, and deliberately: there is nothing the
168
+ // reader could substitute that would make `x db branch <name>` run, because the slot is a verb.
169
+ return {
170
+ subject: `x ${spec.name} ${sub} ${word}`,
171
+ reason: `and ${spec.name} ${sub} takes one of ${choices.join(', ')}`,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Why a citation does not resolve, or `undefined` when it does. FIVE levels, because the drift is
177
+ * mostly BELOW the command name: `x db query` names a real command and an unreal subcommand,
178
+ * `x env check --fix` names both and an unreal flag, `x test summarize` names a first positional
179
+ * that is not a `TestType`, and `x db branch ls` named a real subcommand and a third word that
180
+ * `x db branch` read as a branch NAME. A rule stopping at the command name accepted all four.
181
+ *
182
+ * The planned check is the one the whole thing exists for: a PLANNED command is in the registry and
183
+ * parses, so a resolution that only asked "is this a known name" would accept `x logs tail` — the
184
+ * exact citation that throws `X_NOT_IMPLEMENTED` at the reader.
185
+ *
186
+ * Flags are NOT judged on a planned command. `cmd-planned.ts` builds its spec from a name, a
187
+ * summary and a usage line and declares no flags at all, so every flag its own usage line documents
188
+ * would read as unknown — while the real refusal is `X_NOT_IMPLEMENTED` one level up.
189
+ */
190
+ export function citationFault(
191
+ citation: FixCitation,
192
+ catalog: CommandCatalog,
193
+ rules: CitationRules = {},
194
+ ): CitationFault | undefined {
195
+ const spec = catalog.specs.find(
196
+ (candidate) =>
197
+ candidate.name === citation.command || candidate.aliases?.includes(citation.command) === true,
198
+ );
199
+ if (spec === undefined) {
200
+ return { subject: `x ${citation.command}`, reason: 'which is not a command' };
201
+ }
202
+ const planned = catalog.planned.has(spec.name);
203
+ if (planned && rules.allowPlanned !== true) {
204
+ return {
205
+ subject: `x ${citation.command}`,
206
+ reason: 'which is planned and exits X_NOT_IMPLEMENTED',
207
+ };
208
+ }
209
+ if (citation.sub !== undefined) {
210
+ const fault = wordFault(spec, citation.sub, catalog, rules);
211
+ if (fault !== undefined) return fault;
212
+ if (citation.positional !== undefined) {
213
+ const deeper = positionalFault(spec, citation.sub, citation.positional);
214
+ if (deeper !== undefined) return deeper;
215
+ }
216
+ }
217
+ if (planned) return undefined;
218
+ const declared = declaredFlags(spec);
219
+ const unknown = citation.flags.find((flag) => !declared.has(flag));
220
+ if (unknown === undefined) return undefined;
221
+ return {
222
+ subject: `x ${spec.name} --${unknown}`,
223
+ reason: `and ${spec.name} declares no such flag — the parser refuses it with X_CLI_BAD_FLAG (known: ${[...declared].join(', ')})`,
224
+ };
225
+ }
226
+
227
+ /** The same answer as one sentence, which is what a `cause:` line wants. */
228
+ export function citationProblem(
229
+ citation: FixCitation,
230
+ catalog: CommandCatalog,
231
+ rules: CitationRules = {},
232
+ ): string | undefined {
233
+ const fault = citationFault(citation, catalog, rules);
234
+ return fault === undefined ? undefined : `cites "${fault.subject}", ${fault.reason}`;
235
+ }
236
+
237
+ /** The first citation that does not resolve. One finding per fix line, not one per word. */
238
+ export function citedCommandProblem(
239
+ fix: string,
240
+ catalog: CommandCatalog,
241
+ rules: CitationRules = {},
242
+ ): string | undefined {
243
+ for (const citation of fixCitations(fix)) {
244
+ const problem = citationProblem(citation, catalog, rules);
245
+ if (problem !== undefined) return problem;
246
+ }
247
+ return undefined;
248
+ }
249
+
250
+ /**
251
+ * The registry, as this check reads it.
252
+ *
253
+ * Imported dynamically because `registry.ts` → `cmd-verify.ts` → `error-contract.ts` closes a
254
+ * cycle back to the caller. The precedent is `cmd-build.ts`'s `await import('./cmd-verify')`:
255
+ * one break, inside a function that is already async, rather than a second copy of the command
256
+ * list here — which would be a catalog that can disagree with the one `x help` prints.
257
+ */
258
+ export async function loadCommandCatalog(): Promise<CommandCatalog> {
259
+ const { SPECS } = await import('./registry');
260
+ const { PLANNED_COMMANDS, PLANNED_SUBCOMMANDS } = await import('./cmd-planned');
261
+ return {
262
+ specs: SPECS,
263
+ planned: new Set(PLANNED_COMMANDS.map((planned) => planned.name)),
264
+ plannedSubcommands: new Set(
265
+ PLANNED_SUBCOMMANDS.map((planned) => `${planned.command} ${planned.subcommand}`),
266
+ ),
267
+ };
268
+ }