@ultimat3/cli 23.0.0 → 25.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -0,0 +1,95 @@
1
+ // `x doctor`'s auth-storage checks: what an upgrade leaves in the database for an operator to
2
+ // finish — second-factor secrets written before they were sealed, and a framework table no
3
+ // release reads any more. Reaching the server is `authStorageProbe`, deciding is
4
+ // `authStorageFindings` — the split `doctor-sealed.ts` makes.
5
+
6
+ import { countUnsealedMfaSecrets, postgresAuthAdapter } from '@ultimat3/auth';
7
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
8
+ import { postgresClient, raw } from '@ultimat3/db';
9
+ import type { Finding } from './output';
10
+
11
+ /**
12
+ * Framework tables an earlier release created and none reads now, each with the command that
13
+ * removes it. The boot never drops one: a replica still on the previous release may be using it
14
+ * for the length of a rolling deploy. A whole literal per table — nothing is spliced into a fix.
15
+ */
16
+ type RetiredTable = 'x_auth_failures';
17
+
18
+ // A Map, not an object: a table name is looked up by key, and an object answers `constructor`.
19
+ const RETIRED_TABLE_FIXES: ReadonlyMap<RetiredTable, string> = new Map([
20
+ [
21
+ 'x_auth_failures',
22
+ `psql "$DATABASE_URL" -c 'drop table if exists x_auth_failures' # once every replica runs this release`,
23
+ ],
24
+ ]);
25
+
26
+ export const RETIRED_FRAMEWORK_TABLES: readonly RetiredTable[] = Object.freeze([
27
+ ...RETIRED_TABLE_FIXES.keys(),
28
+ ]);
29
+
30
+ export interface AuthStorageFact {
31
+ /** `x_users` rows holding an `mfa_secret` that is not sealed. */
32
+ readonly unsealedMfaSecrets: number;
33
+ /** Which of `RETIRED_FRAMEWORK_TABLES` are still present. */
34
+ readonly retiredTables: readonly RetiredTable[];
35
+ }
36
+
37
+ /** What a database that was not asked — embedded, or unreachable — answers: nothing to report. */
38
+ export const NO_AUTH_STORAGE_FACT: AuthStorageFact = Object.freeze({
39
+ unsealedMfaSecrets: 0,
40
+ retiredTables: Object.freeze([]),
41
+ });
42
+
43
+ /** The rule, pure. */
44
+ export function authStorageFindings(fact: AuthStorageFact): readonly Finding[] {
45
+ const findings: Finding[] = [];
46
+ if (fact.unsealedMfaSecrets > 0) {
47
+ findings.push({
48
+ code: 'X_MFA_SECRET_UNSEALED',
49
+ cause: `${fact.unsealedMfaSecrets} user(s) hold a second-factor secret that is not sealed; the framework never reads a plaintext secret, so each of them is refused at the second factor until it is sealed`,
50
+ fix: 'x auth seal-mfa --json',
51
+ docs: ERROR_DOCS_URL,
52
+ });
53
+ }
54
+ for (const table of fact.retiredTables) {
55
+ const fix = RETIRED_TABLE_FIXES.get(table);
56
+ if (fix === undefined) continue;
57
+ findings.push({
58
+ code: 'X_FRAMEWORK_TABLE_ORPHANED',
59
+ cause: `${table} is still in the database and no release reads it any more; the boot does not drop it, because a replica on the previous release may still be writing to it during a rolling deploy`,
60
+ fix,
61
+ docs: ERROR_DOCS_URL,
62
+ });
63
+ }
64
+ return findings;
65
+ }
66
+
67
+ /**
68
+ * Asks an EXTERNAL database only — `probeDatabase`'s own rule: opening the embedded one boots
69
+ * PGlite and takes the single-writer lock the next command needs. A server that does not answer
70
+ * is `database()`'s finding to make, so every failure here is "nothing to report".
71
+ */
72
+ export async function authStorageProbe(url: string | undefined): Promise<AuthStorageFact> {
73
+ if (url === undefined || url.trim() === '') return NO_AUTH_STORAGE_FACT;
74
+ const client = postgresClient({ url, applicationName: 'x-doctor' });
75
+ try {
76
+ const present = async (table: string): Promise<boolean> => {
77
+ const rows = await client.query<{ readonly present: boolean }>(
78
+ raw(`select to_regclass('public.${table}') is not null as present`),
79
+ );
80
+ return rows[0]?.present === true;
81
+ };
82
+ const retiredTables: RetiredTable[] = [];
83
+ for (const table of RETIRED_FRAMEWORK_TABLES) {
84
+ if (await present(table)) retiredTables.push(table);
85
+ }
86
+ const unsealedMfaSecrets = (await present('x_users'))
87
+ ? await countUnsealedMfaSecrets({ adapter: postgresAuthAdapter(client) })
88
+ : 0;
89
+ return { unsealedMfaSecrets, retiredTables };
90
+ } catch {
91
+ return NO_AUTH_STORAGE_FACT;
92
+ } finally {
93
+ await client.close();
94
+ }
95
+ }
@@ -4,11 +4,12 @@
4
4
  // diagnostic over.
5
5
 
6
6
  import { ERROR_DOCS_URL } from '@ultimat3/core';
7
- import { describeRoutes } from '@ultimat3/render';
7
+ import { describePages } from '@ultimat3/render';
8
8
  import { loadApp } from './app-load';
9
9
  import { APP_CONFIG_FILE } from './app-root';
10
10
  import type { Finding } from './output';
11
11
  import { loadPwaArtifacts } from './pwa-artifacts';
12
+ import { quoteArg } from './shell-quote';
12
13
 
13
14
  /** A route as this check reads one: the URL it answers, and the surface that answers it. */
14
15
  export interface NavigableRoute {
@@ -46,7 +47,7 @@ export interface OfflineFallbackFact {
46
47
  * below already refuses `--surface app`, so the check and the remedy disagreed about one code.
47
48
  *
48
49
  * Residual, and NOT closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
49
- * either. `NavigableRoute` carries no render mode, and `describeRoutes()` has one — the narrower
50
+ * either. `NavigableRoute` carries no render mode, and `describePages()` has one — the narrower
50
51
  * check belongs with it.
51
52
  */
52
53
  const NAVIGABLE: ReadonlySet<string> = new Set(['site']);
@@ -100,13 +101,13 @@ export function offlineFallbackFinding(fact: OfflineFallbackFact): Finding | und
100
101
  const name = GENERATABLE.exec(fallback)?.[1];
101
102
  return name === undefined
102
103
  ? finding(cause, `set pwa.offline.fallback in ${APP_CONFIG_FILE} to a path a route serves`)
103
- : finding(cause, `x g route ${name} --surface site`);
104
+ : finding(cause, `x g route ${quoteArg(name)} --surface site`);
104
105
  }
105
106
 
106
107
  /**
107
108
  * The fact, read off a real app root. Both halves come from the framework's own answers — the
108
109
  * config through `loadPwaArtifacts` (the one reader of that file) and the routes through
109
- * `describeRoutes()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
110
+ * `describePages()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
110
111
  * built from), so this check and the service worker cannot disagree about which routes exist.
111
112
  */
112
113
  export async function offlineFallbackProbe(root: string): Promise<OfflineFallbackFact> {
@@ -117,6 +118,6 @@ export async function offlineFallbackProbe(root: string): Promise<OfflineFallbac
117
118
  routes:
118
119
  app.findings.length > 0
119
120
  ? undefined
120
- : describeRoutes().map((route) => ({ path: route.path, surface: route.surface })),
121
+ : describePages().map((route) => ({ path: route.path, surface: route.surface })),
121
122
  };
122
123
  }
@@ -32,6 +32,13 @@ export interface DocumentOptions {
32
32
  * decided by `app.config.ts`, which the boot read and the renderer cannot.
33
33
  */
34
34
  readonly themeHead?: string;
35
+ /**
36
+ * The app's brand `<style>` from `theme-brand.ts`, or absent for an app with no
37
+ * `apps/web/shared/theme.ts`. Rendered AFTER the surface stylesheet `<link>`, never in the head
38
+ * block `themeHead` rides: the brand answers `theme.scss` at equal specificity, so only a later
39
+ * position in the cascade lets it win.
40
+ */
41
+ readonly brandHead?: string;
35
42
  /**
36
43
  * The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
37
44
  * document this process serves. Principal-free, so a shareable document carries it too; absent
@@ -0,0 +1,42 @@
1
+ // Single responsibility: which declared tables the drift step expects a migration to have made
2
+ // `REPLICA IDENTITY FULL` — the same set `x db gen` emits the ALTER for (`replicaIdentityTables`),
3
+ // or that command's own refusal as a finding. A tier-3 fact no entity carries, so no source hash
4
+ // moves when a channel with params is added over a table that already exists.
5
+
6
+ import { describeQueries } from '@ultimat3/query';
7
+ import { QuerySubscribesUnknownError, replicaIdentityTables } from './db-subscribes';
8
+ import { MIGRATIONS_DIR } from './migrations';
9
+ import type { Finding } from './output';
10
+ import { findingFrom } from './output';
11
+
12
+ /** The tables needing FULL, given the tables the app's entities declare. Injected for tests. */
13
+ export type ReplicaIdentityWanted = (tables: ReadonlySet<string>) => readonly string[];
14
+
15
+ /**
16
+ * The app's own answer: every params channel's `records` tables, with its live queries'
17
+ * `subscribes:` validated but never granted (#518), read off the registries `loadApp` filled. One function with `db-generate.ts`, so the gate
18
+ * can never expect an identity the generator would not grant, or miss one it would.
19
+ */
20
+ export const appReplicaIdentity: ReplicaIdentityWanted = (tables) =>
21
+ replicaIdentityTables(describeQueries(), tables);
22
+
23
+ export type WantedIdentity =
24
+ | { readonly tables: ReadonlySet<string> }
25
+ | { readonly finding: Finding };
26
+
27
+ /**
28
+ * `x db gen` refuses a `subscribes:` name no entity declares (`X_QUERY_SUBSCRIBES_UNKNOWN`), and the
29
+ * gate reports the same refusal rather than letting it escape the step: every other throw is a
30
+ * defect in this process and is rethrown as one.
31
+ */
32
+ export function wantedReplicaIdentity(
33
+ wanted: ReplicaIdentityWanted,
34
+ tables: ReadonlySet<string>,
35
+ ): WantedIdentity {
36
+ try {
37
+ return { tables: new Set(wanted(tables)) };
38
+ } catch (error) {
39
+ if (!(error instanceof QuerySubscribesUnknownError)) throw error;
40
+ return { finding: { ...findingFrom(error), at: MIGRATIONS_DIR } };
41
+ }
42
+ }
package/src/drift.ts CHANGED
@@ -53,16 +53,39 @@ async function declaredSchemaJson(root: string): Promise<string> {
53
53
  */
54
54
  export async function schemaHash(root: string): Promise<string> {
55
55
  const glob = new Bun.Glob(SCHEMA_GLOB);
56
- const paths: string[] = [];
56
+ const sources: SchemaSource[] = [];
57
57
  for await (const path of glob.scan({ cwd: root, absolute: false })) {
58
- if (!path.includes('.test.')) paths.push(path);
58
+ if (path.includes('.test.')) continue;
59
+ sources.push({ path, text: await Bun.file(join(root, path)).text() });
59
60
  }
60
- paths.sort();
61
+ return hashSchemaSources(await declaredSchemaJson(root), sources);
62
+ }
63
+
64
+ /** One file under `SCHEMA_GLOB`: its path relative to the app root, as the OS spelled it. */
65
+ export interface SchemaSource {
66
+ readonly path: string;
67
+ readonly text: string;
68
+ }
69
+
70
+ /**
71
+ * The hash itself, pure. The sidecar is committed and compared on every OS, so what a checkout
72
+ * spells differently is folded away first: `\` separators (a Windows glob) become `/` BEFORE the
73
+ * sort — `seed2.ts` and `seed/index.ts` order differently under the two — and CRLF text (Git for
74
+ * Windows' `core.autocrlf=true`) becomes LF. An LF tree on POSIX is hashed exactly as before, so no
75
+ * sidecar already recorded moves.
76
+ */
77
+ export function hashSchemaSources(schemaJson: string, sources: readonly SchemaSource[]): string {
78
+ const files = sources
79
+ .map(({ path, text }) => ({
80
+ path: path.replaceAll('\\', '/'),
81
+ text: text.replaceAll('\r\n', '\n'),
82
+ }))
83
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
61
84
  const hasher = new Bun.CryptoHasher('sha256');
62
- hasher.update(await declaredSchemaJson(root));
63
- for (const path of paths) {
85
+ hasher.update(schemaJson);
86
+ for (const { path, text } of files) {
64
87
  hasher.update(path);
65
- hasher.update(await Bun.file(join(root, path)).text());
88
+ hasher.update(text);
66
89
  }
67
90
  return hasher.digest('hex').slice(0, 16);
68
91
  }
@@ -46,21 +46,18 @@ export const CATALOG_PACKAGES = [
46
46
  ] as const;
47
47
 
48
48
  /**
49
- * The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them. `admin` and `ui`
50
- * reach for a JSX runtime an app has and a bare CLI process does not, so a hard dependency would
51
- * make the CLI uninstallable where the codes are merely absent today. `scraping` is an app's own
52
- * choice since 22.0.0: `x shot` drives Chrome over raw CDP and imports nothing from it, so its codes
49
+ * The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them — never one it does
50
+ * declare (`error-catalog.test.ts`), which is why `admin` is not here: `x dev` imports it and
51
+ * `package.json` depends on it. `ui` reaches the CLI only as `admin`'s own dependency, so an
52
+ * installer that keeps transitive packages private reports its codes `unavailable` rather than
53
+ * the CLI declaring a package no command imports. `scraping` is an app's own choice since 22.0.0: `x shot` drives Chrome over raw CDP and imports nothing from it, so its codes
53
54
  * answer where the app installed it and are reported `unavailable` where it did not. Every other
54
55
  * entry above is a real runtime import and must be a declared dependency — `error-catalog.test.ts`
55
56
  * holds the list to exactly that, because an undeclared one resolves through workspace symlinks
56
57
  * here and through nothing in an installed app, where `x errors explain X_FLAG_EXPIRED` then
57
58
  * refuses a code the wiki promises.
58
59
  */
59
- export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
60
- '@ultimat3/admin',
61
- '@ultimat3/scraping',
62
- '@ultimat3/ui',
63
- ];
60
+ export const CATALOG_OPTIONAL_HOSTS: readonly string[] = ['@ultimat3/scraping', '@ultimat3/ui'];
64
61
 
65
62
  export interface ErrorCatalog {
66
63
  /** Packages whose codes are now registered. */
@@ -220,11 +220,22 @@ export const CLI_OWNED_ERROR_CODES = [
220
220
  'X_ADMIN_RESOURCE_UNWIRED',
221
221
  'X_IMAGE_NOT_PREBUILT',
222
222
  'X_ADMIN_UNSCANNED',
223
+ 'X_FRAMEWORK_TABLE_ORPHANED',
224
+ 'X_BUILD_OUT_UNSAFE',
225
+ 'X_FRAMEWORK_SCHEMA_UNAPPLIED',
226
+ 'X_APP_URL_PORT_MISMATCH',
227
+ 'X_DEV_HOST_REFUSED',
228
+ 'X_TRUSTED_PROXY_HOPS_INVALID',
229
+ 'X_PERMISSION_BORROWED',
230
+ 'X_ERROR_CODE_UNTHROWN_STALE',
231
+ 'X_DEV_STATE_UNWRITABLE',
232
+ 'X_PACKAGE_TEST_ONLY_SHIPPED',
233
+ 'X_ISLAND_MODULE_DUPLICATED',
223
234
  ] as const;
224
235
 
225
236
  /**
226
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — `CliNotImplementedError` and every planned command
227
- * throw it, and none of them may declare a title for it. The CLI is the process that imports every
237
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — every planned command throws it through core's
238
+ * own `NotImplementedError`, and none of them may declare a title for it. The CLI is the process that imports every
228
239
  * package (`error-catalog.ts`), so a title declared twice here is the one that would win by load
229
240
  * order rather than by ownership.
230
241
  *
@@ -285,7 +296,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
285
296
  X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
286
297
  X_ERROR_CODE_UNRESOLVED: 'an error code is written as a name this repository cannot resolve',
287
298
  X_ERROR_CODE_UNTHROWN:
288
- 'a registered error code is constructed by nothing and its reference row does not say so',
299
+ 'a registered error code is constructed by nothing and UNTHROWN_CODES in packages/cli/src/unthrown-codes.ts does not list it',
289
300
  X_FRAMEWORK_SCHEMA_FAILED: 'a framework table could not be created at boot',
290
301
  X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
291
302
  X_STORAGE_SECRET_DEV: 'upload grants would be signed with the shipped development key',
@@ -392,6 +403,21 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
392
403
  'a container role built at boot what the image build should have: island chunks or stylesheets',
393
404
  X_ADMIN_UNSCANNED:
394
405
  'a defineAdmin() declaration sits outside the app scan, so the admin is never mounted',
406
+ X_FRAMEWORK_TABLE_ORPHANED:
407
+ 'a framework table no release reads any more is still in the database',
408
+ X_BUILD_OUT_UNSAFE: 'the static export directory is not one a build may empty',
409
+ X_FRAMEWORK_SCHEMA_UNAPPLIED:
410
+ 'a serving role booted over a database whose framework schema this build never applied',
411
+ X_APP_URL_PORT_MISMATCH: 'APP_URL names a loopback port x dev does not serve',
412
+ X_DEV_HOST_REFUSED: 'a /_x request named a host that is not this machine',
413
+ X_TRUSTED_PROXY_HOPS_INVALID:
414
+ 'TRUSTED_PROXY_HOPS is not a whole number of proxies the boot accepts',
415
+ X_PERMISSION_BORROWED: 'an app rule requires a permission only an imported package declared',
416
+ X_ERROR_CODE_UNTHROWN_STALE:
417
+ 'a code UNTHROWN_CODES lists as thrown by nothing is thrown again — delete it from UNTHROWN_CODES in packages/cli/src/unthrown-codes.ts',
418
+ X_DEV_STATE_UNWRITABLE: 'x dev cannot claim its state directory',
419
+ X_PACKAGE_TEST_ONLY_SHIPPED: 'a test-only module ships in the tarball',
420
+ X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
395
421
  };
396
422
 
397
423
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -1,15 +1,11 @@
1
- // The `style-src` sources that admit an app's own error pages. `error-pages.ts` serves
2
- // `apps/web/site/errors/<status>.html` verbatim, and such a page is a self-contained document —
3
- // it carries its own `<style>`, because the app's stylesheet bundle is not linked from it. Under
4
- // the enforced policy a container sends, `style-src 'self' <overlay hash>` blocked that block and
5
- // the page rendered unstyled; `x dev` sends the policy report-only, so the same page looked fine
6
- // on every author's machine. Hashed at boot, like the hydration runtime: a hash is a function of
7
- // the body, and these files do not change while a container runs.
1
+ // The inline `<style>` bodies of an app's own error pages, for `style-src` to admit. Such a page
2
+ // (`apps/web/site/errors/<status>.html`, served verbatim by `error-pages.ts`) carries its own
3
+ // `<style>` — no stylesheet bundle is linked from it — and under the container's enforced policy an
4
+ // unadmitted block is a page rendered unstyled; `x dev` is report-only, so it looked fine there.
8
5
 
9
6
  // why: Bun exposes no path-join primitive, and the directory is app-root-relative — the same
10
7
  // necessity `error-pages.ts` records.
11
8
  import { join } from 'node:path';
12
- import { cspHashSource } from '@ultimat3/http';
13
9
  import { ERROR_PAGE_DIR } from './error-pages';
14
10
 
15
11
  /**
@@ -26,18 +22,21 @@ export function inlineStyleBodies(html: string): readonly string[] {
26
22
  }
27
23
 
28
24
  /**
29
- * One hash per distinct `<style>` body across every error page the app ships. Read once at boot:
30
- * a page an author drops in while `x dev` runs is served (the reader is per request) but not yet
31
- * admitted, which the report-only policy there turns into a console report, not a blank page.
25
+ * Every distinct `<style>` body across every error page the app ships, sorted — BODIES, because
26
+ * `startRoles({ inlineStyles })` hashes what it is given (`style-csp.ts`). This returned the hashes
27
+ * until 2026-10, and the boot hashed them again: the policy admitted the hash of a hash, and every
28
+ * app error page carrying a `<style>` rendered unstyled under the container's enforced CSP. Read
29
+ * once at boot: a page an author drops in while `x dev` runs is served (the reader is per request)
30
+ * but not yet admitted, which the report-only policy there turns into a console report.
32
31
  */
33
- export async function errorPageStyleSources(root: string): Promise<readonly string[]> {
32
+ export async function errorPageStyleBodies(root: string): Promise<readonly string[]> {
34
33
  const dir = join(root, ERROR_PAGE_DIR);
35
- const sources = new Set<string>();
34
+ const bodies = new Set<string>();
36
35
  for (const name of await htmlFilesIn(dir)) {
37
36
  const html = await Bun.file(join(dir, name)).text();
38
- for (const body of inlineStyleBodies(html)) sources.add(cspHashSource(body));
37
+ for (const body of inlineStyleBodies(html)) bodies.add(body);
39
38
  }
40
- return [...sources].sort();
39
+ return [...bodies].sort();
41
40
  }
42
41
 
43
42
  /** `Bun.Glob.scan` throws `ENOENT` on a missing directory, and no error pages is the common case. */
@@ -1,7 +1,8 @@
1
1
  // A registered code nothing constructs. The registry and the reference keep a shipped code alive
2
2
  // forever — which is right — and that is exactly why a code can outlive its last thrower with no
3
3
  // gate noticing: `X_RPC_FAILED` sat registered and documented as live after the transport change
4
- // took its only throw site away. The reference row has to SAY so, or this reports it.
4
+ // took its only throw site away. It has to be LISTED as unthrown (`UNTHROWN_CODES`), or this
5
+ // reports it.
5
6
 
6
7
  import { join } from 'node:path';
7
8
  import { ERROR_DOCS_URL, maskLiterals, stripComments } from '@ultimat3/core';
@@ -9,6 +10,7 @@ import { RESERVED_HEADING } from './error-contract';
9
10
  import type { Finding } from './output';
10
11
  import { eachSourceFile, isGenerated, isTest } from './source-files';
11
12
  import { isCodeRegistry } from './ts-scan';
13
+ import { UNTHROWN_CODES, UNTHROWN_CODES_FILE } from './unthrown-codes';
12
14
 
13
15
  const CODE_LITERAL = /(['"`])(X_[A-Z0-9_]+)\1/g;
14
16
  const TITLE_KEY = /^[\t ]*(X_[A-Z0-9_]+)\s*:/gm;
@@ -18,9 +20,6 @@ const LIST_LINE = /^[\t ]*(['"`])X_[A-Z0-9_]+\1\s*,?\s*$/;
18
20
  const TABLE_ENTRY = /\b([a-z][A-Za-z0-9]*)\s*:\s*(['"`])(X_[A-Z0-9_]+)\2/g;
19
21
  const MEMBER_READ = /\.([A-Za-z_$][\w$]*)/g;
20
22
 
21
- /** Phrases a reference row uses to say, in words, that nothing throws the code any more. */
22
- const DECLARED_UNTHROWN = /thrown by nothing|not thrown/i;
23
-
24
23
  export interface CodeUse {
25
24
  /** Every code a package registry names. */
26
25
  readonly registered: ReadonlySet<string>;
@@ -78,6 +77,8 @@ export async function collectCodeUse(root: string): Promise<CodeUse> {
78
77
  const members = new Set<string>();
79
78
  for await (const source of eachSourceFile(root)) {
80
79
  if (!/^packages\/[^/]+\/src\//.test(source) || isTest(source) || isGenerated(source)) continue;
80
+ // The list names every code it waives, and naming one is not throwing it.
81
+ if (source === UNTHROWN_CODES_FILE) continue;
81
82
  const use = codeUseOf(await Bun.file(join(root, source)).text());
82
83
  for (const code of use.registered) registered.add(code);
83
84
  for (const code of use.used) used.add(code);
@@ -88,28 +89,36 @@ export async function collectCodeUse(root: string): Promise<CodeUse> {
88
89
  return { registered, used };
89
90
  }
90
91
 
91
- /** Codes whose reference row says nothing throws them, or that sit under the reserved heading. */
92
+ /** Codes that sit under the reserved heading — declared unthrown by WHERE they are, not by words. */
92
93
  export function declaredUnthrown(markdown: string): ReadonlySet<string> {
93
94
  const out = new Set<string>();
94
95
  let reserved = false;
95
96
  for (const line of markdown.split('\n')) {
96
97
  if (line.trim() === RESERVED_HEADING) reserved = true;
97
98
  const row = /^\|\s*`(X_[A-Z0-9_]+)`\s*\|/.exec(line);
98
- if (row !== null && (reserved || DECLARED_UNTHROWN.test(line))) out.add(row[1] ?? '');
99
+ if (row !== null && reserved) out.add(row[1] ?? '');
99
100
  }
100
101
  return out;
101
102
  }
102
103
 
103
104
  const unthrownFinding = (code: string, page: string): Finding => ({
104
105
  code: 'X_ERROR_CODE_UNTHROWN',
105
- cause: `${code} is registered and ${page} presents it as live, but no shipped source constructs it — a reader matching on it waits for an error that cannot arrive`,
106
+ cause: `${code} is registered and ${page} presents it as live, but no shipped source constructs it — a reader matching on it waits for an error that cannot arrive. Add it to UNTHROWN_CODES in ${UNTHROWN_CODES_FILE} and say so in its row, or throw it again where it belongs`,
106
107
  // Never "delete the registration": a shipped code is stable forever, and an old log line must
107
108
  // still explain. The row is what has to change.
108
- fix: `write "registered, thrown by nothing since <version>" into ${code}'s row in ${page}, naming the code that replaced it — or throw it again where it belongs`,
109
+ fix: 'x errors explain X_ERROR_CODE_UNTHROWN --json',
109
110
  docs: ERROR_DOCS_URL,
110
111
  at: page,
111
112
  });
112
113
 
114
+ const staleFinding = (code: string): Finding => ({
115
+ code: 'X_ERROR_CODE_UNTHROWN_STALE',
116
+ cause: `${code} is listed in UNTHROWN_CODES and shipped source constructs it again — the listing would excuse a code that is live, so delete it from UNTHROWN_CODES in ${UNTHROWN_CODES_FILE}`,
117
+ fix: 'x errors explain X_ERROR_CODE_UNTHROWN_STALE --json',
118
+ docs: ERROR_DOCS_URL,
119
+ at: UNTHROWN_CODES_FILE,
120
+ });
121
+
113
122
  /**
114
123
  * Registered, used by nothing, and not declared unthrown on the reference. A host check — the page
115
124
  * is the host repo's to name, and only a monorepo's walk sees every package's source; in a
@@ -118,13 +127,22 @@ const unthrownFinding = (code: string, page: string): Finding => ({
118
127
  export async function checkErrorCodesThrown(
119
128
  root: string,
120
129
  page: string,
130
+ listed: ReadonlySet<string> = UNTHROWN_CODES,
121
131
  ): Promise<readonly Finding[]> {
122
132
  const reference = Bun.file(join(root, page));
123
133
  if (!(await reference.exists())) return [];
124
- const exempt = declaredUnthrown(await reference.text());
134
+ const reserved = declaredUnthrown(await reference.text());
125
135
  const { registered, used } = await collectCodeUse(root);
126
- return [...registered]
127
- .filter((code) => !used.has(code) && !exempt.has(code))
136
+ const unthrown = [...registered]
137
+ .filter((code) => !used.has(code) && !listed.has(code) && !reserved.has(code))
128
138
  .sort()
129
139
  .map((code) => unthrownFinding(code, page));
140
+ // Only the THROWN-again direction: a listed name no registry holds waives nothing (the code it
141
+ // was meant to name is still reported), and a host repo's fixture registering none of this
142
+ // repo's codes would otherwise read every listing as stale.
143
+ const stale = [...listed]
144
+ .filter((code) => used.has(code))
145
+ .sort()
146
+ .map((code) => staleFinding(code));
147
+ return [...unthrown, ...stale];
130
148
  }
package/src/errors.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // that resolves it — the codes themselves, their titles and their registration are `./error-codes`,
3
3
  // so a package importing a class does not pull the table and vice versa.
4
4
  import { UltimateError } from '@ultimat3/core';
5
+ import type { Finding } from './output';
5
6
  import { quoteArg } from './shell-quote';
6
7
 
7
8
  /** An unknown command or subcommand. Carries a suggestion so the retry is one keystroke away. */
@@ -301,14 +302,59 @@ export class FixTargetUnknownError extends UltimateError {
301
302
  }
302
303
  }
303
304
 
304
- // The build and bundle refusals live in `build-errors.ts` (split at the 500-line ceiling); re-exported
305
- // here so every existing `from './errors'` import keeps resolving.
306
- export type { FrameworkScriptKind } from './build-errors';
307
- export {
308
- BuildEntryMissingError,
309
- FrameworkScriptBuildFailedError,
310
- IslandBuildFailedError,
311
- } from './build-errors';
305
+ /**
306
+ * A client entry would not compile. `X_BUILD_FAILED`, not a code of its own: an island is a bundle
307
+ * entry point like any other, and the target's own logs are what says which line. The fix builds
308
+ * exactly that one file, so the next message an author reads is the compiler's and not the CLI's.
309
+ */
310
+ export class IslandBuildFailedError extends UltimateError {
311
+ constructor(input: { file: string; logs: string }) {
312
+ super({
313
+ code: 'X_BUILD_FAILED',
314
+ cause: `${input.file} is an island entry point and would not bundle: ${input.logs}`,
315
+ fix: `bun build --target browser ${input.file}`,
316
+ });
317
+ }
318
+ }
319
+
320
+ /** Which framework script a page ships: the sync worker, the page boot, or the client router. */
321
+ export type FrameworkScriptKind = 'sync worker' | 'page boot' | 'client router';
322
+
323
+ /**
324
+ * One of the page's framework scripts would not bundle. Same code as an island: "a browser entry
325
+ * the framework builds did not build" is one condition, and the entry here is framework code, not
326
+ * the app's — the cause names WHICH script, so the reader knows what the page is now missing.
327
+ */
328
+ export class FrameworkScriptBuildFailedError extends UltimateError {
329
+ constructor(input: { what: FrameworkScriptKind; entry: string; logs: string }) {
330
+ super({
331
+ code: 'X_BUILD_FAILED',
332
+ cause: `the ${input.what} (${input.entry}) would not bundle for the browser: ${input.logs}`,
333
+ fix: `bun build --target browser --format iife ${quoteArg(input.entry)}`,
334
+ });
335
+ }
336
+ }
337
+
338
+ /**
339
+ * A static build over an app whose modules did not all import. The route registry is filled BY the
340
+ * import, so a page whose module threw is not skipped but absent — and an export without it, with
341
+ * `x build` green, is a deploy that deletes a page. The `manifest` step owns load findings
342
+ * (`load-findings.ts`), so the fix is the command that lists every one with its own fix.
343
+ */
344
+ export class PrerenderLoadFailedError extends UltimateError {
345
+ constructor(findings: readonly Finding[]) {
346
+ const listed = findings.map((finding) =>
347
+ finding.at === undefined
348
+ ? `${finding.code} ${finding.cause}`
349
+ : `${finding.at}: ${finding.code} ${finding.cause}`,
350
+ );
351
+ super({
352
+ code: 'X_BUILD_FAILED',
353
+ cause: `the static export renders the routes the app's modules register, and ${findings.length} module load finding(s) left it short: ${listed.join('; ')}`,
354
+ fix: 'x verify --only manifest --json',
355
+ });
356
+ }
357
+ }
312
358
 
313
359
  /**
314
360
  * `ROLE` selects what a container is. One image runs every role, so a typo is a process that would
@@ -363,36 +409,6 @@ export class PortInvalidError extends UltimateError {
363
409
  }
364
410
  }
365
411
 
366
- /**
367
- * `x env` was run in an app whose `app.config.ts` exports no `envSchema`. Not a silent success:
368
- * writing a `.env.example` with no variables in it, or reporting "0 declared variables, all
369
- * present", both read as a working environment declaration to whoever runs the command next.
370
- *
371
- * `X_CONFIG_INVALID` is core's code for "a configuration this process cannot boot on — env or
372
- * `app.config.ts`", which is exactly this; the CLI names it in `CLI_BORROWED_ERROR_CODES` rather
373
- * than minting a synonym.
374
- */
375
- export class EnvSchemaMissingError extends UltimateError {
376
- constructor(input: { subcommand: string }) {
377
- super({
378
- code: 'X_CONFIG_INVALID',
379
- cause: `x env ${input.subcommand} needs the env declaration, and app.config.ts exports no "envSchema"`,
380
- fix: "add to app.config.ts: export const envSchema = { DATABASE_URL: { type: 'url', description: 'Postgres connection URL' } } satisfies EnvSchema; export const env = defineEnv(envSchema);",
381
- });
382
- }
383
- }
384
-
385
- /** An interface-complete command path whose remote/native half is not written yet. */
386
- export class CliNotImplementedError extends UltimateError {
387
- constructor(input: { feature: string; fix: string }) {
388
- super({
389
- code: 'X_NOT_IMPLEMENTED',
390
- cause: `${input.feature} is not implemented in this build`,
391
- fix: input.fix,
392
- });
393
- }
394
- }
395
-
396
412
  /**
397
413
  * The process could not obtain a storage disk to write to.
398
414
  *
@@ -432,48 +448,3 @@ export class LocalDiskUnsafeError extends UltimateError {
432
448
  });
433
449
  }
434
450
  }
435
-
436
- /**
437
- * `x secrets edit` decrypts into a buffer and hands it to `$EDITOR`. There is no fallback editor:
438
- * guessing one and opening a decrypted file in it is the last place a surprise belongs.
439
- */
440
- export class SecretsEditorMissingError extends UltimateError {
441
- constructor(input: { vars: readonly string[] }) {
442
- super({
443
- code: 'X_SECRETS_EDITOR_MISSING',
444
- cause: `x secrets edit opens the decrypted secrets in an editor and none of ${input.vars.join(', ')} is set`,
445
- fix: 'EDITOR=nano x secrets edit',
446
- });
447
- }
448
- }
449
-
450
- /**
451
- * The editor exited non-zero — a crash, or a deliberate abort. The buffer is discarded either way
452
- * and the committed file is left exactly as it was: resealing a buffer whose editor failed would
453
- * commit whatever half-written state the crash left behind.
454
- */
455
- export class SecretsEditFailedError extends UltimateError {
456
- constructor(input: { editor: string; code: number }) {
457
- super({
458
- code: 'X_SECRETS_EDIT_FAILED',
459
- cause: `"${input.editor}" exited ${input.code}, so the decrypted buffer was discarded and the committed secrets file was not rewritten`,
460
- fix: 'x secrets edit',
461
- });
462
- }
463
- }
464
-
465
- /**
466
- * `x secrets init` would overwrite a file that already exists. `X_GENERATE_CONFLICT` is this
467
- * package's own code for exactly that, and a second name for "a generator would clobber something"
468
- * is the duplication the code registry exists to prevent. Losing a master key is unrecoverable —
469
- * the committed file it opens is then ciphertext nobody can read again.
470
- */
471
- export class SecretsExistsError extends UltimateError {
472
- constructor(input: { path: string; fix: string }) {
473
- super({
474
- code: 'X_GENERATE_CONFLICT',
475
- cause: `${input.path} already exists, and x secrets init would replace it`,
476
- fix: input.fix,
477
- });
478
- }
479
- }