@ultimat3/cli 1.2.0 → 3.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 (141) hide show
  1. package/CLAUDE.md +761 -0
  2. package/README.md +42 -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 +134 -9
  11. package/src/cmd-build.ts +69 -21
  12. package/src/cmd-db-branch.ts +219 -0
  13. package/src/cmd-db.ts +458 -153
  14. package/src/cmd-deploy.ts +59 -6
  15. package/src/cmd-dev.ts +92 -18
  16. package/src/cmd-docs.ts +167 -0
  17. package/src/cmd-doctor.ts +74 -10
  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 +14 -8
  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 +29 -24
  33. package/src/cmd-verify.ts +197 -25
  34. package/src/db-backfill.ts +401 -0
  35. package/src/db-branch.ts +269 -0
  36. package/src/db-destructive.ts +29 -0
  37. package/src/db-finding.ts +28 -0
  38. package/src/db-generate.ts +144 -0
  39. package/src/db-seed.ts +294 -0
  40. package/src/db-snapshot.ts +24 -0
  41. package/src/dev-assets.ts +108 -23
  42. package/src/dev-cache.ts +122 -0
  43. package/src/dev-dashboard.ts +19 -4
  44. package/src/dev-hooks.ts +27 -2
  45. package/src/dev-n-plus-one.ts +191 -0
  46. package/src/dev-queue.ts +105 -19
  47. package/src/dev-render.ts +158 -26
  48. package/src/dev-roles-fixture.ts +67 -0
  49. package/src/dev-roles.ts +167 -78
  50. package/src/dev-runtime.ts +117 -40
  51. package/src/dev-services.ts +15 -0
  52. package/src/dev-storage.ts +247 -0
  53. package/src/dev-sync.ts +107 -0
  54. package/src/dev-traces.ts +37 -7
  55. package/src/dispatch.ts +4 -2
  56. package/src/document-styles.ts +54 -0
  57. package/src/drift.ts +78 -10
  58. package/src/error-catalog.ts +8 -18
  59. package/src/error-codes.ts +192 -0
  60. package/src/error-contract.ts +29 -7
  61. package/src/error-fixes.ts +114 -0
  62. package/src/errors.ts +201 -138
  63. package/src/exec.ts +42 -8
  64. package/src/fix-command.ts +268 -0
  65. package/src/flag-number.ts +67 -0
  66. package/src/framework-scope.ts +49 -0
  67. package/src/generate-kinds.ts +97 -0
  68. package/src/guards.ts +186 -0
  69. package/src/index.ts +92 -15
  70. package/src/island-bundle.ts +166 -0
  71. package/src/island-routes.ts +50 -0
  72. package/src/jobs-driver.ts +33 -0
  73. package/src/jobs-json.ts +24 -0
  74. package/src/jobs-report.ts +17 -4
  75. package/src/mcp-db-target.ts +52 -27
  76. package/src/mcp-errors.ts +128 -19
  77. package/src/mcp-host.ts +44 -25
  78. package/src/messages.ts +93 -2
  79. package/src/metrics-endpoint.ts +64 -16
  80. package/src/migrations.ts +37 -4
  81. package/src/otlp-export.ts +64 -0
  82. package/src/output.ts +46 -16
  83. package/src/parse.ts +41 -3
  84. package/src/policy-facts.ts +38 -6
  85. package/src/policy-fixture.ts +14 -7
  86. package/src/prerender.ts +111 -2
  87. package/src/registry.ts +21 -3
  88. package/src/runtime-overrides.ts +66 -0
  89. package/src/safe-url-label.ts +24 -0
  90. package/src/scaffold-fixture.ts +10 -0
  91. package/src/scaffold-typecheck.ts +16 -38
  92. package/src/serve.ts +185 -13
  93. package/src/shell-quote.ts +15 -0
  94. package/src/source-files.ts +4 -0
  95. package/src/statement-loop.ts +74 -0
  96. package/src/style-csp.ts +18 -0
  97. package/src/sync-authenticator.ts +59 -0
  98. package/src/templates/action.ts +15 -30
  99. package/src/templates/admin-page.ts +103 -0
  100. package/src/templates/admin.ts +11 -7
  101. package/src/templates/backfill.ts +212 -0
  102. package/src/templates/entity.ts +72 -31
  103. package/src/templates/guard.ts +143 -0
  104. package/src/templates/index.ts +12 -1
  105. package/src/templates/island.ts +67 -0
  106. package/src/templates/job.ts +53 -13
  107. package/src/templates/naming.ts +17 -1
  108. package/src/templates/policy.ts +35 -28
  109. package/src/templates/query.ts +24 -5
  110. package/src/templates/resource.ts +19 -11
  111. package/src/templates/route.ts +90 -15
  112. package/src/templates/scaffold-app.ts +142 -45
  113. package/src/templates/scaffold-claude-agents.ts +149 -0
  114. package/src/templates/scaffold-claude-commands.ts +221 -0
  115. package/src/templates/scaffold-claude.ts +134 -0
  116. package/src/templates/scaffold-container.ts +46 -2
  117. package/src/templates/scaffold-db-package.ts +91 -0
  118. package/src/templates/scaffold-docs.ts +24 -5
  119. package/src/templates/scaffold-domain-package.ts +90 -0
  120. package/src/templates/scaffold-env.ts +87 -0
  121. package/src/templates/scaffold-i18n.ts +4 -1
  122. package/src/templates/scaffold-mcp-package.ts +49 -0
  123. package/src/templates/scaffold-package-shape.ts +25 -4
  124. package/src/templates/scaffold-repo.ts +116 -257
  125. package/src/templates/scaffold-roles.ts +68 -0
  126. package/src/templates/scaffold-ui-package.ts +56 -0
  127. package/src/templates/slice-foundation.ts +88 -0
  128. package/src/templates/wrap.ts +95 -0
  129. package/src/test-counts.ts +35 -0
  130. package/src/test-select.ts +30 -15
  131. package/src/test-shards.ts +20 -11
  132. package/src/test-workers.ts +50 -0
  133. package/src/ts-scan.ts +284 -15
  134. package/src/tsconfig-references.ts +103 -0
  135. package/src/verify-floor.ts +133 -0
  136. package/src/verify-step.ts +19 -0
  137. package/src/verify-test-run.ts +72 -0
  138. package/src/verify-tests.ts +160 -71
  139. package/src/version-loader.ts +20 -3
  140. package/src/workspace-checks.ts +87 -16
  141. package/src/write-line.ts +34 -0
@@ -1,36 +1,64 @@
1
- // Which database the MCP dev host is pointed at, and whether that database is a branch. `db.migrate`
2
- // decides from this alone, so the reading has to be exact: a wrong `branch` is a migration against a
3
- // database somebody else is using.
1
+ // Which database the MCP dev host is pointed at: whether it is a branch, and whether this is
2
+ // production. `db.migrate` decides from these two alone, so both readings have to be exact — a
3
+ // wrong `branch` is a migration against a database somebody else is using, and a `production` that
4
+ // is always false is a refusal that never runs.
4
5
 
5
- import { basename, join } from 'node:path';
6
+ // `node:path` for the joiner Bun has no equivalent of — the same reason `db-branch.ts` reaches for
7
+ // it, and the state directory it builds a path under is a real one on disk.
8
+ import { join } from 'node:path';
9
+ import { tryResolveEnvironment } from '@ultimat3/core';
6
10
  import { pgliteDataDir } from '@ultimat3/db';
7
11
  import type { DatabaseTarget } from '@ultimat3/mcp';
8
- import type { DevServices } from './dev-services';
12
+ import { branchNameOf, pgliteBranchName } from './db-branch';
13
+ import type { DevServices, Env } from './dev-services';
14
+ import { safeUrlLabel } from './safe-url-label';
9
15
 
10
16
  /**
11
- * `production` is always false: this target is whatever `x dev` resolved — embedded PGlite under
12
- * `.x/`, or the `DATABASE_URL` of a developer's shell. Production is reached through `ROLE=migrate`
13
- * in a deploy hook, never through MCP. What actually stops a migration against a shared database is
14
- * `branch`, which is null unless the name says otherwise.
17
+ * `production` was the literal `false` in both arms until `As of 2026-08`, and this is the only
18
+ * place a `DatabaseTarget` is ever built — so `assertBranchDatabase`'s FIRST refusal, the one
19
+ * meaning "production is never migratable from MCP at all", could not run for any database this
20
+ * CLI produced. A production database refused it only incidentally, because its name lacked
21
+ * `_branch_`; one named `shop_branch_hotfix`, or a container whose data directory is
22
+ * `pgdata-hotfix`, read as a branch and was migratable through an MCP tool call.
23
+ *
24
+ * The environment is read the way `x doctor` reads it (`cmd-doctor.ts`), through core's one key —
25
+ * so `production` is a fact about the deploy and `branch` stays a fact about the database, and
26
+ * `@ultimat3/mcp` keeps receiving two answers rather than re-deriving either.
15
27
  */
16
- export function databaseTarget(services: DevServices): DatabaseTarget {
28
+ export function databaseTarget(services: DevServices, env: Env): DatabaseTarget {
17
29
  const url = services.db.url;
30
+ const production = isProduction(env);
18
31
  return services.db.mode === 'embedded'
19
- ? { label: url, branch: pgliteBranch(url, services.stateDir), production: false }
20
- : { label: safeLabel(url), branch: postgresBranch(url), production: false };
32
+ ? { label: url, branch: pgliteBranch(url, services.stateDir), production }
33
+ : {
34
+ // An external `DATABASE_URL` may carry credentials, and this string gets printed.
35
+ label: safeUrlLabel(url, 'external database'),
36
+ branch: postgresBranch(url),
37
+ production,
38
+ };
21
39
  }
22
40
 
23
- /** An external `DATABASE_URL` may carry credentials, and this string gets printed. */
24
- function safeLabel(url: string): string {
25
- try {
26
- const parsed = new URL(url);
27
- return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
28
- } catch {
29
- return 'external database';
30
- }
31
- }
41
+ /**
42
+ * The non-throwing read, because `ULTIMATE_ENV` is in no env schema — nothing validates it at boot,
43
+ * and a tool call is not where a typo should surface as a crash (`cmd-doctor.ts` chose the same
44
+ * variant for the same reason).
45
+ *
46
+ * `undefined` is answered **true**, and that is the whole point of using it here. It means exactly
47
+ * one thing: `ULTIMATE_ENV` is set to something that is not an environment. Reading a typo as "not
48
+ * production" would let the single misconfiguration this guard exists to survive defeat it — so an
49
+ * environment that cannot be read is treated as the most dangerous one it could be.
50
+ */
51
+ const isProduction = (env: Env): boolean => {
52
+ const environment = tryResolveEnvironment({ env });
53
+ return environment === undefined || environment === 'production';
54
+ };
32
55
 
33
- /** `x db branch <name>` names an external clone `<source>_branch_<name>` (`branchDatabaseName`). */
56
+ /**
57
+ * `x db branch create <name>` names an external clone `<source>_branch_<name>`. Both readings come
58
+ * from `db-branch.ts` — the module that also WRITES those names — because a target that disagrees
59
+ * with `x db branch ls` about what a branch is would let `db.migrate` run against a shared
60
+ * database on the strength of a naming rule one of the two had drifted away from.
61
+ */
34
62
  function postgresBranch(url: string): string | null {
35
63
  let database: string;
36
64
  try {
@@ -38,13 +66,10 @@ function postgresBranch(url: string): string | null {
38
66
  } catch {
39
67
  return null;
40
68
  }
41
- return /_branch_(.+)$/.exec(database)?.[1] ?? null;
69
+ return branchNameOf(database);
42
70
  }
43
71
 
44
72
  /** `branchPglite` copies `<stateDir>/pgdata` to `<stateDir>/pgdata-<name>`; the dev dir is no branch. */
45
73
  function pgliteBranch(url: string, stateDir: string): string | null {
46
- const dir = pgliteDataDir(url);
47
- const dev = join(stateDir, 'pgdata');
48
- if (dir === dev || basename(dir) === basename(dev)) return null;
49
- return dir.startsWith(`${dev}-`) ? dir.slice(dev.length + 1) : null;
74
+ return pgliteBranchName(pgliteDataDir(url), join(stateDir, 'pgdata'));
50
75
  }
package/src/mcp-errors.ts CHANGED
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { describeErrorCode, hasErrorCode, listErrorCodes } from '@ultimat3/core';
6
6
  import type { ErrorExplanation } from '@ultimat3/mcp';
7
- import type { CliErrorCode } from './errors';
8
- import { CLI_ERROR_CODES, docsFor } from './errors';
7
+ import type { CliErrorCode } from './error-codes';
8
+ import { CLI_ERROR_CODES, docsFor } from './error-codes';
9
+ import { codeFixes, codeFixScan } from './error-fixes';
9
10
 
10
11
  /**
11
12
  * One runnable command per CLI code. Typed over `CliErrorCode`, so a new code fails the build.
@@ -18,17 +19,25 @@ import { CLI_ERROR_CODES, docsFor } from './errors';
18
19
  */
19
20
  const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
20
21
  X_CLI_UNKNOWN_COMMAND: 'x help --json',
21
- X_CLI_BAD_FLAG: 'x help <command> --json',
22
+ // Runnable first, the narrowing behind a `#`: `x help <command> --json` pasted into a shell
23
+ // is a redirect, not a command, and this table is copied verbatim by whoever reads it.
24
+ X_CLI_BAD_FLAG: 'x help --json # then narrow to the command the cause names',
22
25
  X_VERIFY_FAILED: 'x verify --json',
23
26
  X_NOT_IN_APP: 'x new myapp --json && cd myapp',
24
27
  X_BUN_VERSION: 'bun upgrade',
25
28
  X_NOT_IMPLEMENTED: 'x doctor --json',
26
- X_TEST_NO_FILES: 'x test --cwd <repo root> --json',
29
+ // Core's three env codes, answered by the command that covers each. `X_CONFIG_INVALID` gets
30
+ // `x doctor` rather than `x env check`: its causes are env *and* `app.config.ts` fields, and
31
+ // `x env check` on a config the app cannot boot on would throw this same code straight back.
32
+ X_CONFIG_INVALID: 'x doctor --json',
33
+ X_ENV_MISSING: 'x env check --json',
34
+ X_ENV_EXAMPLE_DRIFT: 'x env example --json',
35
+ X_TEST_NO_FILES: 'x test --json # from the repo root, or pass --cwd to it',
27
36
  X_TEST_SHARD_FAILED: 'x test --workers 1 --json',
28
- X_SCAFFOLD_PATH_ESCAPE: 'x g route <name> --json # a path with no ".." segment',
37
+ X_SCAFFOLD_PATH_ESCAPE: 'x g route posts --json # a path with no ".." segment',
29
38
  X_GENERATE_JSON_INVALID:
30
39
  'bun test packages/cli/src/cmd-generate.test.ts # the error names the template to fix',
31
- X_APP_PACKAGE_INVALID: 'bun pm pkg set name=<app> version=0.1.0',
40
+ X_APP_PACKAGE_INVALID: 'bun pm pkg set name=my-app version=0.1.0',
32
41
  X_ERROR_CODE_UNKNOWN: 'x errors list --json',
33
42
  X_DECLARATION_UNKNOWN: 'x actions list --json',
34
43
  X_JOB_UNKNOWN: 'x jobs ls --json',
@@ -41,20 +50,41 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
41
50
  X_TYPECHECK_FAILED: 'bunx tsc -b --pretty false',
42
51
  X_LINT_FAILED: 'bunx biome check --write .',
43
52
  X_TEST_FAILED: 'x test --json # the finding carries the exact bun test invocation that failed',
53
+ // The same two edits `vanishedSuiteFinding` names, verbatim, so both surfaces of this code hand
54
+ // an agent one instruction. Neither edit is scripted here on purpose: a command that rewrites
55
+ // x.verify.json is the gate editing its own ratchet, which is the false green the floor closes.
56
+ X_VERIFY_SUITE_VANISHED:
57
+ 'x verify --json # restore the suite, or drop its name from x.verify.json in the commit that says why',
44
58
  X_FILE_TOO_LONG: 'x verify --json # the finding names the file to split',
45
- X_PACKAGE_SHAPE: 'bun run scripts/new-package.ts <pkg> --only <file>',
59
+ X_PACKAGE_SHAPE: 'bun run verify --json # every finding carries its own new-package.ts command',
60
+ // NOT `bunx tsc -b`: an unreferenced package is one `tsc -b` skips by definition, so it exits 0
61
+ // while the finding stands — a fix that runs clean and changes nothing is the failure axiom 4
62
+ // exists to prevent. The gate is what re-emits the finding, whose own `fix:` carries the exact
63
+ // `{ "path": … }` entry; the `tsc -b` that then reports the type errors the package had been
64
+ // hiding is a step of the same run.
65
+ X_PACKAGE_UNREFERENCED:
66
+ 'x verify --json # the package-shape finding carries the tsconfig.json entry to add',
46
67
  X_RELEASE_VERSION_SKEW: 'bun run scripts/release.ts --bump patch --dry-run --json',
68
+ // Two real remedies and the command cannot know which one this deployment wants, so it names
69
+ // the one that inspects the binding rather than guessing between a volume and a bucket.
70
+ X_STORAGE_UNWRITABLE: 'x doctor --json',
71
+ X_STORAGE_SECRET_DEV: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
47
72
  X_MANIFEST_STALE: 'x manifest --json',
48
- X_BUDGET_UNMEASURED: 'x build --json && x verify --json',
73
+ // `--target static`, not a bare `x build`: `--target` defaults to `docker`, and only the static
74
+ // target runs `apps/web/prerender.ts` — the one caller of `writeBuildStats`. Without the flag
75
+ // this fix builds an image, writes no `.x/build-stats.json`, and the next `x verify` reports the
76
+ // same code. Byte-identical to `checkBudgets`'s own finding, which is the other half of the pair.
77
+ X_BUDGET_UNMEASURED: 'x build --target static --json && x verify --json',
49
78
  X_BUILD_FAILED: 'x build --json # the finding names the failing step',
50
79
  X_BUILD_ENTRY_MISSING:
51
- 'x new <name> --dry-run --json # the file list names every entry a build needs',
80
+ 'x new scratch-app --dry-run --json # the file list names every entry a build needs',
52
81
  X_DEPLOY_FAILED: 'x deploy --json # the finding carries the command to re-run directly',
53
82
  // The container's own environment, so the answer is the run that sets it — never an `x` command,
54
83
  // which is not what is running when a `ROLE=wroker` pod refuses to boot.
55
- X_ROLE_UNKNOWN: 'docker run -e ROLE=web <image>',
56
- X_PORT_INVALID: 'docker run -e PORT=3000 <image>',
57
- X_GENERATE_CONFLICT: 'x g <kind> <name> --force --json',
84
+ X_ROLE_UNKNOWN: 'docker run -e ROLE=web my-app:latest',
85
+ X_PORT_INVALID: 'docker run -e PORT=3000 my-app:latest',
86
+ X_RUNTIME_DRIVER_SPLIT: 'x dev --json # the boot names the driver the app installed twice',
87
+ X_GENERATE_CONFLICT: 'x g route posts --force --json',
58
88
  X_PORT_IN_USE: 'x dev --port 3001 --json',
59
89
  // Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
60
90
  // so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
@@ -63,20 +93,99 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
63
93
  X_DB_MIGRATE_FAILED: 'x doctor --json # cause carries the Postgres error verbatim',
64
94
  X_DB_BRANCH_FAILED: 'x db branch ls --json',
65
95
  X_DB_STUDIO_FAILED: 'x doctor --json',
66
- X_BOUNDARY_SITE_TO_APP: 'x fix boundary <file> --json',
67
- X_BOUNDARY_SHARED_LEAF: 'x fix boundary <file> --json',
68
- X_BOUNDARY_APP_TO_API: 'x fix boundary <file> --json',
69
- X_BOUNDARY_ROUTE_TO_DB: 'x fix boundary <file> --json',
70
- X_BOUNDARY_SERVICE_TO_HTTP: 'x fix boundary <file> --json',
96
+ // Runnable first, the narrowing behind a `#`, exactly as X_CLI_UNKNOWN_COMMAND above: naming
97
+ // the tier IS the consent, and which seed to consent to is the one thing this table cannot
98
+ // know — a bare `x db seed --tier dev` would seed every dev fixture in production to answer a
99
+ // refusal about one. The dry run is what lists them, and the raised error's own `fix:` already
100
+ // carries the fully named invocation. `ULTIMATE_SEED_TIER=<tier>` is the other half of the
101
+ // consent and stays in the cause: it is the answer only for a container with a fixed argv.
102
+ X_SEED_ENVIRONMENT:
103
+ 'x db seed --dry-run --json # then name the tier: x db seed <name> --tier dev --json',
104
+ X_BOUNDARY_SITE_TO_APP:
105
+ 'x verify --json # then: x fix boundary <the file the finding names> --json',
106
+ X_BOUNDARY_SHARED_LEAF:
107
+ 'x verify --json # then: x fix boundary <the file the finding names> --json',
108
+ X_BOUNDARY_APP_TO_API:
109
+ 'x verify --json # then: x fix boundary <the file the finding names> --json',
110
+ X_BOUNDARY_ROUTE_TO_DB:
111
+ 'x verify --json # then: x fix boundary <the file the finding names> --json',
112
+ X_BOUNDARY_SERVICE_TO_HTTP:
113
+ 'x verify --json # then: x fix boundary <the file the finding names> --json',
114
+ // The app's own guards. All three are reported by the gate and by nothing else, so the runnable
115
+ // half is the gate — the narrowing behind the `#` is the edit, because only the finding knows
116
+ // which file in `guards/` is the one to open.
117
+ X_GUARD_INVALID: 'x verify --json # then export a `guard` from the file the finding names',
118
+ X_GUARD_FAILED: 'x verify --json # the cause carries the throw the guard raised, verbatim',
119
+ X_GUARD_FINDING_INVALID:
120
+ 'x verify --json # then give the finding an X_ code, a cause and a fix naming a command',
121
+ // `EDITOR=` inline rather than `export`: the variable is only needed for the one invocation, and
122
+ // an agent copying this line gets a working command instead of a shell it has to keep.
123
+ X_SECRETS_EDITOR_MISSING: 'EDITOR=nano x secrets edit',
124
+ X_SECRETS_EDIT_FAILED: 'x secrets show --json # then re-open the buffer: x secrets edit',
125
+ // Render's code, thrown here by the bundler half: the cause names the specifier and the file it
126
+ // resolved to, and `x g island` is what puts that file where the page already says it is.
127
+ X_ISLAND_INVALID: 'x routes --json # the cause names the src; then: x g island <name>',
71
128
  };
72
129
 
73
130
  const isCliCode = (code: string): code is CliErrorCode =>
74
131
  (CLI_ERROR_CODES as readonly string[]).includes(code);
75
132
 
133
+ /**
134
+ * The fix a code's own throw site writes, for every code this table does not own.
135
+ *
136
+ * The table above stays the answer for `CliErrorCode` and only for it: those lines are typed,
137
+ * build-enforced, and several of them are deliberately NOT the throw site's wording (the comments
138
+ * above say which and why). Everywhere else the throw site is the definition and this is a
139
+ * projection of it — one `fix:`, written once, `x errors explain` and the raised error agreeing by
140
+ * construction rather than by review.
141
+ *
142
+ * Both fallbacks name what they do not know. `x verify --json` was the old answer for all 327 of
143
+ * them, and it is a lie for every runtime code: the gate does not raise `X_UNAUTHENTICATED`, so
144
+ * running it reports green and the reader is exactly where they started.
145
+ */
146
+ function projectedFix(code: string): string {
147
+ const sites = codeFixes().get(code) ?? [];
148
+ const readable = sites.filter((site) => site.fix !== undefined);
149
+ const first = readable[0];
150
+ if (first?.fix === undefined) {
151
+ const site = sites[0];
152
+ if (site !== undefined) {
153
+ // The file comes FIRST and carries no verb. `open …` read as a command — `open(1)`,
154
+ // `xdg-open` — and an agent that executed it got `command not found`, which is the same
155
+ // axiom-4 failure as the `x verify --json` this replaced, one step further along. There is
156
+ // genuinely no command here: the fix is assembled from values only the raised error holds.
157
+ // "A file they can open" is the error contract's own fourth shape (`COMMAND_TOKENS`), and
158
+ // citing a command that does not really fix it is the mistake `fix-command.ts` warns about.
159
+ // `x errors explain --json` carries the same site as DATA, so nothing has to parse this.
160
+ return `${site.at}:${site.line} — the fix is built there out of values only the raised error carries, so reproduce the error and read its own fix line`;
161
+ }
162
+ if (codeFixScan() === 'unread') {
163
+ // Not "nothing raises it": nothing LOOKED. `cmd-docs.ts` answers the same broken install
164
+ // with the same line, because it is the same condition seen from a second command.
165
+ return `bun install && x doctor --json # the installed @ultimat3 packages could not be read, so no throw site could be quoted for ${code}`;
166
+ }
167
+ return `x errors list --json # nothing in the installed framework raises ${code}, so the package that registered it owns its fix`;
168
+ }
169
+ // Both notes name a thing this answer does NOT know, because an answer that hides either is one
170
+ // an agent acts on without noticing: which of several throw sites it is quoting, and which words
171
+ // in it were an interpolation at the throw site and are a placeholder here.
172
+ const notes: string[] = [];
173
+ if (readable.length > 1) {
174
+ notes.push(
175
+ `${code} is raised at ${readable.length} sites; this one is ${first.at}:${first.line}`,
176
+ );
177
+ }
178
+ if (first.fix.includes('<value>')) {
179
+ notes.push('each <value> is filled in by the error that raises it');
180
+ }
181
+ return notes.length === 0 ? first.fix : `${first.fix} # ${notes.join('; ')}`;
182
+ }
183
+
76
184
  /**
77
185
  * `undefined` for a code nobody registered — the tool then answers "unknown error code", which
78
186
  * beats an invented explanation. The framework-wide registry holds a title and a docs URL but no
79
- * fix (a thrown error carries its own), so a non-CLI code points at the gate that surfaces it.
187
+ * fix, so the fix comes from `error-fixes.ts`'s read of the throw sites; a caller that has not
188
+ * awaited `loadCodeFixes()` gets the honest fallback rather than a stale answer.
80
189
  */
81
190
  export function explainErrorCode(code: string): ErrorExplanation | undefined {
82
191
  const cli = isCliCode(code);
@@ -85,7 +194,7 @@ export function explainErrorCode(code: string): ErrorExplanation | undefined {
85
194
  return {
86
195
  code,
87
196
  cause: described.title,
88
- fix: cli ? CLI_FIXES[code] : 'x verify --json',
197
+ fix: cli ? CLI_FIXES[code] : projectedFix(code),
89
198
  docs: cli ? docsFor(code) : described.docs,
90
199
  };
91
200
  }
package/src/mcp-host.ts CHANGED
@@ -3,11 +3,17 @@
3
3
  // the gate. The description half is the framework's own `frameworkIntrospection`, so nothing here
4
4
  // is a second catalog of routes, entities, actions, queries or jobs.
5
5
 
6
- import { existsSync } from 'node:fs';
7
6
  import { join } from 'node:path';
8
- import { agentActor, UltimateError } from '@ultimat3/core';
7
+ import { agentActor, isUltimateError, UltimateError } from '@ultimat3/core';
9
8
  import type { DbClient } from '@ultimat3/db';
10
- import { ensureReadOnlyRole, readLedger, readOnlyQuery } from '@ultimat3/db';
9
+ import {
10
+ ensureReadOnlyRole,
11
+ isLedgerMissing,
12
+ migrate,
13
+ pendingMigrations,
14
+ readLedger,
15
+ readOnlyQuery,
16
+ } from '@ultimat3/db';
11
17
  import { inspectJobList, inspectQueues } from '@ultimat3/jobs';
12
18
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
13
19
  import type {
@@ -28,13 +34,14 @@ import type { RunningServices } from './dev-runtime';
28
34
  import { startServices } from './dev-runtime';
29
35
  import type { DevServices, Env } from './dev-services';
30
36
  import { resolveServices } from './dev-services';
31
- import { MIGRATIONS_DIR } from './drift';
37
+ import { loadCodeFixes } from './error-fixes';
32
38
  import { CliNotImplementedError } from './errors';
33
39
  import type { Runner } from './exec';
34
40
  import { execOutput } from './exec';
35
41
  import { databaseTarget } from './mcp-db-target';
36
42
  import { explainErrorCode } from './mcp-errors';
37
43
  import { parseBunTest } from './mcp-test-output';
44
+ import { readMigrations } from './migrations';
38
45
 
39
46
  export interface DevHostInput {
40
47
  readonly root: string;
@@ -106,20 +113,25 @@ export function lazyServices(input: DevHostInput): LazyServices {
106
113
  };
107
114
  }
108
115
 
109
- /** Migration ids on disk (`0001_init.sql` → `0001_init`) that the ledger does not record. */
110
- async function pendingMigrations(root: string, lazy: LazyServices): Promise<readonly string[]> {
111
- const dir = join(root, MIGRATIONS_DIR);
112
- if (!existsSync(dir)) return [];
113
- const ids: string[] = [];
114
- for await (const file of new Bun.Glob('*.sql').scan({ cwd: dir, absolute: false })) {
115
- if (!file.endsWith('.down.sql')) ids.push(file.replace(/\.sql$/, ''));
116
- }
116
+ /**
117
+ * Migration ids on disk that the ledger does not record. Both halves are the framework's own —
118
+ * `readMigrations` is the list `ROLE=migrate` applies and `pendingMigrations` is the filter
119
+ * `migrate()` applies it through, so this tool can never report a pending set the migrator would
120
+ * disagree with.
121
+ */
122
+ async function pendingIds(root: string, lazy: LazyServices): Promise<readonly string[]> {
123
+ const migrations = await readMigrations(root);
124
+ if (migrations.length === 0) return [];
117
125
  const { db } = await lazy.running();
118
126
  // No ledger table means nothing has been applied. `ensureLedger` would create it, and a dry run
119
- // is not allowed to write.
120
- const ledger = await readLedger(db).catch(() => []);
121
- const applied = new Set(ledger.map((row) => row.id));
122
- return ids.filter((id) => !applied.has(id)).sort();
127
+ // is not allowed to write. Only that condition: a permission denied or an unreachable server is
128
+ // a ledger nobody read, and answering it with `[]` reports every migration as pending against a
129
+ // database whose state this tool never saw.
130
+ const ledger = await readLedger(db).catch((error: unknown) => {
131
+ if (!isLedgerMissing(error)) throw error;
132
+ return [];
133
+ });
134
+ return pendingMigrations(ledger, migrations).map((migration) => migration.id);
123
135
  }
124
136
 
125
137
  // ── the capabilities ─────────────────────────────────────────────────────────
@@ -164,7 +176,7 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
164
176
  let readOnlyRole: Promise<string | null> | undefined;
165
177
 
166
178
  return {
167
- database: databaseTarget(lazy.services),
179
+ database: databaseTarget(lazy.services, input.env),
168
180
 
169
181
  async runQuery(sql: string, limits: QueryLimits): Promise<QueryRows> {
170
182
  const { db } = await lazy.running();
@@ -175,20 +187,24 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
175
187
  },
176
188
 
177
189
  async runMigrations(branch: string, dryRun: boolean) {
178
- const before = await pendingMigrations(root, lazy);
190
+ const before = await pendingIds(root, lazy);
179
191
  if (dryRun) return { branch, applied: [], pending: before };
180
- const result = await runner(['bunx', 'drizzle-kit', 'migrate'], { cwd: root });
181
- if (!result.ok) {
192
+ const { db } = await lazy.running();
193
+ try {
194
+ await migrate({ migrations: await readMigrations(root), client: db });
195
+ } catch (error) {
182
196
  // Thrown, not returned: `server.ts` renders any X_* error as the three-line
183
- // code/cause/fix result, which is what an agent needs to act without a round trip.
197
+ // code/cause/fix result, which is what an agent needs to act without a round trip. The
198
+ // engine's own errors already carry that shape and pass through untouched.
199
+ if (isUltimateError(error)) throw error;
184
200
  throw new UltimateError({
185
201
  code: 'X_DB_MIGRATE_FAILED',
186
- cause: `${result.command.join(' ')} exited ${result.code}: ${execOutput(result).slice(0, 400)}`,
202
+ cause: error instanceof Error ? error.message : String(error),
187
203
  fix: 'x db reset',
188
204
  });
189
205
  }
190
- // The ledger is the evidence for "applied" — never the migrator's own stdout.
191
- const pending = await pendingMigrations(root, lazy);
206
+ // The ledger is the evidence for "applied" — never the migrator's own return value.
207
+ const pending = await pendingIds(root, lazy);
192
208
  return { branch, applied: before.filter((id) => !pending.includes(id)), pending };
193
209
  },
194
210
 
@@ -270,7 +286,10 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
270
286
  * introspection tool then answers from the framework's own registries, not from a scan.
271
287
  */
272
288
  export async function createDevMcpServer(input: DevHostInput): Promise<CliMcpServer> {
273
- await loadApp(input.root);
289
+ // `explainError` is synchronous by `DevCapabilities`' own signature, so the walk that reads the
290
+ // framework's `fix:` lines happens here, once, or `errors.explain` answers with the fallback for
291
+ // every code it could have quoted.
292
+ await Promise.all([loadApp(input.root), loadCodeFixes()]);
274
293
  const lazy = lazyServices(input);
275
294
  const introspection = frameworkIntrospection({
276
295
  routes: () => describeRoutes(),
package/src/messages.ts CHANGED
@@ -10,6 +10,7 @@ const CATALOG = {
10
10
  'cli.flags.heading': 'flags',
11
11
  'cli.commands.heading': 'commands',
12
12
  'cli.build.done': 'built {target}',
13
+ 'cli.build.failed': '{target} build failed',
13
14
  // `describeCron`'s vocabulary. `@ultimat3/time` is tier 1 and reaches no i18n runtime, so the
14
15
  // caller supplies the words — and the caller here is a rendered `x tasks show` line, which is
15
16
  // exactly what this catalog holds. `msg()` leaves an un-supplied `{n}`/`{time}`/`{days}`/
@@ -24,7 +25,43 @@ const CATALOG = {
24
25
  'cli.cron.inMonths': 'in {months}',
25
26
  'cli.cron.onDaysOfMonth': 'on day {days} of the month',
26
27
  'cli.cron.onWeekdays': 'on {days}',
28
+ 'cli.db.backfill.empty': 'no backfill has run against this database yet',
29
+ 'cli.db.backfill.listed': '{count} backfill pass(es)',
30
+ /** The empty cell in a `x db backfill --list` column — a value, not a column key. */
31
+ 'cli.db.backfill.none': '-',
32
+ 'cli.db.backfill.pending': '{count} of {declared} declared backfill(s) never completed',
33
+ 'cli.db.backfill.swept': 'every one of {declared} declared backfill(s) has completed',
34
+ // Every action counted, never derived: `count - enqueued` folded a deduped pass into "blocked",
35
+ // so the summary said blocked while `--json` said deduped for the same row — two renderers
36
+ // stating different facts about one run, which is the thing `--json` exists to make impossible.
37
+ 'cli.db.backfill.planned':
38
+ '{count} backfill(s): {enqueued} enqueued, {deduped} already live, {blocked} blocked',
39
+ 'cli.db.backfill.dryRun': '{count} backfill(s) would run — nothing written without --write',
27
40
  'cli.db.branch.ready': 'branch {name} ready',
41
+ 'cli.db.branch.dropped': 'branch {name} dropped',
42
+ 'cli.db.branch.failed': 'branch command failed',
43
+ 'cli.db.branch.listed': '{count} branch(es) of this database',
44
+ 'cli.db.branch.none': 'this database has no branch',
45
+ /** The empty cell in an `x db branch ls` column — a value, not a column key. */
46
+ 'cli.db.branch.unknown': '-',
47
+ 'cli.db.gen.failed': 'migration not generated',
48
+ 'cli.db.gen.unchanged': 'entities and migrations agree — nothing to generate',
49
+ // A THIRD outcome, and it is neither of the other two: nothing to generate, but the sidecar the
50
+ // `drift` step reads did move — an edit under `packages/db/src` that implies no DDL. Rendering it
51
+ // as `written` would name a migration nobody can apply; as `unchanged`, it would hide a file this
52
+ // command wrote. `GeneratedFiles.outcome` is what `--json` carries the same distinction on.
53
+ 'cli.db.gen.recorded': 'no migration needed — schema hash re-recorded in {file}',
54
+ 'cli.db.gen.written': 'migration {id} generated',
55
+ 'cli.db.migrate.applied': 'migrations applied',
56
+ 'cli.db.migrate.failed': 'migration failed',
57
+ 'cli.db.reset.done': 'database reset and migrated',
58
+ // Every seed counted per outcome, exactly as the backfill summary is: a replayed seed writes
59
+ // nothing and skips everything, and a total that hid that would make the second run look idle.
60
+ 'cli.db.seed.done':
61
+ '{count} seed(s): {inserted} inserted, {updated} updated, {skipped} already stored',
62
+ 'cli.db.seed.dryRun': '{count} seed(s) would run — nothing written while --dry-run is set',
63
+ 'cli.db.seed.failed': '{failed} of {count} seed(s) failed',
64
+ 'cli.db.seed.none': 'no seed matched — nothing to run',
28
65
  'cli.dev.ready': 'dev ready on {url} — /_x mounted ({panels} panels), {services}',
29
66
  // The mail and CDN halves of that boot line. Rendered text, so it lives here — while
30
67
  // `describeMail`/`describeCdn` keep the same wording as the fixed vocabulary `x dev --json`
@@ -33,6 +70,7 @@ const CATALOG = {
33
70
  'cli.dev.cdn.none': 'cdn=none',
34
71
  'cli.dev.mail.embedded': 'mail=embedded',
35
72
  'cli.dev.mail.external': 'mail=external({driver} via {detail})',
73
+ 'cli.dev.mail.refused': 'mail=refused({detail})',
36
74
  'cli.dev.hmr': 'reloaded {file} in {ms}ms',
37
75
  'cli.dev.roles': ' roles {roles}',
38
76
  'cli.dev.panels': ' panels {panels}',
@@ -41,17 +79,42 @@ const CATALOG = {
41
79
  'cli.deploy.plan': 'containers only: {images} image, roles {roles}',
42
80
  'cli.doctor.clean': 'no findings — environment is shippable',
43
81
  'cli.doctor.findings': '{count} finding(s)',
82
+ 'cli.docs.code': '{code} is an error code — x errors explain answers it',
83
+ 'cli.docs.exports': 'exports: {list}',
84
+ 'cli.docs.installed': 'installed: {list}',
85
+ 'cli.docs.tryErrors': 'every X_* code, with its fix',
86
+ 'cli.docs.tryActions': "this app's own primitives, not the framework's",
87
+ 'cli.docs.found': '{count} doc(s) for "{query}"',
88
+ 'cli.docs.none': 'no framework doc matches "{query}"',
89
+ 'cli.docs.unresolved': 'the installed framework packages could not be located',
44
90
  'cli.errors.count': '{count} registered error code(s)',
45
91
  'cli.errors.explained': '{code} — {title}',
92
+ // One rendering of "this file was written", for every command that writes files — `x g`, its
93
+ // own `--dry-run`, and `x new`. Three copies of the same two characters is how a fourth writer
94
+ // arrives with a fifth marker; `--json` carries the paths themselves in `data.files`.
95
+ 'cli.file.added': ' + {path}',
46
96
  'cli.fix.clean': 'no boundary violation involves {file}',
47
- 'cli.fix.plan': '{count} boundary violation(s) involve {file} — {edits} edit(s) to make',
97
+ // "nothing written", the same admission `cli.generate.planned` makes and for the same reason: a
98
+ // command called `fix` that only ever REPORTS teaches an agent to expect a repair and act as
99
+ // though one happened. There is no `--write` and there is not going to be one
100
+ // (`docs/architecture/02-boundaries.md`), so the line that runs says so every time.
101
+ 'cli.fix.plan':
102
+ '{count} boundary violation(s) involve {file} — {edits} edit(s) to make, nothing written',
48
103
  'cli.generate.wrote': 'wrote {count} file(s) for {kind} {name}',
104
+ // A distinct key, not the same sentence with a flag beside it: `--dry-run` reported "wrote 4
105
+ // file(s)" while `data.dryRun` said nothing had landed, so an agent branching on `summary`
106
+ // believed the files were on disk.
107
+ 'cli.generate.planned': 'would write {count} file(s) for {kind} {name} — nothing written',
49
108
  'cli.i18n.added': 'added {locale} — {keys} key(s) seeded from {from}',
50
109
  'cli.i18n.dynamic': '{count} dynamic t() call(s) the extractor cannot verify:',
51
110
  'cli.i18n.gaps': '{missing} missing key(s) across {locales} locale(s)',
52
111
  'cli.i18n.ok': '{locales} locale(s), {keys} key(s) used — no gaps',
53
112
  'cli.i18n.synced': 'synced {locale} from {from} — {added} key(s) added, {total} total',
54
113
  'cli.i18n.unused': '{count} key(s) defined in {locale} and never used:',
114
+ 'cli.jobs.backfillNoCursor': 'no cursor yet',
115
+ 'cli.jobs.cancelled': 'job {id} cancelled — {state}',
116
+ 'cli.jobs.backfillRow': '{name} — {rows} row(s) so far, cursor {cursor}',
117
+ 'cli.jobs.backfills': '{count} backfill(s) in flight:',
55
118
  'cli.jobs.deadLetters': '{count} dead letter(s):',
56
119
  'cli.jobs.depth':
57
120
  '{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
@@ -69,7 +132,10 @@ const CATALOG = {
69
132
  'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
70
133
  'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
71
134
  'cli.mcp.scopes': ' scopes {scopes}',
72
- 'cli.new.done': 'created {name} — next: cd {name} && x dev',
135
+ // `x db gen "initial"` is a first step, not an optional one: the scaffold writes no migration, so
136
+ // the app has a schema no migration records and `x verify`'s drift step says so until it runs.
137
+ 'cli.new.done':
138
+ 'created {name} — next: cd {name} && bun install && x db gen "initial" && x db migrate && x dev',
73
139
  'cli.policy.count':
74
140
  '{permissions} permission(s), {roles} role(s), {enforced} enforced by a declaration',
75
141
  // One row per (declaration, actor) pair, never per role: a permission two declarations enforce
@@ -97,6 +163,31 @@ const CATALOG = {
97
163
  'cli.test.type.pass': '{type} — {files} test file(s) on {workers} worker(s) passed in {ms}ms',
98
164
  'cli.verify.pass': 'all {count} steps passed in {ms}ms',
99
165
  'cli.verify.fail': '{failed} of {count} steps failed',
166
+ // A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
167
+ // ones are named, because "which suite has nothing to run here?" is the question a green gate
168
+ // over a missing suite has to answer on its own line. Whole sentences per case rather than a
169
+ // clause the caller glues on, the same shape `cli.jobs.drained`/`drainedPartial` already uses.
170
+ 'cli.verify.passSkipped':
171
+ '{passed} of {count} steps passed in {ms}ms — {skipped} skipped: {names}',
172
+ 'cli.verify.failSkipped': '{failed} of {count} steps failed — {skipped} skipped: {names}',
173
+ 'cli.verify.serial': 'serial',
174
+ 'cli.verify.workers': '{workers} workers',
175
+ 'cli.env.checked': '{count} declared variable(s), all present and valid',
176
+ 'cli.env.invalid': '{count} of {total} declared variable(s) missing or malformed',
177
+ 'cli.env.wrote': 'wrote {path} — {count} declared variable(s)',
178
+ 'cli.env.fresh': '{path} already matches the declaration',
179
+ 'cli.secrets.init': 'sealed {path} — master key {kid}, and .gitignore now covers the key file',
180
+ 'cli.secrets.deploy': ' carry the key into a deploy with {env}="$(cat {keyPath})"',
181
+ 'cli.secrets.redeploy': ' set {env}="$(cat {keyPath})" in every deploy before the next release',
182
+ 'cli.secrets.shown': '{count} secret(s) in {path}, sealed with master key {kid}',
183
+ 'cli.secrets.empty': '{path} holds no secrets yet',
184
+ 'cli.secrets.undeclared':
185
+ '{count} secret(s) no envSchema declares, so nothing reads them: {names}',
186
+ 'cli.secrets.edited': '{path} resealed — {added} added, {updated} changed, {removed} removed',
187
+ 'cli.secrets.unchanged': '{path} unchanged — nothing was written',
188
+ 'cli.secrets.set': 'sealed {name} into {path} — {count} secret(s)',
189
+ 'cli.secrets.rotated':
190
+ 'rotated {path} from master key {from} to {to} — {count} secret(s) resealed',
100
191
  } as const;
101
192
 
102
193
  export type MessageKey = keyof typeof CATALOG;