@ultimat3/cli 23.0.0 → 24.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 (149) hide show
  1. package/CLAUDE.md +15 -7
  2. package/README.md +3 -3
  3. package/package.json +28 -28
  4. package/src/affected.ts +27 -35
  5. package/src/api-registration.ts +89 -0
  6. package/src/app-artifacts.ts +1 -1
  7. package/src/app-auth.ts +4 -27
  8. package/src/app-config-load.ts +51 -0
  9. package/src/app-env.ts +5 -11
  10. package/src/app-manifest.ts +2 -8
  11. package/src/app-mcp.ts +10 -23
  12. package/src/app-permissions-borrowed.ts +129 -0
  13. package/src/app-permissions-site.ts +24 -0
  14. package/src/app-permissions.ts +7 -17
  15. package/src/budgets.ts +4 -35
  16. package/src/cdp-shot-driver.ts +3 -1
  17. package/src/cmd-auth-spec.ts +16 -0
  18. package/src/cmd-auth.ts +65 -0
  19. package/src/cmd-build.ts +64 -15
  20. package/src/cmd-db-backfill.ts +25 -12
  21. package/src/cmd-db.ts +6 -5
  22. package/src/cmd-deploy-helm.ts +7 -6
  23. package/src/cmd-deploy.ts +4 -4
  24. package/src/cmd-dev.ts +9 -8
  25. package/src/cmd-doctor-spec.ts +2 -9
  26. package/src/cmd-doctor.ts +41 -14
  27. package/src/cmd-env.ts +26 -2
  28. package/src/cmd-generate.ts +79 -32
  29. package/src/cmd-help.ts +19 -6
  30. package/src/cmd-i18n.ts +10 -9
  31. package/src/cmd-jobs.ts +18 -6
  32. package/src/cmd-manifest-spec.ts +1 -1
  33. package/src/cmd-manifest.ts +6 -25
  34. package/src/cmd-mcp.ts +46 -15
  35. package/src/cmd-planned.ts +15 -8
  36. package/src/cmd-routes.ts +12 -2
  37. package/src/cmd-secrets.ts +47 -6
  38. package/src/cmd-shot-matrix.ts +1 -3
  39. package/src/cmd-shot.ts +12 -3
  40. package/src/cmd-tasks.ts +2 -2
  41. package/src/cmd-test-spec.ts +1 -7
  42. package/src/cmd-test.ts +13 -29
  43. package/src/cmd-verify.ts +4 -4
  44. package/src/db-subscribes.ts +12 -4
  45. package/src/dev-dashboard-guard.ts +76 -0
  46. package/src/dev-dashboard.ts +51 -29
  47. package/src/dev-lock.ts +4 -7
  48. package/src/dispatch.ts +3 -6
  49. package/src/doctor-app-url.ts +39 -0
  50. package/src/doctor-auth.ts +95 -0
  51. package/src/drift-replica-identity.ts +42 -0
  52. package/src/error-catalog.ts +6 -9
  53. package/src/error-codes.ts +23 -3
  54. package/src/error-unthrown.ts +29 -11
  55. package/src/errors.ts +54 -83
  56. package/src/framework-schema-apply.ts +78 -0
  57. package/src/framework-schema-stamp.ts +96 -0
  58. package/src/framework-schema.ts +13 -2
  59. package/src/generate-files.ts +4 -3
  60. package/src/generate-kinds.ts +33 -1
  61. package/src/generate-shadow.ts +46 -0
  62. package/src/generate-write.ts +47 -13
  63. package/src/gh-target.ts +12 -7
  64. package/src/i18n-audit.ts +4 -1
  65. package/src/index.ts +1 -3
  66. package/src/island-bundle.ts +3 -15
  67. package/src/jobs-drain.ts +82 -15
  68. package/src/jobs-report.ts +4 -4
  69. package/src/loopback-host.ts +13 -0
  70. package/src/manifest-staleness.ts +62 -0
  71. package/src/mcp-errors.ts +12 -3
  72. package/src/mcp-host.ts +16 -9
  73. package/src/mcp-test-run.ts +38 -0
  74. package/src/messages.ts +2 -0
  75. package/src/metrics-endpoint.ts +25 -1
  76. package/src/output.ts +6 -0
  77. package/src/page-navigation.ts +7 -23
  78. package/src/page-speculation.ts +5 -20
  79. package/src/prerender-out.ts +34 -7
  80. package/src/prerender.ts +8 -5
  81. package/src/pwa-artifacts.ts +26 -94
  82. package/src/registry.ts +2 -0
  83. package/src/revalidated-response.ts +4 -3
  84. package/src/role-replicator.ts +16 -0
  85. package/src/role-start.ts +9 -4
  86. package/src/role-sync.ts +55 -20
  87. package/src/runtime-assets.ts +11 -7
  88. package/src/runtime-bindings.ts +4 -4
  89. package/src/runtime-cache.ts +38 -47
  90. package/src/runtime-idempotency-scope.ts +23 -0
  91. package/src/runtime-isr.ts +36 -0
  92. package/src/runtime-jobs.ts +8 -33
  93. package/src/runtime-mfa-warning.ts +29 -0
  94. package/src/runtime-notify-retention.ts +10 -46
  95. package/src/runtime-overrides.ts +1 -1
  96. package/src/runtime-purge.ts +38 -16
  97. package/src/runtime-queue.ts +31 -10
  98. package/src/runtime-realtime.ts +9 -45
  99. package/src/runtime-render.ts +62 -35
  100. package/src/runtime-services.ts +20 -17
  101. package/src/runtime-storage.ts +10 -5
  102. package/src/schema-diff.ts +16 -0
  103. package/src/schema-drift.ts +45 -15
  104. package/src/seo-routes.ts +80 -9
  105. package/src/serve-boot.ts +34 -6
  106. package/src/serve-drain.ts +9 -33
  107. package/src/serve-env.ts +9 -6
  108. package/src/serve-web.ts +12 -7
  109. package/src/shot-locale.ts +5 -23
  110. package/src/site-assets.ts +3 -5
  111. package/src/site-config.ts +9 -48
  112. package/src/site-seo.ts +1 -1
  113. package/src/sitemap-lastmod.ts +5 -0
  114. package/src/solid-loader.ts +12 -3
  115. package/src/source-files.ts +6 -0
  116. package/src/stored-object-headers.ts +43 -0
  117. package/src/sync-url.ts +2 -2
  118. package/src/tasks-facts.ts +3 -29
  119. package/src/templates/action.ts +3 -0
  120. package/src/templates/naming.ts +28 -0
  121. package/src/templates/route.ts +3 -6
  122. package/src/templates/scaffold-app.ts +6 -2
  123. package/src/templates/scaffold-container-compose.ts +10 -1
  124. package/src/templates/scaffold-container.ts +4 -2
  125. package/src/templates/scaffold-db-client.ts +4 -2
  126. package/src/templates/scaffold-helm-fences.ts +76 -0
  127. package/src/templates/scaffold-helm-templates.ts +32 -7
  128. package/src/templates/scaffold-helm.ts +31 -1
  129. package/src/templates/scaffold-repo.ts +9 -1
  130. package/src/templates/scaffold-shell.ts +39 -36
  131. package/src/test-counts.ts +2 -6
  132. package/src/test-ignores.ts +25 -0
  133. package/src/test-passes.ts +0 -15
  134. package/src/test-select.ts +5 -14
  135. package/src/test-shards.ts +41 -93
  136. package/src/test-workers.ts +26 -4
  137. package/src/theme-boot.ts +6 -31
  138. package/src/trusted-hops-error.ts +17 -0
  139. package/src/unthrown-codes.ts +26 -0
  140. package/src/verify-checks.ts +17 -46
  141. package/src/verify-deadline.ts +130 -33
  142. package/src/verify-merge.ts +60 -27
  143. package/src/verify-part-step.ts +90 -0
  144. package/src/verify-run.ts +29 -8
  145. package/src/verify-shard.ts +13 -2
  146. package/src/verify-stalled.ts +152 -0
  147. package/src/verify-tests.ts +5 -2
  148. package/src/build-errors.ts +0 -54
  149. package/src/verify-errors.ts +0 -58
@@ -1,6 +1,6 @@
1
1
  // Mounting `@ultimat3/admin`'s `/_x` dashboard in the `x dev` process. The CLI contributes only
2
- // what no registry holds — a SQL runner on the live dev database, the caught outbox, the committed
3
- // manifest, and two panels of process facts — and projects the dashboard onto HTTP routes.
2
+ // what no registry holds — the live dev database, the caught outbox, the committed manifest, and
3
+ // two panels of process facts — and projects the dashboard onto HTTP routes behind a Host check.
4
4
  // A panel implemented here instead of in `admin` would be the second copy this seam exists to ban.
5
5
 
6
6
  import type {
@@ -14,7 +14,13 @@ import type {
14
14
  SqlResult,
15
15
  StatementLoopFact,
16
16
  } from '@ultimat3/admin/dev';
17
- import { DEV_BASE_PATH, DEV_PANELS, defaultDevSources, devDashboard } from '@ultimat3/admin/dev';
17
+ import {
18
+ DEV_BASE_PATH,
19
+ DEV_PANELS,
20
+ defaultDevSources,
21
+ devDashboard,
22
+ readOnlySql,
23
+ } from '@ultimat3/admin/dev';
18
24
  import { recentInvalidations } from '@ultimat3/cache';
19
25
  import type { Role } from '@ultimat3/core';
20
26
  import type { Route, UltimateRequest } from '@ultimat3/http';
@@ -24,6 +30,7 @@ import { isMemoryDriver } from '@ultimat3/mail';
24
30
  import type { Manifest } from '@ultimat3/manifest';
25
31
  import { appManifest, readAppManifest } from './app-manifest';
26
32
  import { appBoundaryFindings } from './boundary-findings';
33
+ import { assertSameOrigin, hostRefusal, SQL_BODY, sqlOnGet } from './dev-dashboard-guard';
27
34
  import type { StatementLedger } from './dev-n-plus-one';
28
35
  import { devPolicyMatrix } from './dev-policy';
29
36
  import type { TraceRecorder } from './dev-traces';
@@ -53,24 +60,6 @@ export interface DevDashboardInput {
53
60
  readonly statements?: StatementLedger | undefined;
54
61
  }
55
62
 
56
- /**
57
- * Read-only is already enforced by `assertReadOnly` inside `dbPanel`, before `runSql` is ever
58
- * reached. A second gate here would be a second authz: two places to update when what the panel
59
- * allows changes, and one of them would eventually disagree.
60
- */
61
- async function runSql(input: DevDashboardInput, sql: string): Promise<SqlResult> {
62
- const started = performance.now();
63
- const rows = await input.runtime.db.query<Readonly<Record<string, unknown>>>({
64
- text: sql,
65
- values: [],
66
- });
67
- const elapsedMs = Math.round(performance.now() - started);
68
- // Columns come from the first row because the driver returns objects, not a described result
69
- // set; no rows means no columns to name, which the panel renders as an empty grid.
70
- const columns = Object.keys(rows[0] ?? {});
71
- return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
72
- }
73
-
74
63
  /** `MailMessage.locale` is non-optional in `@ultimat3/mail`, so the panel never has to guess. */
75
64
  function mailFacts(outbox: MemoryMailDriver): readonly MailFact[] {
76
65
  return outbox.outbox().map((entry) => ({
@@ -137,9 +126,13 @@ export function devSources(input: DevDashboardInput): DevSources {
137
126
  // the messages are at the provider, so the hook is omitted rather than answered with `[]` —
138
127
  // an empty outbox claims nobody was mailed, which is a different and unearned answer.
139
128
  const outbox = isMemoryDriver(input.runtime.mail) ? input.runtime.mail : undefined;
129
+ // `dbPanel`'s `assertReadOnly` is the parse guard; `readOnlySql` is the server saying no — its own
130
+ // `BEGIN READ ONLY` that always rolls back, a statement timeout and a row ceiling. The plain
131
+ // `db.query` it replaces ran whatever the guard let through with the app's own credentials.
132
+ const runSql = readOnlySql(input.runtime.db);
140
133
  return defaultDevSources({
141
134
  hooks: {
142
- runSql: (sql: string): Promise<SqlResult> => runSql(input, sql),
135
+ runSql: (sql: string): Promise<SqlResult> => runSql(sql),
143
136
  ...(outbox === undefined
144
137
  ? {}
145
138
  : { mail: (): Promise<readonly MailFact[]> => Promise.resolve(mailFacts(outbox)) }),
@@ -222,8 +215,13 @@ const notClaimed = (path: string): Response =>
222
215
  { status: 404 },
223
216
  );
224
217
 
225
- const devRoute = (path: string, name: string, handler: Route['handler']): Route => ({
226
- method: 'GET',
218
+ const devRoute = (
219
+ path: string,
220
+ name: string,
221
+ handler: Route['handler'],
222
+ method: 'GET' | 'POST' = 'GET',
223
+ ): Route => ({
224
+ method,
227
225
  path,
228
226
  // Public: /_x exists to be read without credentials by whatever drives the dev loop, and
229
227
  // `devDashboard` refuses to construct at all outside development.
@@ -232,9 +230,10 @@ const devRoute = (path: string, name: string, handler: Route['handler']): Route
232
230
  });
233
231
 
234
232
  /**
235
- * One route for the base path plus one per panel, because the router matches exact paths. The
236
- * dashboard is built once — its sources close over this process, and rebuilding per request would
237
- * re-run `assertDevOnly` on every hit for no new answer.
233
+ * One route for the base path plus one per panel, because the router matches exact paths, and a
234
+ * `POST` for the SQL panel — the only `/_x` read that runs something. The dashboard is built once
235
+ * — its sources close over this process, and rebuilding per request would re-run `assertDevOnly`
236
+ * on every hit for no new answer. Every route answers this machine's Host only.
238
237
  */
239
238
  export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
240
239
  const panels = devPanels(input);
@@ -245,13 +244,36 @@ export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
245
244
  ...(input.env === undefined ? {} : { env: input.env }),
246
245
  });
247
246
 
248
- const handler = async (request: UltimateRequest): Promise<Response> =>
249
- (await dashboard.handle(request.raw)) ?? notClaimed(request.pathname);
247
+ const sqlPath = `${DEV_BASE_PATH}/db`;
248
+ const handler = async (request: UltimateRequest): Promise<Response> => {
249
+ const devUrl = input.status().url;
250
+ const refused = hostRefusal(request, devUrl);
251
+ if (refused !== undefined) return refused;
252
+ if (request.pathname === sqlPath && request.url.searchParams.has('sql')) throw sqlOnGet(devUrl);
253
+ return (await dashboard.handle(request.raw)) ?? notClaimed(request.pathname);
254
+ };
255
+ // The statement moves from the body onto the panel's own parameter, server side: `dbPanel`
256
+ // reads `sql` off the query, and this is the one caller allowed to put it there.
257
+ const runStatement = async (request: UltimateRequest): Promise<Response> => {
258
+ const refused = hostRefusal(request, input.status().url);
259
+ if (refused !== undefined) return refused;
260
+ assertSameOrigin(request);
261
+ const { sql } = await request.body(SQL_BODY);
262
+ const target = new URL(request.url);
263
+ target.searchParams.set('sql', sql);
264
+ // A GET carries no body, so the body's own headers stay behind; `accept` and the rest ride.
265
+ const headers = new Headers(request.headers);
266
+ headers.delete('content-type');
267
+ headers.delete('content-length');
268
+ const forwarded = new Request(target, { headers });
269
+ return (await dashboard.handle(forwarded)) ?? notClaimed(request.pathname);
270
+ };
250
271
 
251
272
  return [
252
273
  devRoute(DEV_BASE_PATH, 'dev._x', handler),
253
274
  ...panels.map((panel) =>
254
275
  devRoute(`${DEV_BASE_PATH}/${panel.key}`, `dev._x.${panel.key}`, handler),
255
276
  ),
277
+ devRoute(sqlPath, 'dev._x.db.run', runStatement, 'POST'),
256
278
  ];
257
279
  }
package/src/dev-lock.ts CHANGED
@@ -18,6 +18,7 @@ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync
18
18
  import { join } from 'node:path';
19
19
  import { stringField, UltimateError } from '@ultimat3/core';
20
20
  import { exec, type Runner } from './exec';
21
+ import { portPairAfter } from './flag-number';
21
22
  import { quoteArg } from './shell-quote';
22
23
 
23
24
  /** Where the running dev server records itself, inside the state directory it already owns. */
@@ -220,12 +221,6 @@ export class DevPortInUseError extends UltimateError {
220
221
  }
221
222
  }
222
223
 
223
- /**
224
- * The next port to suggest. Never `port + 1` at the top of the range — 65536 is not a port, and a
225
- * `fix:` that cannot run is the failure this whole module exists to end.
226
- */
227
- export const suggestPort = (port: number): number => (port >= 65535 ? port - 1 : port + 1);
228
-
229
224
  /**
230
225
  * Is anything listening? A successful bind-then-close is the only answer that does not lie.
231
226
  *
@@ -379,7 +374,9 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
379
374
  release();
380
375
  throw new DevPortInUseError({
381
376
  port: input.port,
382
- suggestion: suggestPort(input.port),
377
+ // `portPairAfter`, never `port + 1`: `x dev` binds a PAIR, so the holder is most often
378
+ // another `x dev` whose sync role already sits on `port + 1`.
379
+ suggestion: portPairAfter(input.port),
383
380
  holder: await (input.holder ?? portHolder)(input.port),
384
381
  });
385
382
  }
package/src/dispatch.ts CHANGED
@@ -6,9 +6,9 @@ import { isAbsolute, resolve } from 'node:path';
6
6
  import { setLogStream } from '@ultimat3/core';
7
7
  import { requireAppRoot, requireBunVersion } from './app-root';
8
8
  import { createHelpCommand } from './cmd-help';
9
- import { plannedCommandFor } from './cmd-planned';
9
+ import { plannedCommandError, plannedCommandFor } from './cmd-planned';
10
10
  import type { CommandContext } from './command';
11
- import { CliNotImplementedError, UnknownCommandError } from './errors';
11
+ import { UnknownCommandError } from './errors';
12
12
  import type { Runner } from './exec';
13
13
  import { exec } from './exec';
14
14
  import type { CommandResult } from './output';
@@ -91,10 +91,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
91
91
  // X_NOT_IMPLEMENTED with a runnable fix. Substituted here rather than in `parse.ts`, which is
92
92
  // pure and knows nothing about what a command means; the precedent is the help swap below.
93
93
  const planned = plannedCommandFor(commandFor(options.argv[0] ?? '')?.spec.name);
94
- const failure =
95
- planned === undefined
96
- ? error
97
- : new CliNotImplementedError({ feature: `x ${planned.name}`, fix: planned.fix });
94
+ const failure = planned === undefined ? error : plannedCommandError(planned);
98
95
  // `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
99
96
  // an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
100
97
  const result = errorResult(planned?.name ?? 'x', failure);
@@ -0,0 +1,39 @@
1
+ // `x doctor`'s APP_URL rule, pure: a set APP_URL must be an origin a browser can match, and a
2
+ // loopback one must name the port `x dev` serves on — OAuth's redirect, absolute links and the sync
3
+ // node's admitted origin all read it, and a laptop origin on another port reaches another server.
4
+
5
+ import { ERROR_DOCS_URL, renderFixShellArg } from '@ultimat3/core';
6
+ import { isLoopbackHostname } from './loopback-host';
7
+ import type { Finding } from './output';
8
+ import { findingFrom } from './output';
9
+ import { syncOriginsFrom } from './sync-url';
10
+
11
+ /**
12
+ * Unset is no finding: `x dev` serves without one, and a production build warns on its own
13
+ * (`originWarning`). A public origin is not judged against the dev port — that is the deploy's
14
+ * address, not this machine's. The parse is the sync boot's own (`syncOriginsFrom`), so doctor
15
+ * refuses exactly the values the boot would.
16
+ */
17
+ export function appUrlFindings(raw: string | undefined, port: number): readonly Finding[] {
18
+ let origins: readonly string[];
19
+ try {
20
+ origins = syncOriginsFrom({ APP_URL: raw });
21
+ } catch (error) {
22
+ return [findingFrom(error)];
23
+ }
24
+ const origin = origins[0];
25
+ if (origin === undefined) return [];
26
+ const url = new URL(origin);
27
+ if (!isLoopbackHostname(url.hostname)) return [];
28
+ const declared = url.port === '' ? (url.protocol === 'https:' ? 443 : 80) : Number(url.port);
29
+ if (declared === port) return [];
30
+ const wanted = `${url.protocol}//${url.hostname}:${String(port)}`;
31
+ return [
32
+ {
33
+ code: 'X_APP_URL_PORT_MISMATCH',
34
+ cause: `APP_URL is ${origin}, but x dev serves this app on port ${String(port)} — OAuth redirects, absolute links and the sync origin would point at another server`,
35
+ fix: `export APP_URL=${renderFixShellArg(wanted, 'http://localhost:3000')}`,
36
+ docs: ERROR_DOCS_URL,
37
+ },
38
+ ];
39
+ }
@@ -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 { BuiltinAdapter, countUnsealedMfaSecrets } from '@ultimat3/auth';
7
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
8
+ import { createPostgresClient, 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 = createPostgresClient({ 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: new BuiltinAdapter(client) })
88
+ : 0;
89
+ return { unsealedMfaSecrets, retiredTables };
90
+ } catch {
91
+ return NO_AUTH_STORAGE_FACT;
92
+ } finally {
93
+ await client.close();
94
+ }
95
+ }
@@ -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: its live queries' `subscribes:` plus every params channel's `records`
17
+ * tables, 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
+ }
@@ -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,19 @@ 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',
223
231
  ] as const;
224
232
 
225
233
  /**
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
234
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s — every planned command throws it through core's
235
+ * own `NotImplementedError`, and none of them may declare a title for it. The CLI is the process that imports every
228
236
  * package (`error-catalog.ts`), so a title declared twice here is the one that would win by load
229
237
  * order rather than by ownership.
230
238
  *
@@ -285,7 +293,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
285
293
  X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
286
294
  X_ERROR_CODE_UNRESOLVED: 'an error code is written as a name this repository cannot resolve',
287
295
  X_ERROR_CODE_UNTHROWN:
288
- 'a registered error code is constructed by nothing and its reference row does not say so',
296
+ 'a registered error code is constructed by nothing and UNTHROWN_CODES in packages/cli/src/unthrown-codes.ts does not list it',
289
297
  X_FRAMEWORK_SCHEMA_FAILED: 'a framework table could not be created at boot',
290
298
  X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
291
299
  X_STORAGE_SECRET_DEV: 'upload grants would be signed with the shipped development key',
@@ -392,6 +400,18 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
392
400
  'a container role built at boot what the image build should have: island chunks or stylesheets',
393
401
  X_ADMIN_UNSCANNED:
394
402
  'a defineAdmin() declaration sits outside the app scan, so the admin is never mounted',
403
+ X_FRAMEWORK_TABLE_ORPHANED:
404
+ 'a framework table no release reads any more is still in the database',
405
+ X_BUILD_OUT_UNSAFE: 'the static export directory is not one a build may empty',
406
+ X_FRAMEWORK_SCHEMA_UNAPPLIED:
407
+ 'a serving role booted over a database whose framework schema this build never applied',
408
+ X_APP_URL_PORT_MISMATCH: 'APP_URL names a loopback port x dev does not serve',
409
+ X_DEV_HOST_REFUSED: 'a /_x request named a host that is not this machine',
410
+ X_TRUSTED_PROXY_HOPS_INVALID:
411
+ 'TRUSTED_PROXY_HOPS is not a whole number of proxies the boot accepts',
412
+ X_PERMISSION_BORROWED: 'an app rule requires a permission only an imported package declared',
413
+ X_ERROR_CODE_UNTHROWN_STALE:
414
+ 'a code UNTHROWN_CODES lists as thrown by nothing is thrown again — delete it from UNTHROWN_CODES in packages/cli/src/unthrown-codes.ts',
395
415
  };
396
416
 
397
417
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -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
  }