@ultimat3/cli 22.15.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 (267) hide show
  1. package/CLAUDE.md +36 -13
  2. package/README.md +31 -8
  3. package/package.json +29 -28
  4. package/src/admin-catalog-keys.ts +16 -0
  5. package/src/admin-registration.ts +192 -0
  6. package/src/affected.ts +27 -35
  7. package/src/api-registration.ts +125 -11
  8. package/src/app-artifacts.ts +1 -1
  9. package/src/app-auth.ts +4 -27
  10. package/src/app-boundaries.ts +6 -20
  11. package/src/app-config-load.ts +51 -0
  12. package/src/app-env.ts +5 -11
  13. package/src/app-load.ts +55 -11
  14. package/src/app-manifest.ts +16 -8
  15. package/src/app-mcp.ts +10 -23
  16. package/src/app-permissions-borrowed.ts +129 -0
  17. package/src/app-permissions-site.ts +24 -0
  18. package/src/app-permissions.ts +0 -0
  19. package/src/app-reload-graph.ts +7 -30
  20. package/src/app-transport.ts +219 -0
  21. package/src/boundary-findings.ts +16 -4
  22. package/src/browser-transport.ts +160 -0
  23. package/src/budgets.ts +27 -34
  24. package/src/cdp-shot-driver.ts +3 -1
  25. package/src/cmd-auth-spec.ts +16 -0
  26. package/src/cmd-auth.ts +65 -0
  27. package/src/cmd-build-spec.ts +10 -3
  28. package/src/cmd-build.ts +107 -11
  29. package/src/cmd-db-backfill.ts +25 -12
  30. package/src/cmd-db.ts +66 -16
  31. package/src/cmd-deploy-helm.ts +7 -6
  32. package/src/cmd-deploy.ts +4 -4
  33. package/src/cmd-dev.ts +9 -8
  34. package/src/cmd-doctor-spec.ts +2 -9
  35. package/src/cmd-doctor.ts +75 -24
  36. package/src/cmd-env.ts +26 -2
  37. package/src/cmd-generate.ts +195 -47
  38. package/src/cmd-help.ts +19 -6
  39. package/src/cmd-i18n.ts +10 -9
  40. package/src/cmd-jobs-spec.ts +19 -3
  41. package/src/cmd-jobs.ts +136 -7
  42. package/src/cmd-manifest-spec.ts +1 -1
  43. package/src/cmd-manifest.ts +6 -25
  44. package/src/cmd-mcp.ts +46 -15
  45. package/src/cmd-new.ts +15 -1
  46. package/src/cmd-planned.ts +15 -8
  47. package/src/cmd-routes.ts +27 -3
  48. package/src/cmd-secrets-ring.ts +76 -0
  49. package/src/cmd-secrets-spec.ts +10 -2
  50. package/src/cmd-secrets.ts +95 -18
  51. package/src/cmd-shot-matrix.ts +1 -3
  52. package/src/cmd-shot.ts +12 -3
  53. package/src/cmd-tasks.ts +84 -15
  54. package/src/cmd-test-spec.ts +1 -7
  55. package/src/cmd-test.ts +13 -29
  56. package/src/cmd-verify.ts +13 -5
  57. package/src/comment-safe.ts +16 -0
  58. package/src/coverage-floor.ts +334 -0
  59. package/src/coverage-lcov.ts +193 -0
  60. package/src/coverage-source.ts +136 -0
  61. package/src/db-object-drift.ts +30 -0
  62. package/src/db-replay-engine.ts +115 -0
  63. package/src/db-schema-dump.ts +150 -0
  64. package/src/db-schema-refresh.ts +122 -0
  65. package/src/db-subscribes.ts +12 -4
  66. package/src/dev-dashboard-guard.ts +76 -0
  67. package/src/dev-dashboard.ts +51 -29
  68. package/src/dev-lock.ts +4 -7
  69. package/src/dev-route-table.ts +9 -0
  70. package/src/dispatch.ts +3 -6
  71. package/src/doctor-app-url.ts +39 -0
  72. package/src/doctor-auth.ts +95 -0
  73. package/src/doctor-coverage.ts +39 -0
  74. package/src/doctor-guards.ts +54 -0
  75. package/src/doctor-sealed.ts +132 -0
  76. package/src/document-graph.ts +82 -0
  77. package/src/drift-replica-identity.ts +42 -0
  78. package/src/error-catalog.ts +6 -9
  79. package/src/error-codes.ts +55 -3
  80. package/src/error-unthrown.ts +29 -11
  81. package/src/errors.ts +54 -83
  82. package/src/framework-schema-apply.ts +78 -0
  83. package/src/framework-schema-stamp.ts +96 -0
  84. package/src/framework-schema.ts +28 -2
  85. package/src/generate-files.ts +79 -12
  86. package/src/generate-grant-findings.ts +76 -0
  87. package/src/generate-grants.ts +77 -3
  88. package/src/generate-kinds.ts +52 -1
  89. package/src/generate-shadow.ts +46 -0
  90. package/src/generate-write.ts +62 -40
  91. package/src/generated-imports.ts +59 -0
  92. package/src/gh-target.ts +12 -7
  93. package/src/guard-sources.ts +104 -0
  94. package/src/guards.ts +23 -7
  95. package/src/handle-registration.ts +217 -0
  96. package/src/i18n-audit.ts +4 -1
  97. package/src/i18n-registration.ts +36 -0
  98. package/src/image-prepare.ts +51 -8
  99. package/src/import-closure.ts +165 -0
  100. package/src/index.ts +12 -5
  101. package/src/island-bundle.ts +3 -15
  102. package/src/island-store.ts +24 -16
  103. package/src/jobs-drain.ts +95 -21
  104. package/src/jobs-json.ts +50 -0
  105. package/src/jobs-report.ts +7 -4
  106. package/src/json-merge.ts +46 -0
  107. package/src/loopback-host.ts +13 -0
  108. package/src/manifest-staleness.ts +62 -0
  109. package/src/mcp-errors.ts +41 -4
  110. package/src/mcp-host.ts +16 -9
  111. package/src/mcp-test-run.ts +38 -0
  112. package/src/messages.ts +35 -0
  113. package/src/metrics-endpoint.ts +95 -24
  114. package/src/migration-extensions.ts +29 -0
  115. package/src/module-imports.ts +47 -0
  116. package/src/output.ts +24 -9
  117. package/src/page-navigation.ts +7 -23
  118. package/src/page-speculation.ts +5 -20
  119. package/src/permission-grants.ts +41 -9
  120. package/src/prerender-out.ts +34 -7
  121. package/src/prerender.ts +8 -5
  122. package/src/publish-closure.ts +101 -0
  123. package/src/pwa-artifacts.ts +26 -94
  124. package/src/registry.ts +2 -0
  125. package/src/revalidated-response.ts +4 -3
  126. package/src/role-load.ts +143 -0
  127. package/src/role-replicator.ts +16 -0
  128. package/src/role-start.ts +27 -5
  129. package/src/role-sync.ts +55 -20
  130. package/src/role-wake.ts +45 -0
  131. package/src/runtime-admin.ts +141 -0
  132. package/src/runtime-assets.ts +11 -7
  133. package/src/runtime-bindings.ts +4 -4
  134. package/src/runtime-cache.ts +38 -47
  135. package/src/runtime-hooks.ts +5 -2
  136. package/src/runtime-idempotency-scope.ts +23 -0
  137. package/src/runtime-isr.ts +36 -0
  138. package/src/runtime-jobs.ts +8 -33
  139. package/src/runtime-mfa-warning.ts +29 -0
  140. package/src/runtime-notify-retention.ts +10 -46
  141. package/src/runtime-overrides.ts +1 -1
  142. package/src/runtime-purge.ts +38 -16
  143. package/src/runtime-queue.ts +39 -11
  144. package/src/runtime-realtime.ts +9 -45
  145. package/src/runtime-render.ts +68 -35
  146. package/src/runtime-services.ts +22 -18
  147. package/src/runtime-storage.ts +10 -5
  148. package/src/scaffold-typecheck.ts +1 -0
  149. package/src/schema-diff.ts +16 -0
  150. package/src/schema-drift.ts +45 -15
  151. package/src/schema-dump-drift.ts +64 -0
  152. package/src/seo-routes.ts +80 -9
  153. package/src/serve-boot.ts +114 -158
  154. package/src/serve-drain.ts +9 -33
  155. package/src/serve-env.ts +9 -6
  156. package/src/serve-prebuilt-paths.ts +19 -0
  157. package/src/serve-prebuilt.ts +84 -0
  158. package/src/serve-web.ts +169 -0
  159. package/src/serve.ts +6 -9
  160. package/src/server-barrels.ts +54 -0
  161. package/src/shot-locale.ts +5 -23
  162. package/src/site-assets.ts +3 -5
  163. package/src/site-config.ts +9 -48
  164. package/src/site-seo.ts +1 -1
  165. package/src/sitemap-lastmod.ts +5 -0
  166. package/src/solid-loader.ts +12 -3
  167. package/src/source-edit.ts +49 -0
  168. package/src/source-files.ts +6 -0
  169. package/src/stored-object-headers.ts +43 -0
  170. package/src/sync-url.ts +2 -2
  171. package/src/tasks-facts.ts +3 -26
  172. package/src/templates/action.ts +147 -11
  173. package/src/templates/admin-catalog.ts +58 -0
  174. package/src/templates/admin-page.ts +36 -8
  175. package/src/templates/admin.ts +57 -17
  176. package/src/templates/backfill.ts +121 -87
  177. package/src/templates/entity.ts +140 -44
  178. package/src/templates/guard-animated-layout-property.ts +4 -11
  179. package/src/templates/guard-bare-error.ts +9 -13
  180. package/src/templates/guard-focus-visible.ts +4 -11
  181. package/src/templates/guard-image-dimensions.ts +6 -6
  182. package/src/templates/guard-island-without-states.ts +2 -4
  183. package/src/templates/guard-raw-breakpoint.ts +210 -0
  184. package/src/templates/guard-raw-colour.ts +4 -11
  185. package/src/templates/guard-raw-length.ts +375 -0
  186. package/src/templates/guard-raw-motion.ts +213 -0
  187. package/src/templates/guard-raw-shadow.ts +187 -0
  188. package/src/templates/guard-raw-z-index.ts +166 -0
  189. package/src/templates/guard-repo-raw-sql.ts +345 -0
  190. package/src/templates/guard-semantic-interactive.ts +6 -6
  191. package/src/templates/guard-undeclared-custom-property.ts +217 -0
  192. package/src/templates/guard-undefined-style-class.ts +215 -0
  193. package/src/templates/guard-untranslated-string.ts +6 -6
  194. package/src/templates/guard-unzoned-date.ts +9 -13
  195. package/src/templates/index.ts +3 -2
  196. package/src/templates/island.ts +34 -51
  197. package/src/templates/job-body-test.ts +121 -0
  198. package/src/templates/job.ts +23 -8
  199. package/src/templates/naming.ts +28 -0
  200. package/src/templates/policy.ts +16 -9
  201. package/src/templates/query.ts +242 -26
  202. package/src/templates/resource-create.ts +62 -18
  203. package/src/templates/resource-form-island.ts +58 -80
  204. package/src/templates/resource-page.ts +269 -0
  205. package/src/templates/resource-service.ts +96 -0
  206. package/src/templates/resource.ts +86 -61
  207. package/src/templates/route.ts +41 -35
  208. package/src/templates/scaffold-api.ts +55 -1
  209. package/src/templates/scaffold-app.ts +177 -53
  210. package/src/templates/scaffold-auth.ts +83 -19
  211. package/src/templates/scaffold-browser-client.ts +90 -0
  212. package/src/templates/scaffold-container-compose.ts +10 -1
  213. package/src/templates/scaffold-container.ts +20 -2
  214. package/src/templates/scaffold-dashboard-bare.ts +64 -2
  215. package/src/templates/scaffold-dashboard-example.ts +124 -31
  216. package/src/templates/scaffold-dashboard-shared.ts +1 -20
  217. package/src/templates/scaffold-dashboard.ts +1 -2
  218. package/src/templates/scaffold-db-client.ts +144 -0
  219. package/src/templates/scaffold-db-package.ts +90 -8
  220. package/src/templates/scaffold-docs.ts +50 -5
  221. package/src/templates/scaffold-guards.ts +54 -11
  222. package/src/templates/scaffold-helm-fences.ts +76 -0
  223. package/src/templates/scaffold-helm-templates.ts +32 -7
  224. package/src/templates/scaffold-helm.ts +31 -1
  225. package/src/templates/scaffold-http.ts +9 -7
  226. package/src/templates/scaffold-repo.ts +61 -3
  227. package/src/templates/scaffold-roles.ts +56 -9
  228. package/src/templates/scaffold-shell.ts +74 -105
  229. package/src/templates/scaffold-site.ts +45 -19
  230. package/src/templates/scaffold-ui-package.ts +18 -0
  231. package/src/templates/slice-foundation.ts +46 -8
  232. package/src/test-counts.ts +14 -2
  233. package/src/test-ignores.ts +25 -0
  234. package/src/test-passes.ts +0 -15
  235. package/src/test-select.ts +5 -14
  236. package/src/test-shards.ts +41 -93
  237. package/src/test-workers.ts +26 -4
  238. package/src/theme-boot.ts +6 -31
  239. package/src/transport-calls.ts +105 -0
  240. package/src/trusted-hops-error.ts +17 -0
  241. package/src/unscanned-admin.ts +54 -0
  242. package/src/unthrown-codes.ts +26 -0
  243. package/src/verify-checks.ts +43 -53
  244. package/src/verify-coverage-run.ts +190 -0
  245. package/src/verify-deadline.ts +353 -0
  246. package/src/verify-floor.ts +36 -9
  247. package/src/verify-merge.ts +171 -45
  248. package/src/verify-part-step.ts +90 -0
  249. package/src/verify-progress.ts +25 -0
  250. package/src/verify-role-load-probe.ts +54 -0
  251. package/src/verify-role-load.ts +148 -0
  252. package/src/verify-run.ts +116 -37
  253. package/src/verify-shard.ts +33 -11
  254. package/src/verify-stalled.ts +152 -0
  255. package/src/verify-step.ts +42 -1
  256. package/src/verify-tests.ts +83 -8
  257. package/src/workspace-checks.ts +50 -8
  258. package/src/workspace-dep-edit.ts +127 -0
  259. package/src/build-errors.ts +0 -54
  260. package/src/cmd-dev-fixture.ts +0 -204
  261. package/src/dev-live-fixture.ts +0 -122
  262. package/src/policy-fixture.ts +0 -78
  263. package/src/realtime-browser-probe-fixture.ts +0 -9
  264. package/src/role-start-fixture.ts +0 -78
  265. package/src/scaffold-fixture.ts +0 -160
  266. package/src/templates/island-fixture.ts +0 -76
  267. package/src/verify-errors.ts +0 -49
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
  }
@@ -21,6 +21,7 @@ import type { PageSpeculation } from './page-speculation';
21
21
  import { loadSpeculation, pageSpeculation } from './page-speculation';
22
22
  import { pageSync } from './page-sync';
23
23
  import { loadPwaArtifacts } from './pwa-artifacts';
24
+ import { adminMountRoutes } from './runtime-admin';
24
25
  import { assetRoutes } from './runtime-assets';
25
26
  import { appRoutes } from './runtime-render';
26
27
  import { servedStorage, storageRoutes } from './runtime-storage';
@@ -150,6 +151,14 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
150
151
  ...sync.routes,
151
152
  ...navigation.routes,
152
153
  ...pagePostRoutes(),
154
+ // The app's generated admin, when it declared one (`defineAdmin`): every screen under its base
155
+ // path, served through the same document builder as the pages below. Nothing for an app with
156
+ // no admin — the registry is asked, the package is not imported.
157
+ ...adminMountRoutes({
158
+ buildId: input.buildId,
159
+ themeHead: theme.head,
160
+ ...(origin === undefined ? {} : { origin }),
161
+ }),
153
162
  ...appRoutes({
154
163
  buildId: input.buildId,
155
164
  ...(input.isr === undefined ? {} : { isr: input.isr }),
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,39 @@
1
+ // `x doctor`'s coverage listing: every file the app's coverage floor does not see, and the reason
2
+ // `x.verify.json` gives for each. A LISTING, never a finding: an `exclude` with its `why` is a
3
+ // decision the app made in a committed file — printing it is what keeps the decision in view, and
4
+ // a red `x doctor` over it would be the framework overruling what it asked to be written down.
5
+
6
+ import type { CoverageExclude } from './coverage-floor';
7
+ import { msg } from './messages';
8
+ import type { CommandResult, JsonValue } from './output';
9
+ import { readVerifyFloor } from './verify-floor';
10
+
11
+ /** The I/O half: the excludes this app's `x.verify.json` states. None when it states no floor. */
12
+ export async function coverageExcludesProbe(root: string): Promise<readonly CoverageExclude[]> {
13
+ return (await readVerifyFloor(root))?.coverage?.exclude ?? [];
14
+ }
15
+
16
+ /**
17
+ * The listing, on a result `x doctor` already decided: `data.coverage.exclude` for a machine, one
18
+ * line per entry for a person. `ok`, the summary and the findings are returned as they came.
19
+ */
20
+ export function withCoverageExcludes(
21
+ result: CommandResult,
22
+ exclude: readonly CoverageExclude[],
23
+ ): CommandResult {
24
+ const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
25
+ const listed: JsonValue = exclude.map(({ glob, why }) => ({ glob, why }));
26
+ return {
27
+ ...result,
28
+ data: { ...(data as Record<string, JsonValue>), coverage: { exclude: listed } },
29
+ ...(exclude.length === 0
30
+ ? {}
31
+ : {
32
+ lines: [
33
+ ...(result.lines ?? []),
34
+ msg('cli.doctor.coverageExclude', { count: exclude.length }),
35
+ ...exclude.map(({ glob, why }) => ` ${glob} — ${why}`),
36
+ ],
37
+ }),
38
+ };
39
+ }
@@ -0,0 +1,54 @@
1
+ // `x doctor`'s guard listing: the guards the framework ships that this app's `guards/` does not
2
+ // hold, each with the command that adds it. A LISTING, never a finding: every shipped guard says
3
+ // "delete this file to drop the rule", so an app without one may have decided — and a red
4
+ // `x doctor` over a decision would be the framework overruling the convention it handed over.
5
+
6
+ import { GUARD_DIR, guardPaths } from './guards';
7
+ import { msg } from './messages';
8
+ import type { CommandResult, JsonValue } from './output';
9
+ import { SHIPPED_GUARD_NAMES } from './templates/scaffold-guards';
10
+
11
+ export interface MissingGuard {
12
+ readonly name: string;
13
+ /** The command that writes it, with its test. Then `x verify` says what it finds. */
14
+ readonly add: string;
15
+ }
16
+
17
+ /** The rule, pure: which shipped names have no file among the guards an app holds. */
18
+ export function missingShippedGuards(present: readonly string[]): readonly MissingGuard[] {
19
+ const held = new Set(present.map((path) => path.replace(/\.tsx?$/, '')));
20
+ return SHIPPED_GUARD_NAMES.filter((name) => !held.has(`${GUARD_DIR}/${name}`)).map((name) => ({
21
+ name,
22
+ add: `x g guard ${name}`,
23
+ }));
24
+ }
25
+
26
+ /** The I/O half: the same enumeration the `boundaries` step runs, so the two cannot disagree. */
27
+ export async function shippedGuardsProbe(root: string): Promise<readonly MissingGuard[]> {
28
+ return missingShippedGuards(await guardPaths(root));
29
+ }
30
+
31
+ /**
32
+ * The listing, on a result `x doctor` already decided: `data.guards.missing` for a machine, one
33
+ * line per command for a person. `ok`, the summary and the findings are returned as they came.
34
+ */
35
+ export function withGuardListing(
36
+ result: CommandResult,
37
+ missing: readonly MissingGuard[],
38
+ ): CommandResult {
39
+ const data = typeof result.data === 'object' && result.data !== null ? result.data : {};
40
+ const listed: JsonValue = missing.map(({ name, add }) => ({ name, add }));
41
+ return {
42
+ ...result,
43
+ data: { ...(data as Record<string, JsonValue>), guards: { missing: listed } },
44
+ ...(missing.length === 0
45
+ ? {}
46
+ : {
47
+ lines: [
48
+ ...(result.lines ?? []),
49
+ msg('cli.doctor.guards', { count: missing.length }),
50
+ ...missing.map(({ add }) => ` ${add}`),
51
+ ],
52
+ }),
53
+ };
54
+ }
@@ -0,0 +1,132 @@
1
+ // `x doctor`'s sealed-column checks: the columns the app declared `.sealed()`, against the master
2
+ // keys this environment declares. Its own file for `doctor-offline.ts`'s reason — answering needs
3
+ // the app's entities and the committed secrets, and `cmd-doctor.ts`'s job is the probe. The split
4
+ // is the same too: reaching the disk is `sealedKeysProbe`, deciding is `sealedKeyFindings`.
5
+
6
+ import {
7
+ ERROR_DOCS_URL,
8
+ findMasterKey,
9
+ readSecretsFile,
10
+ SECRETS_RETIRED_KEYS_ENV,
11
+ sealKeyIds,
12
+ secretsFileExists,
13
+ } from '@ultimat3/core';
14
+ import { registeredEntities, sealedFields } from '@ultimat3/entity';
15
+ import { loadApp } from './app-load';
16
+ import type { Finding } from './output';
17
+ import { quoteArg } from './shell-quote';
18
+
19
+ export interface SealedColumnFact {
20
+ readonly entity: string;
21
+ /** Property key, as the entity declares it. */
22
+ readonly column: string;
23
+ readonly lookup: boolean;
24
+ }
25
+
26
+ export interface SealedKeysFact {
27
+ /**
28
+ * Every sealed column the app declares, or `undefined` when the app would not load — a short
29
+ * registry reads as "no sealed columns", which is the false green `appEntities` exists to refuse.
30
+ */
31
+ readonly columns: readonly SealedColumnFact[] | undefined;
32
+ /** The key ids in force, or `undefined` when no usable master key was found. */
33
+ readonly keys: { readonly current: string; readonly retired: readonly string[] } | undefined;
34
+ }
35
+
36
+ const named = (columns: readonly SealedColumnFact[]): string =>
37
+ columns.map((one) => `${one.entity}.${one.column}`).join(', ');
38
+
39
+ /**
40
+ * The rule, pure. Two conditions, and an app with no sealed column is judged by neither.
41
+ *
42
+ * No key: every write to a sealed column is `X_SEAL_KEY_MISSING` — core's own code and its own
43
+ * fix, reported here before the first request finds out.
44
+ *
45
+ * A retired key still declared: a rotation whose re-seal has not finished. That is a live-system
46
+ * state and not a fault, but it has a cost that ends only when someone acts — a `lookup` column
47
+ * matches through `in (…)` over every key and its `.unique()` holds per key — so it is a finding
48
+ * until the retired key is dropped.
49
+ */
50
+ export function sealedKeyFindings(fact: SealedKeysFact): readonly Finding[] {
51
+ const columns = fact.columns;
52
+ if (columns === undefined || columns.length === 0) return [];
53
+ if (fact.keys === undefined) {
54
+ return [
55
+ {
56
+ code: 'X_SEAL_KEY_MISSING',
57
+ cause: `${named(columns)} ${columns.length === 1 ? 'is' : 'are'} sealed and this environment has no usable master key, so every write to ${columns.length === 1 ? 'it' : 'them'} is refused`,
58
+ fix: 'x secrets init # or, where the key already exists: export ULTIMATE_SECRETS_KEY="$(cat .secrets.key)"',
59
+ docs: ERROR_DOCS_URL,
60
+ },
61
+ ];
62
+ }
63
+ const [oldest] = fact.keys.retired;
64
+ if (oldest === undefined) return [];
65
+ const lookups = columns.filter((one) => one.lookup);
66
+ const weakened =
67
+ lookups.length === 0
68
+ ? ''
69
+ : ` — ${named(lookups)} ${lookups.length === 1 ? 'is a lookup column' : 'are lookup columns'}, so until then equality reads through in (…) over every key and .unique() holds per key`;
70
+ return [
71
+ {
72
+ code: 'X_SEAL_RESEAL_PENDING',
73
+ cause: `${named(columns)} may still hold values sealed under retired master key(s) ${fact.keys.retired.join(', ')}: a rotation whose re-seal has not finished${weakened}`,
74
+ // The id is 16 hex characters computed here from a key, never text read from a file.
75
+ fix: `x secrets rotate --drop ${quoteArg(oldest)} --json # AFTER a backfill() has rewritten every row — update(id, { column: row.column }) seals under the current key`,
76
+ docs: ERROR_DOCS_URL,
77
+ },
78
+ ];
79
+ }
80
+
81
+ /**
82
+ * The retired ring as a booted process would see it: the real environment first, the committed
83
+ * file second — `installSecrets()`'s own order, without installing anything into this process.
84
+ */
85
+ async function retiredRing(
86
+ root: string,
87
+ env: Readonly<Record<string, string | undefined>>,
88
+ ): Promise<string | undefined> {
89
+ const declared = env[SECRETS_RETIRED_KEYS_ENV];
90
+ if (declared !== undefined && declared !== '') return declared;
91
+ const key = findMasterKey(root, { ...env });
92
+ if (key === undefined || !secretsFileExists(root)) return undefined;
93
+ return (await readSecretsFile(root, key))[SECRETS_RETIRED_KEYS_ENV];
94
+ }
95
+
96
+ /** The app's sealed columns, or `undefined` when it would not load. */
97
+ export type DeclaredSealedColumns = (
98
+ root: string,
99
+ ) => Promise<readonly SealedColumnFact[] | undefined>;
100
+
101
+ /** `appEntities`' rule (`schema-drift.ts`): a registry short one module is not "none declared". */
102
+ const appSealedColumns: DeclaredSealedColumns = async (root) => {
103
+ const app = await loadApp(root);
104
+ if (app.findings.length > 0) return undefined;
105
+ return registeredEntities().flatMap((entry) =>
106
+ // `core` is absent on a hand-registered entry, which declares no columns to seal.
107
+ (entry.core === undefined ? [] : sealedFields(entry.core)).map((field) => ({
108
+ entity: entry.name,
109
+ column: field.property,
110
+ lookup: field.lookup,
111
+ })),
112
+ );
113
+ };
114
+
115
+ export async function sealedKeysProbe(
116
+ root: string,
117
+ env: Readonly<Record<string, string | undefined>> = process.env,
118
+ declared: DeclaredSealedColumns = appSealedColumns,
119
+ ): Promise<SealedKeysFact> {
120
+ const columns = await declared(root);
121
+ // No sealed column means no question about keys, and no reason to open the secrets file.
122
+ if (columns === undefined || columns.length === 0) return { columns, keys: undefined };
123
+ try {
124
+ const ring = await retiredRing(root, env);
125
+ const source = { ...env, ...(ring === undefined ? {} : { [SECRETS_RETIRED_KEYS_ENV]: ring }) };
126
+ return { columns, keys: await sealKeyIds({ root, env: source }) };
127
+ } catch {
128
+ // Whatever refused — no key, a malformed one, a secrets file this key does not open — the
129
+ // answer for a sealed column is the same: there is no key ring to seal under.
130
+ return { columns, keys: undefined };
131
+ }
132
+ }
@@ -0,0 +1,82 @@
1
+ // Whether importing an app module would bring a DOCUMENT module into this process: a component
2
+ // (`.tsx`) or a stylesheet. A role that renders nothing asks this before each import
3
+ // (`role-load.ts`), so a worker never compiles the app's Sass or evaluates its pages to learn that
4
+ // they register no job.
5
+
6
+ import { resolvedImports } from './module-imports';
7
+ import { hasPathSegment } from './path-segments';
8
+
9
+ /** A component or a stylesheet: what only a process that renders documents evaluates. */
10
+ const DOCUMENT = /\.(?:[jt]sx|s[ac]ss|css)$/;
11
+
12
+ export const isDocumentModule = (path: string): boolean => DOCUMENT.test(path);
13
+
14
+ /** An installed framework package, under either linker: `…/node_modules/@ultimat3/<pkg>/…`. */
15
+ const FRAMEWORK_SCOPE = '/node_modules/@ultimat3/';
16
+
17
+ export interface DocumentGraph {
18
+ /**
19
+ * True when importing `absolute` would evaluate a document module this process has not already
20
+ * loaded. Errs toward `false`: a specifier that will not resolve and a file that will not parse
21
+ * contribute no edge, so the caller imports the module and the import itself reports the fault.
22
+ */
23
+ reachesDocument(absolute: string): Promise<boolean>;
24
+ }
25
+
26
+ /**
27
+ * `loaded` answers whether a module is already in this process — a module the API index imported
28
+ * costs nothing more, whatever it reaches, so the walk stops there. Injected, so a test decides it.
29
+ */
30
+ export function createDocumentGraph(loaded: (absolute: string) => boolean): DocumentGraph {
31
+ const edges = new Map<string, readonly string[]>();
32
+ const verdicts = new Map<string, boolean>();
33
+
34
+ /**
35
+ * The files importing `path` evaluates, when the walk follows them: the app's own modules and
36
+ * the framework's — `@ultimat3/ui` is a barrel of components, and an app module importing it
37
+ * brings every one of them and their Sass. A third-party package is not walked: it ships no
38
+ * component this framework's loader compiles. A lazy import's target is no edge at all.
39
+ */
40
+ async function importsOf(path: string): Promise<readonly string[]> {
41
+ const known = edges.get(path);
42
+ if (known !== undefined) return known;
43
+ // Unreadable: no edge, and the caller's own import is what reports it.
44
+ const source = await Bun.file(path)
45
+ .text()
46
+ .catch(() => '');
47
+ const found = resolvedImports(path, source, 'evaluated').filter(
48
+ (target) => !hasPathSegment(target, 'node_modules') || target.includes(FRAMEWORK_SCOPE),
49
+ );
50
+ edges.set(path, found);
51
+ return found;
52
+ }
53
+
54
+ return {
55
+ async reachesDocument(absolute: string): Promise<boolean> {
56
+ const known = verdicts.get(absolute);
57
+ if (known !== undefined) return known;
58
+ // The whole reachable set, never a per-node recursion: inside an import cycle a node's own
59
+ // answer depends on one still being computed, and a `false` memoised there is wrong.
60
+ const seen = new Set<string>();
61
+ const stack = [absolute];
62
+ let reaches = false;
63
+ while (stack.length > 0 && !reaches) {
64
+ const path = stack.pop();
65
+ if (path === undefined || seen.has(path)) continue;
66
+ seen.add(path);
67
+ if (verdicts.get(path) === true || (isDocumentModule(path) && !loaded(path))) {
68
+ reaches = true;
69
+ break;
70
+ }
71
+ // Already here, or already cleared: nothing below it is new to this process.
72
+ if (isDocumentModule(path) || loaded(path) || verdicts.get(path) === false) continue;
73
+ stack.push(...(await importsOf(path)));
74
+ }
75
+ // A clean walk clears everything it visited — each one's reachable set is inside this one's.
76
+ // A walk that found a document proves it for the start alone.
77
+ if (reaches) verdicts.set(absolute, true);
78
+ else for (const path of seen) verdicts.set(path, false);
79
+ return reaches;
80
+ },
81
+ };
82
+ }
@@ -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. */