@ultimat3/cli 21.0.0 → 22.1.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 (236) hide show
  1. package/CLAUDE.md +84 -1650
  2. package/README.md +40 -2
  3. package/package.json +30 -30
  4. package/src/api-registration.ts +124 -0
  5. package/src/app-artifacts.ts +39 -0
  6. package/src/app-boundaries.ts +5 -10
  7. package/src/app-load.ts +37 -5
  8. package/src/app-openapi.ts +27 -0
  9. package/src/app-permissions.ts +0 -0
  10. package/src/app-root.ts +1 -1
  11. package/src/async-pages.ts +32 -0
  12. package/src/bin.ts +7 -1
  13. package/src/boundary-findings.ts +36 -0
  14. package/src/browser-launcher-fake-html.ts +80 -0
  15. package/src/browser-launcher-fake.ts +165 -0
  16. package/src/browser-launcher-port.ts +159 -0
  17. package/src/browser-launcher.ts +26 -133
  18. package/src/budgets.ts +11 -1
  19. package/src/cdp-shot-a11y.ts +77 -0
  20. package/src/cdp-shot-clock.ts +14 -0
  21. package/src/cdp-shot-driver.ts +150 -0
  22. package/src/cdp-shot-element.ts +147 -0
  23. package/src/cdp-shot-errors.ts +62 -0
  24. package/src/cdp-shot-keys.ts +152 -0
  25. package/src/cdp-shot-page.ts +230 -0
  26. package/src/cdp-shot-watch.ts +241 -0
  27. package/src/cmd-affected-spec.ts +24 -0
  28. package/src/cmd-affected.ts +3 -19
  29. package/src/cmd-build-spec.ts +16 -0
  30. package/src/cmd-build.ts +46 -20
  31. package/src/cmd-ci-spec.ts +24 -0
  32. package/src/cmd-ci.ts +17 -21
  33. package/src/cmd-db-branch.ts +2 -2
  34. package/src/cmd-db-spec.ts +99 -0
  35. package/src/cmd-db.ts +15 -94
  36. package/src/cmd-deploy-helm.ts +136 -0
  37. package/src/cmd-deploy-spec.ts +40 -0
  38. package/src/cmd-deploy.ts +111 -43
  39. package/src/cmd-dev-spec.ts +25 -0
  40. package/src/cmd-dev.ts +55 -36
  41. package/src/cmd-docs-spec.ts +16 -0
  42. package/src/cmd-docs.ts +2 -11
  43. package/src/cmd-doctor-spec.ts +21 -0
  44. package/src/cmd-doctor.ts +4 -18
  45. package/src/cmd-env-spec.ts +18 -0
  46. package/src/cmd-env.ts +4 -13
  47. package/src/cmd-errors-spec.ts +23 -0
  48. package/src/cmd-errors.ts +3 -17
  49. package/src/cmd-fix-spec.ts +18 -0
  50. package/src/cmd-fix.ts +4 -13
  51. package/src/cmd-generate-spec.ts +31 -0
  52. package/src/cmd-generate.ts +46 -42
  53. package/src/cmd-i18n-spec.ts +16 -0
  54. package/src/cmd-i18n.ts +7 -14
  55. package/src/cmd-jobs-spec.ts +56 -0
  56. package/src/cmd-jobs.ts +3 -50
  57. package/src/cmd-manifest-spec.ts +15 -0
  58. package/src/cmd-manifest.ts +19 -20
  59. package/src/cmd-mcp-spec.ts +21 -0
  60. package/src/cmd-mcp.ts +2 -15
  61. package/src/cmd-new-spec.ts +33 -0
  62. package/src/cmd-new.ts +38 -33
  63. package/src/cmd-policy-spec.ts +13 -0
  64. package/src/cmd-policy.ts +2 -9
  65. package/src/cmd-pr-spec.ts +39 -0
  66. package/src/cmd-pr.ts +5 -65
  67. package/src/cmd-registries-spec.ts +32 -0
  68. package/src/cmd-registries.ts +4 -25
  69. package/src/cmd-routes-spec.ts +12 -0
  70. package/src/cmd-routes.ts +2 -7
  71. package/src/cmd-secrets-spec.ts +19 -0
  72. package/src/cmd-secrets.ts +22 -34
  73. package/src/cmd-shot-island.ts +12 -13
  74. package/src/cmd-shot-spec.ts +53 -0
  75. package/src/cmd-shot.ts +11 -60
  76. package/src/cmd-tasks-spec.ts +21 -0
  77. package/src/cmd-tasks.ts +2 -16
  78. package/src/cmd-test-spec.ts +54 -0
  79. package/src/cmd-test.ts +4 -49
  80. package/src/cmd-verify-spec.ts +28 -0
  81. package/src/cmd-verify.ts +3 -23
  82. package/src/db-seed.ts +4 -2
  83. package/src/dev-dashboard.ts +4 -4
  84. package/src/dev-lock.ts +18 -2
  85. package/src/dev-port.ts +21 -0
  86. package/src/dev-route-table.ts +3 -3
  87. package/src/dispatch.ts +12 -5
  88. package/src/document-styles.ts +1 -1
  89. package/src/drift.ts +4 -18
  90. package/src/error-catalog.ts +16 -9
  91. package/src/error-codes.ts +28 -30
  92. package/src/error-pages.ts +19 -7
  93. package/src/errors.ts +14 -0
  94. package/src/favicon.ts +2 -2
  95. package/src/fix-imports.ts +1 -1
  96. package/src/fix-scan.ts +2 -9
  97. package/src/flag-reads.ts +1 -2
  98. package/src/foreign-text.ts +36 -0
  99. package/src/framework-schema.ts +5 -6
  100. package/src/generate-feature.ts +42 -0
  101. package/src/generate-files.ts +7 -3
  102. package/src/generate-grants.ts +83 -0
  103. package/src/generate-kinds.ts +56 -4
  104. package/src/i18n-index.ts +59 -10
  105. package/src/icon-assets.ts +1 -1
  106. package/src/image-prepare.ts +14 -0
  107. package/src/import-scan.ts +63 -0
  108. package/src/index.ts +67 -348
  109. package/src/invocation-flags.ts +26 -0
  110. package/src/island-bundle.ts +6 -1
  111. package/src/island-capture.ts +6 -6
  112. package/src/island-harness-script.ts +6 -2
  113. package/src/island-realtime.ts +15 -8
  114. package/src/island-shot.ts +5 -0
  115. package/src/island-store.ts +131 -0
  116. package/src/island-verdict.ts +1 -1
  117. package/src/job-registration.ts +42 -0
  118. package/src/jobs-driver.ts +2 -2
  119. package/src/load-findings.ts +51 -0
  120. package/src/mcp-db-target.ts +1 -1
  121. package/src/mcp-errors.ts +28 -27
  122. package/src/mcp-host.ts +27 -12
  123. package/src/mcp-ui-diff.ts +27 -0
  124. package/src/mcp-ui-inspect.ts +4 -4
  125. package/src/mcp-ui-interact.ts +21 -15
  126. package/src/mcp-ui.ts +23 -17
  127. package/src/measure-database.ts +73 -0
  128. package/src/measure-paths.ts +74 -0
  129. package/src/measure-scope.ts +74 -0
  130. package/src/messages.ts +1 -3
  131. package/src/metrics-endpoint.ts +1 -1
  132. package/src/otlp-export.ts +1 -1
  133. package/src/output.ts +6 -0
  134. package/src/permission-grants.ts +86 -0
  135. package/src/prerender-out.ts +25 -0
  136. package/src/prerender.ts +121 -102
  137. package/src/pwa-artifacts.ts +3 -3
  138. package/src/reexport-manifest.ts +2 -1
  139. package/src/registry.ts +80 -56
  140. package/src/role-realtime.ts +36 -0
  141. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  142. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  143. package/src/role-start-types.ts +112 -0
  144. package/src/{dev-roles.ts → role-start.ts} +42 -115
  145. package/src/{dev-sync.ts → role-sync.ts} +22 -8
  146. package/src/root-env.ts +67 -0
  147. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  148. package/src/{dev-services.ts → runtime-bindings.ts} +39 -16
  149. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  150. package/src/runtime-jobs.ts +87 -0
  151. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +18 -5
  152. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  153. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  154. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  155. package/src/runtime-realtime.ts +55 -0
  156. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  157. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  158. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  159. package/src/scaffold-fixture.ts +28 -6
  160. package/src/scaffold-typecheck.ts +6 -3
  161. package/src/schema-drift.ts +7 -1
  162. package/src/script-csp.ts +5 -2
  163. package/src/secrets-rotation.ts +59 -0
  164. package/src/serve-boot.ts +192 -0
  165. package/src/serve-drain.ts +24 -0
  166. package/src/serve-entry.ts +6 -0
  167. package/src/serve-env.ts +116 -0
  168. package/src/serve-types.ts +55 -0
  169. package/src/serve.ts +44 -347
  170. package/src/shot-server.ts +2 -2
  171. package/src/shot-settle.ts +10 -1
  172. package/src/shot-theme.ts +3 -3
  173. package/src/shot-verdict.ts +16 -7
  174. package/src/signal-shred.ts +27 -0
  175. package/src/solid-loader.ts +26 -2
  176. package/src/static-report.ts +8 -1
  177. package/src/sync-url.ts +21 -1
  178. package/src/templates/action.ts +30 -16
  179. package/src/templates/entity.ts +12 -7
  180. package/src/templates/index.ts +1 -1
  181. package/src/templates/job.ts +10 -7
  182. package/src/templates/policy.ts +20 -2
  183. package/src/templates/resource-create.ts +127 -0
  184. package/src/templates/resource-form-island.ts +49 -12
  185. package/src/templates/resource.ts +10 -4
  186. package/src/templates/scaffold-app.ts +4 -1
  187. package/src/templates/scaffold-auth.ts +3 -1
  188. package/src/templates/scaffold-container-compose.ts +184 -0
  189. package/src/templates/scaffold-container.ts +24 -159
  190. package/src/templates/scaffold-dashboard-example.ts +2 -2
  191. package/src/templates/scaffold-db-package.ts +16 -3
  192. package/src/templates/scaffold-demo-org.ts +41 -0
  193. package/src/templates/scaffold-entries.ts +1 -1
  194. package/src/templates/scaffold-env.ts +6 -0
  195. package/src/templates/scaffold-helm-templates.ts +66 -7
  196. package/src/templates/scaffold-helm.ts +27 -5
  197. package/src/templates/scaffold-i18n.ts +15 -10
  198. package/src/templates/scaffold-repo.ts +13 -10
  199. package/src/templates/scaffold-roles.ts +38 -10
  200. package/src/templates/slice-foundation.ts +1 -1
  201. package/src/templates/wrap.ts +4 -1
  202. package/src/test-passes.ts +2 -1
  203. package/src/test-workers.ts +26 -0
  204. package/src/ts-scan.ts +3 -6
  205. package/src/tsconfig-references.ts +1 -2
  206. package/src/verify-checks.ts +41 -39
  207. package/src/verify-e2e.ts +10 -7
  208. package/src/verify-run.ts +11 -1
  209. package/src/verify-step.ts +3 -3
  210. package/src/verify-tests.ts +1 -26
  211. package/src/verify-typecheck.ts +28 -0
  212. package/src/web-binding.ts +2 -2
  213. package/src/workspace-graph.ts +10 -33
  214. package/src/cdp-browser.ts +0 -94
  215. package/src/cdp-connection.ts +0 -247
  216. package/src/cdp-e2e-page.ts +0 -180
  217. package/src/cdp-e2e-session.ts +0 -199
  218. package/src/cdp-errors.ts +0 -56
  219. package/src/cdp-launch.ts +0 -193
  220. package/src/cdp-offline-script.ts +0 -73
  221. package/src/cdp-pipe.ts +0 -77
  222. package/src/e2e-app.ts +0 -103
  223. package/src/e2e-browser-handle.ts +0 -55
  224. package/src/e2e-dom-fixture.ts +0 -117
  225. package/src/e2e-driver.ts +0 -117
  226. package/src/e2e-errors.ts +0 -117
  227. package/src/e2e-evaluate.ts +0 -156
  228. package/src/e2e-locator.ts +0 -86
  229. package/src/e2e-page.ts +0 -153
  230. package/src/e2e-preload.ts +0 -64
  231. package/src/e2e-probe.ts +0 -23
  232. package/src/e2e-selection.ts +0 -182
  233. package/src/e2e-spawn.ts +0 -169
  234. package/src/measurement-actor.ts +0 -26
  235. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
  236. /package/src/{dev-render.ts → runtime-render.ts} +0 -0
@@ -0,0 +1,87 @@
1
+ // `jobs.queues`, `jobs.concurrency` and `jobs.visibilityTimeoutMs`, read out of the app's own
2
+ // `app.config.ts` for the `worker` role — the sibling of `runtime-realtime.ts`, and structural
3
+ // for its reason. Until 22.0.0 `createWorker` was called with none of them: every worker served
4
+ // `default` at its own concurrency whatever the config said, so a `mail` queue ran nothing.
5
+
6
+ // why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
7
+ // `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
8
+ import { join } from 'node:path';
9
+ import { registeredJobs, type WorkerOptions } from '@ultimat3/jobs';
10
+ import { APP_CONFIG_EXPORT } from './app-auth';
11
+ import { APP_CONFIG_FILE } from './app-root';
12
+
13
+ /** What the worker takes from the config. An absent number keeps the worker's own default. */
14
+ export interface WorkerConfig {
15
+ readonly queues: readonly string[];
16
+ readonly concurrency: number | undefined;
17
+ readonly visibilityTimeoutMs: number | undefined;
18
+ }
19
+
20
+ const NO_WORKER_CONFIG: WorkerConfig = Object.freeze({
21
+ queues: [],
22
+ concurrency: undefined,
23
+ visibilityTimeoutMs: undefined,
24
+ });
25
+
26
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
27
+ typeof value === 'object' && value !== null;
28
+
29
+ /**
30
+ * A number is handed on AS WRITTEN, never screened here: `createWorker`'s `resolveWorkerTimings`
31
+ * is the one refusal for a non-finite knob, and core's `defineConfig` screens both counts first.
32
+ */
33
+ const numberOf = (value: unknown): number | undefined =>
34
+ typeof value === 'number' ? value : undefined;
35
+
36
+ export async function loadWorkerConfig(root: string): Promise<WorkerConfig> {
37
+ const path = join(root, APP_CONFIG_FILE);
38
+ if (!(await Bun.file(path).exists())) return NO_WORKER_CONFIG;
39
+ const module = (await import(path)) as Record<string, unknown>;
40
+ const config = module[APP_CONFIG_EXPORT];
41
+ const jobs = isRecord(config) ? config['jobs'] : undefined;
42
+ if (!isRecord(jobs)) return NO_WORKER_CONFIG;
43
+ const queues = Array.isArray(jobs['queues'])
44
+ ? jobs['queues'].filter((queue): queue is string => typeof queue === 'string' && queue !== '')
45
+ : [];
46
+ return {
47
+ queues,
48
+ concurrency: numberOf(jobs['concurrency']),
49
+ visibilityTimeoutMs: numberOf(jobs['visibilityTimeoutMs']),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * The queues a worker serves: the configured ones, then every queue a registered job is enqueued
55
+ * on. The union and not the list, decided 2026-09-23: `x new` and the deployed demo configure
56
+ * `['<app>-default']` while a `job()` naming no queue lands on `default`, so obeying the list alone
57
+ * would leave every such job queued for ever — the silent direction. `undefined` when both are
58
+ * empty, which is `createWorker`'s own `['default']`.
59
+ */
60
+ export function workerQueuesFor(
61
+ configured: readonly string[],
62
+ registered: readonly string[],
63
+ ): readonly string[] | undefined {
64
+ const served = [...new Set([...configured, ...registered])];
65
+ return served.length === 0 ? undefined : served;
66
+ }
67
+
68
+ type WorkerConfigOptions = Pick<WorkerOptions, 'queues' | 'concurrency' | 'visibilityTimeoutMs'>;
69
+
70
+ /**
71
+ * The `createWorker` options the config supplies, read at START so every job the app registered
72
+ * on import is counted. A key the config leaves unset is omitted, never passed as `undefined`, so
73
+ * the worker's own default applies (`exactOptionalPropertyTypes`).
74
+ */
75
+ export function workerOptionsFor(config: WorkerConfig | undefined): WorkerConfigOptions {
76
+ const queues = workerQueuesFor(
77
+ config?.queues ?? [],
78
+ registeredJobs().map((handle) => handle.queue),
79
+ );
80
+ return {
81
+ ...(queues === undefined ? {} : { queues }),
82
+ ...(config?.concurrency === undefined ? {} : { concurrency: config.concurrency }),
83
+ ...(config?.visibilityTimeoutMs === undefined
84
+ ? {}
85
+ : { visibilityTimeoutMs: config.visibilityTimeoutMs }),
86
+ };
87
+ }
@@ -4,17 +4,22 @@
4
4
  // subscription under `x dev` took its snapshot and then heard nothing, so every `--live` query in
5
5
  // every scaffolded app was dead in development — which is where an author first tries one.
6
6
  //
7
- // The bridge is `@ultimat3/testing`'s `startLiveReplicator`, the same in-process row observer the
7
+ // The bridge is `@ultimat3/realtime/server`'s `startLiveReplicator`, the same in-process row observer the
8
8
  // framework's own live tests run on: a repository write in THIS process becomes a `ChangeEvent`
9
9
  // shaped exactly as the WAL decoder shapes one, fanned into the node's registry. Its honest bound
10
10
  // is stated there and repeated here — a write another process makes is invisible — and `x dev` is
11
11
  // the one boot where that bound holds by construction: every role runs in this one process.
12
12
 
13
13
  import { logger, renderThrowable } from '@ultimat3/core';
14
- import type { LiveReplicator } from '@ultimat3/testing';
15
- import { startLiveReplicator } from '@ultimat3/testing';
16
- import type { ServiceMode } from './dev-services';
17
- import type { RunningSync } from './dev-sync';
14
+ // `@ultimat3/realtime/server`, never `@ultimat3/testing`: this module is on the production boot
15
+ // path (`serve.ts` → `role-start.ts` → here), and the test harness was 38 modules of every image.
16
+ import {
17
+ type LiveReplicator,
18
+ RealtimeTopologyError,
19
+ startLiveReplicator,
20
+ } from '@ultimat3/realtime/server';
21
+ import type { RunningSync } from './role-sync';
22
+ import type { ServiceMode } from './runtime-bindings';
18
23
 
19
24
  /** What feeds the sync node, said out loud on the boot line and in `--json`. */
20
25
  export type LiveFeed = 'in-process' | 'replication' | 'none';
@@ -31,6 +36,10 @@ export interface LiveFeedInput {
31
36
  readonly sync: RunningSync | null;
32
37
  /** The database's binding: `embedded` is PGlite, which has no log to decode. */
33
38
  readonly dbMode: ServiceMode;
39
+ /** The bus this process fans out on (`Transport.name`): `in-process` reaches no other process. */
40
+ readonly transport: string;
41
+ /** Whether the `replicator` role runs in THIS process. */
42
+ readonly replicatorHere: boolean;
34
43
  }
35
44
 
36
45
  /** The label the `x dev` boot line carries beside `db=`, `events=` and `storage=`. */
@@ -45,6 +54,10 @@ export const liveFeedLabel = (feed: LiveFeed): string => `live=${feed}`;
45
54
  export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFeed> {
46
55
  if (input.sync === null) return { feed: 'none', bridge: null, stop: () => undefined };
47
56
  if (input.dbMode !== 'embedded') {
57
+ // A decoder this node can hear: in this process, or on a bus other processes share. Neither
58
+ // is a sync node that reported `replication` while nothing could ever reach it.
59
+ if (input.transport === 'in-process' && !input.replicatorHere)
60
+ throw new RealtimeTopologyError();
48
61
  return { feed: 'replication', bridge: null, stop: () => undefined };
49
62
  }
50
63
  const bridge = await startLiveReplicator({
@@ -1,5 +1,5 @@
1
1
  // `notify.inboxReadRetentionMs` and `notify.inboxUnreadRetentionMs`, read out of the app's own
2
- // `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `dev-cache.ts`'s
2
+ // `app.config.ts`. The sibling of `app-auth.ts`'s `loadSignInPath` and `runtime-cache.ts`'s
3
3
  // `loadCacheTiers`, and structural for the same reason: `defineConfig` returns a plain object, so
4
4
  // a config that resolved through an older core simply has no `notify` section.
5
5
  //
@@ -23,8 +23,8 @@ import type { PostgresRateLimitStore } from '@ultimat3/http';
23
23
  import type { JobHandle, PurgeInput, PurgeTarget } from '@ultimat3/jobs';
24
24
  import { DEFAULT_PURGE_CRON, getJob, getTask, purge, task } from '@ultimat3/jobs';
25
25
  import { purgeNotifyDeliveries, purgeNotifyInbox } from '@ultimat3/notify';
26
- import type { InboxRetention } from './dev-notify-retention';
27
- import { NO_INBOX_RETENTION } from './dev-notify-retention';
26
+ import type { InboxRetention } from './runtime-notify-retention';
27
+ import { NO_INBOX_RETENTION } from './runtime-notify-retention';
28
28
 
29
29
  /** The durable queue key. Pinned, like every framework-owned job name — rows carry it. */
30
30
  export const PURGE_JOB_NAME = 'x.purge';
@@ -1,5 +1,5 @@
1
1
  // The database and the job queue, started together and released together. Split from
2
- // `dev-runtime.ts` because `x jobs` needs exactly this pair and nothing else — and because a
2
+ // `runtime-services.ts` because `x jobs` needs exactly this pair and nothing else — and because a
3
3
  // process that installs ambient accessors (`db()`, `jobDriver()`, the jobs facade, the event bus)
4
4
  // must have one place that takes them all back, or the next command in the same process inherits
5
5
  // a driver over a closed socket.
@@ -32,10 +32,10 @@ import {
32
32
  setJobDriver,
33
33
  setJobsFacade,
34
34
  } from '@ultimat3/jobs';
35
- import { attachReplica, type ReplicaEnv, replicaUrlFor } from './dev-replica';
36
- import type { DevServices } from './dev-services';
37
35
  import { applyFrameworkSchema } from './framework-schema';
36
+ import type { DevServices } from './runtime-bindings';
38
37
  import type { RuntimeOverrides } from './runtime-overrides';
38
+ import { attachReplica, type ReplicaEnv, replicaUrlFor } from './runtime-replica';
39
39
 
40
40
  /** Both embedded and external clients boot lazily and close explicitly. */
41
41
  export type DevDbClient = PgliteClient | PostgresClient;
@@ -51,7 +51,7 @@ export interface RunningQueue {
51
51
  readonly jobs: JobDriver;
52
52
  /**
53
53
  * The `x_outbox` store this boot installed behind `handle.enqueue()`. Returned because the
54
- * relay that drains it is a ROLE's decision, not the queue's — `dev-roles.ts` starts one.
54
+ * relay that drains it is a ROLE's decision, not the queue's — `role-start.ts` starts one.
55
55
  */
56
56
  readonly outbox: OutboxStore;
57
57
  /** The `x_job_events` bus a `step.waitForEvent` resumes from. Durable, not per-process. */
@@ -0,0 +1,55 @@
1
+ // `realtime.enabled`, `realtime.transport` and `realtime.urlEnv`, read out of the app's own
2
+ // `app.config.ts` for the boot. The sibling of `runtime-notify-retention.ts` and `runtime-cache.ts`, and
3
+ // structural for their reason: `startServices` holds no `AppConfig`, and `defineConfig` returns a
4
+ // plain object. Until 22.0.0 no boot read any of the three and `NATS_URL` alone decided the bus.
5
+
6
+ // why: Bun ships no path-joining API — `Object.keys(Bun)` has `file`, `write`, `Glob`,
7
+ // `pathToFileURL` and `fileURLToPath`, and nothing that joins a path.
8
+ import { join } from 'node:path';
9
+ import { ConfigInvalidError, type RealtimeConfig, type RealtimeTransport } from '@ultimat3/core';
10
+ import { APP_CONFIG_EXPORT } from './app-auth';
11
+ import { APP_CONFIG_FILE } from './app-root';
12
+
13
+ /** Core's `defaults()` for the section, restated as the answer for a config that names none. */
14
+ export const REALTIME_DEFAULTS: RealtimeConfig = Object.freeze({
15
+ enabled: true,
16
+ transport: 'memory',
17
+ urlEnv: undefined,
18
+ });
19
+
20
+ const TRANSPORTS: readonly RealtimeTransport[] = ['memory', 'nats'];
21
+
22
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
23
+ typeof value === 'object' && value !== null;
24
+
25
+ /** A transport this boot can build, or a refusal — never a guess at a bus. */
26
+ function transportOf(section: Record<string, unknown>): RealtimeTransport {
27
+ const value = section['transport'];
28
+ if (value === undefined) return REALTIME_DEFAULTS.transport;
29
+ const known = TRANSPORTS.find((transport) => transport === value);
30
+ if (known !== undefined) return known;
31
+ throw new ConfigInvalidError({
32
+ cause: `realtime.transport in ${APP_CONFIG_FILE} is not one of ${TRANSPORTS.join(', ')}`,
33
+ fix: `set realtime: { transport: 'memory' } in ${APP_CONFIG_FILE}, or { transport: 'nats', urlEnv: 'NATS_URL' } for more than one node`,
34
+ meta: { key: 'realtime.transport' },
35
+ });
36
+ }
37
+
38
+ /**
39
+ * The section as the boot obeys it. A key the file does not name keeps core's default, the same
40
+ * per-key merge `defineConfig` performs, so the boot and the app's own config object agree.
41
+ */
42
+ export async function loadRealtimeConfig(root: string): Promise<RealtimeConfig> {
43
+ const path = join(root, APP_CONFIG_FILE);
44
+ if (!(await Bun.file(path).exists())) return REALTIME_DEFAULTS;
45
+ const module = (await import(path)) as Record<string, unknown>;
46
+ const config = module[APP_CONFIG_EXPORT];
47
+ const section = isRecord(config) ? config['realtime'] : undefined;
48
+ if (!isRecord(section)) return REALTIME_DEFAULTS;
49
+ const { enabled, urlEnv } = section;
50
+ return {
51
+ enabled: typeof enabled === 'boolean' ? enabled : REALTIME_DEFAULTS.enabled,
52
+ transport: transportOf(section),
53
+ urlEnv: typeof urlEnv === 'string' && urlEnv.trim() !== '' ? urlEnv.trim() : undefined,
54
+ };
55
+ }
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // 1. `defaultClient()` is the one place db composes `replicatedClient(primary, replica)` from
8
8
  // `DATABASE_REPLICA_URL`, and it runs only from `baseClient()` — "the client an app installed
9
- // none for". Every process the framework boots installs one: `dev-queue.ts` calls
9
+ // none for". Every process the framework boots installs one: `runtime-queue.ts` calls
10
10
  // `setDbClient(createPgliteClient(…) | createPostgresClient({ url }))`, so `defaultClient()` was
11
11
  // unreachable from `x dev`, from `apps/web/server.ts` and from every container role.
12
12
  // 2. Routing needs an OPEN scope as well as a configured replica, and nothing opened one.
@@ -24,7 +24,7 @@ import {
24
24
  withReplicaReads,
25
25
  } from '@ultimat3/db';
26
26
  import type { Middleware } from '@ultimat3/http';
27
- import type { ServiceBinding } from './dev-services';
27
+ import type { ServiceBinding } from './runtime-bindings';
28
28
  import type { RuntimeOverrides } from './runtime-overrides';
29
29
 
30
30
  export type ReplicaEnv = Readonly<Record<string, string | undefined>>;
@@ -1,4 +1,4 @@
1
- // Starting the services `dev-services.ts` resolved. Resolution answers "which database"; this
1
+ // Starting the services `runtime-bindings.ts` resolved. Resolution answers "which database"; this
2
2
  // answers "it is running, and every ambient accessor in the framework now points at it" — so
3
3
  // `db()`, `jobDriver()`, `mailDriver()` and the realtime transport are the objects a production
4
4
  // boot installs, only backed by embedded drivers.
@@ -10,6 +10,7 @@ import type { PurgeDriver } from '@ultimat3/cache';
10
10
  import { isNoopPurgeDriver, selectPurgeDriver } from '@ultimat3/cache';
11
11
  import {
12
12
  isLocal,
13
+ type RealtimeConfig,
13
14
  registerReadinessCheck,
14
15
  renderThrowable,
15
16
  resolveEnvironment,
@@ -30,15 +31,17 @@ import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
30
31
  import { selectTransport } from '@ultimat3/realtime/server';
31
32
  import type { Storage } from '@ultimat3/storage';
32
33
  import { defineStorage, localDriver, s3Driver, usesDevStorageSecret } from '@ultimat3/storage';
33
- import { loadCacheTiers, startCacheTiers } from './dev-cache';
34
- import { loadInboxRetention } from './dev-notify-retention';
35
- import { installRetentionSweep } from './dev-purge';
36
- import type { DevDbClient } from './dev-queue';
37
- import { pgExecutorFor, startQueue } from './dev-queue';
38
- import type { DevServices, Env } from './dev-services';
39
34
  import { LocalDiskUnsafeError, StorageUnwritableError } from './errors';
40
35
  import { msg } from './messages';
36
+ import type { DevServices, Env } from './runtime-bindings';
37
+ import { loadCacheTiers, startCacheTiers } from './runtime-cache';
38
+ import { loadWorkerConfig, type WorkerConfig } from './runtime-jobs';
39
+ import { loadInboxRetention } from './runtime-notify-retention';
41
40
  import type { RuntimeOverrides } from './runtime-overrides';
41
+ import { installRetentionSweep } from './runtime-purge';
42
+ import type { DevDbClient } from './runtime-queue';
43
+ import { pgExecutorFor, startQueue } from './runtime-queue';
44
+ import { loadRealtimeConfig } from './runtime-realtime';
42
45
 
43
46
  export interface RunningServices {
44
47
  readonly services: DevServices;
@@ -48,6 +51,17 @@ export interface RunningServices {
48
51
  readonly outbox: OutboxStore;
49
52
  readonly events: EventBus;
50
53
  readonly transport: Transport;
54
+ /**
55
+ * `app.config.ts`'s `realtime` section as this boot obeyed it. `transport` and `urlEnv` chose
56
+ * `transport` above; `enabled` is what `startRoles` reads before it starts `sync` or a replicator.
57
+ */
58
+ readonly realtime: RealtimeConfig;
59
+ /**
60
+ * `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role.
61
+ * Optional because a `RunningServices` can be hand-built, and a test runtime with none keeps the
62
+ * worker's own defaults — the answer it always got. `startServices` always sets it.
63
+ */
64
+ readonly workerConfig?: WorkerConfig;
51
65
  readonly storage: Storage;
52
66
  readonly mail: MailDriver;
53
67
  /**
@@ -193,8 +207,13 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
193
207
  // `usesDevStorageSecret({ env })` left bare, refuse a boot whose own env carries a real
194
208
  // `STORAGE_SIGNING_SECRET` because the PROCESS does not. Which environment, whether a secret
195
209
  // exists, and the name the message prints are one question about one table.
196
- if (!isLocal({ env }) && usesDevStorageSecret({ env })) {
197
- throw new LocalDiskUnsafeError({ environment: resolveEnvironment({ env }), root });
210
+ // `fallback: 'production'` on both, the reading `localDriver` itself takes: a boot that names no
211
+ // environment is not a development one, and this message beats the driver's to it.
212
+ if (!isLocal({ env, fallback: 'production' }) && usesDevStorageSecret({ env })) {
213
+ throw new LocalDiskUnsafeError({
214
+ environment: resolveEnvironment({ env, fallback: 'production' }),
215
+ root,
216
+ });
198
217
  }
199
218
  try {
200
219
  mkdirSync(root, { recursive: true });
@@ -293,12 +312,15 @@ export async function startServices(
293
312
  // Same reason, same place: building a purge driver reads env and dials nothing, so a half-set
294
313
  // `FASTLY_API_TOKEN` without its service id fails here rather than on the first stale page.
295
314
  const cdn = selectPurgeDriver(env);
296
- // Third of the same kind. `NATS_URL` selects the bus rather than quietly keeping the in-process
297
- // one — dev pointed at compose is a parity check, and a parity check that silently ran the
298
- // embedded driver is worse than none. Which transport, which KV bucket and which presence TTL is
299
- // `@ultimat3/realtime`'s decision, and it is the same call a `ROLE=sync` container makes, so this
300
- // process cannot resolve the bus differently from the container it stands in for.
301
- const bus: TransportSelection = selectTransport(env);
315
+ // Third of the same kind. `realtime.transport` selects the bus and `realtime.urlEnv` names the
316
+ // variable holding its url; a config and an environment that disagree refuse here, before any
317
+ // service starts, rather than quietly keeping the in-process bus. Which transport, which KV
318
+ // bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
319
+ // `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
320
+ // container it stands in for. The root is `dirname(stateDir)`, as `loadCacheTiers` reads it.
321
+ const realtime = await loadRealtimeConfig(dirname(services.stateDir));
322
+ const workerConfig = await loadWorkerConfig(dirname(services.stateDir));
323
+ const bus: TransportSelection = selectTransport(env, realtime);
302
324
  // `env`, not the ambient one: this function is HANDED the boot's environment and every other
303
325
  // reader here already uses it, so a queue that asked `process.env` would decide the standby from
304
326
  // a different answer than the middleware that routes to it.
@@ -335,7 +357,7 @@ export async function startServices(
335
357
  // ships a `purgeExpired()` that nothing called, so every row written was a row kept —
336
358
  // `x_rate_limit` takes one upsert per request the web role serves, assets included.
337
359
  //
338
- // `inboxRetention` is READ HERE and not defaulted in `dev-purge.ts`: the two windows are the
360
+ // `inboxRetention` is READ HERE and not defaulted in `runtime-purge.ts`: the two windows are the
339
361
  // app's, `startServices` holds no `AppConfig`, and a loader that silently answered "never
340
362
  // sweep" from inside the sweep would be indistinguishable from an app that chose it.
341
363
  started.push(
@@ -347,7 +369,7 @@ export async function startServices(
347
369
  );
348
370
  // One readiness check per resource this boot OWNS, released with it. Nothing in the tree
349
371
  // registered one, so `/readyz` was `markReady()` alone — "this process bound a socket" — and
350
- // `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `dev-roles.ts` calls it
372
+ // `packages/http/src/server.ts` calls that BEFORE `Bun.serve`, while `role-start.ts` calls it
351
373
  // before `sync`, `worker` and `scheduler` start. The chart's `readinessProbe` and the container
352
374
  // healthcheck both route on it, so a replica whose pool was gone kept taking traffic.
353
375
  started.push(probedReadinessCheck('database', () => db.ping()));
@@ -399,6 +421,8 @@ export async function startServices(
399
421
  outbox,
400
422
  events,
401
423
  transport,
424
+ realtime,
425
+ workerConfig,
402
426
  storage,
403
427
  mail,
404
428
  mailDetail: selection.detail,
@@ -1,6 +1,6 @@
1
1
  // Single responsibility: the one HTTP surface that SERVES a stored object. `@ultimat3/storage`
2
2
  // owns keys, bytes and the tenant boundary and owns no `Response`; `@ultimat3/policy` owns the
3
- // one authz decision; this file is where those two meet a `Route` — the same shape `dev-assets.ts`
3
+ // one authz decision; this file is where those two meet a `Route` — the same shape `runtime-assets.ts`
4
4
  // uses for `/icons` and `/media`, so `x dev` and `apps/web/server.ts` mount one read path, not two.
5
5
  //
6
6
  // The base path is `@ultimat3/storage`'s `DEFAULT_SIGNED_URL_BASE`, imported and never restated:
@@ -37,7 +37,7 @@ export const STORAGE_READ_PERMISSION = 'storage:read';
37
37
  * augments `PermissionRegistry` — and this package compiles against no app, so the bare literal is
38
38
  * a type error inside a generated project (`scaffold-typecheck` is what proves it). The check that
39
39
  * decides is the runtime one anyway: `can()` calls `assertPermission`, which throws
40
- * `X_PERMISSION_UNKNOWN` naming the `definePermissions` edit. `dev-hooks.ts` narrows a route's
40
+ * `X_PERMISSION_UNKNOWN` naming the `definePermissions` edit. `runtime-hooks.ts` narrows a route's
41
41
  * structurally-typed permission for the same reason.
42
42
  */
43
43
  const READ_PERMISSION = STORAGE_READ_PERMISSION as unknown as KnownPermission;
@@ -94,7 +94,7 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
94
94
  * (never 403 — `error-map.ts` maps `X_STORAGE_ORG_MISMATCH` there so a refusal cannot confirm that
95
95
  * a key exists).
96
96
  *
97
- * Split out of `readStorageObject` because `/media/*key` (`dev-assets.ts`) has to make the same
97
+ * Split out of `readStorageObject` because `/media/*key` (`runtime-assets.ts`) has to make the same
98
98
  * decision and made none at all: it passed a client-supplied key straight to `disk().get`, so every
99
99
  * object on the app's only disk was one unauthenticated URL away. A second copy of this test is how
100
100
  * one of the two surfaces would drift back — `storage-surfaces.test.ts` is what holds them level.
@@ -198,7 +198,7 @@ export function storageResponse(request: UltimateRequest, read: StorageRead): Re
198
198
  headers.set('content-range', `bytes */${size}`);
199
199
  return new Response(null, { status: 416, headers });
200
200
  }
201
- // Copied at each call, not through a helper, for `dev-assets.ts`'s reason: a
201
+ // Copied at each call, not through a helper, for `runtime-assets.ts`'s reason: a
202
202
  // `Uint8Array<ArrayBufferLike>` may be backed by a `SharedArrayBuffer`, which `Response` does
203
203
  // not accept — and a helper's declared return type widens the copy back to the type it refuses.
204
204
  if (range === undefined) {
@@ -46,6 +46,16 @@ export async function list(limit = 50): Promise<readonly ShortLink[]> {
46
46
  }
47
47
  `;
48
48
 
49
+ /**
50
+ * The `invoice` resource's own `entity.ts`, as `x g` would read it off disk after the resource
51
+ * below ran — the generators writing INTO that slice are handed it, because a slice with no entity
52
+ * now gets the neutral body and no invented table.
53
+ */
54
+ export const INVOICE_ENTITY = String(
55
+ generate({ kind: 'entity', name: 'invoice' }).find((file) => file.path.endsWith('/entity.ts'))
56
+ ?.contents,
57
+ );
58
+
49
59
  /**
50
60
  * One realistic invocation of every generator, on top of `x new --example`. Names differ from
51
61
  * their feature on purpose: `x g query invoice --feature invoice` would collide with the entity
@@ -59,19 +69,31 @@ export const FIXTURE_GENERATORS: readonly GenerateOptions[] = [
59
69
  { kind: 'resource', name: 'invoice', admin: true },
60
70
  { kind: 'entity', name: 'credit-note', feature: 'credit-note' },
61
71
  { kind: 'policy', name: 'credit-note', feature: 'credit-note' },
62
- { kind: 'action', name: 'send-invoice', feature: 'invoice' },
63
- { kind: 'mutator', name: 'rename-invoice', feature: 'invoice' },
72
+ { kind: 'action', name: 'send-invoice', feature: 'invoice', sliceEntity: INVOICE_ENTITY },
73
+ { kind: 'mutator', name: 'rename-invoice', feature: 'invoice', sliceEntity: INVOICE_ENTITY },
64
74
  // The other shape both templates have: a slice whose `errors.ts` is the author's and declares
65
75
  // no `InvoiceNotFoundError`. The resource's own `errors.ts` still lands in the sandbox (it does
66
76
  // declare one), which is the point — this compiles the file `x g action` writes when it must
67
77
  // not import that class, beside the one it writes when it may.
68
- { kind: 'action', name: 'ping-invoice', feature: 'invoice', sliceErrors: HANDWRITTEN_ERRORS },
69
- { kind: 'mutator', name: 'touch-invoice', feature: 'invoice', sliceErrors: HANDWRITTEN_ERRORS },
78
+ {
79
+ kind: 'action',
80
+ name: 'ping-invoice',
81
+ feature: 'invoice',
82
+ sliceErrors: HANDWRITTEN_ERRORS,
83
+ sliceEntity: INVOICE_ENTITY,
84
+ },
85
+ {
86
+ kind: 'mutator',
87
+ name: 'touch-invoice',
88
+ feature: 'invoice',
89
+ sliceErrors: HANDWRITTEN_ERRORS,
90
+ sliceEntity: INVOICE_ENTITY,
91
+ },
70
92
  { kind: 'query', name: 'invoice-search', feature: 'invoice' },
71
93
  { kind: 'query', name: 'invoice-feed', feature: 'invoice', live: true },
72
- { kind: 'job', name: 'sweep-invoices', feature: 'invoice' },
94
+ { kind: 'job', name: 'sweep-invoices', feature: 'invoice', sliceEntity: INVOICE_ENTITY },
73
95
  { kind: 'backfill', name: 'reindex-invoices', feature: 'invoice' },
74
- { kind: 'task', name: 'nightly-sweep', feature: 'invoice' },
96
+ { kind: 'task', name: 'nightly-sweep', feature: 'invoice', sliceEntity: INVOICE_ENTITY },
75
97
  // The other shape both templates have: a feature whose entity names no tenant column, so the
76
98
  // job/task must not assume one — compiled here beside the tenant-scoped pair above, exactly as
77
99
  // `ping-invoice`/`touch-invoice` compile the action's other shape beside `send-invoice`.
@@ -146,11 +146,14 @@ const overlay = (root: string, app: string): string =>
146
146
  compilerOptions: {
147
147
  noEmit: true,
148
148
  paths: {
149
- // The two subpath exports a generated app reaches for, spelled the way the packages'
150
- // own `exports` maps spell them — the wildcard below would read `ui/icons/zap` as a
151
- // package name. Longest prefix wins, so these are consulted first.
149
+ // The subpath exports a generated app reaches for, spelled the way the packages' own
150
+ // `exports` maps spell them — the wildcard below would read `ui/icons/zap` as a package
151
+ // name, and `cli/serve` as `packages/cli/serve`. Longest prefix wins, so these are
152
+ // consulted first. `@ultimat3/cli/serve` is what `x new`'s `apps/web/server.ts` imports.
152
153
  '@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
153
154
  '@ultimat3/render/server': [`${root}/packages/render/src/server`],
155
+ '@ultimat3/cli/serve': [`${root}/packages/cli/src/serve-entry`],
156
+ '@ultimat3/testing/test-types': [`${root}/packages/testing/src/test-types`],
154
157
  '@ultimat3/*': [`${root}/packages/*/src`],
155
158
  [`@${app}/web/*`]: ['./apps/web/*'],
156
159
  [`@${app}/admin/*`]: ['./apps/admin/*'],
@@ -89,8 +89,14 @@ function repairFix(
89
89
  * never get what the app declares; `undeclared` means the database holds what nothing declares.
90
90
  * One "drift" verdict over both teaches a reader neither.
91
91
  */
92
+ /** A table difference names the table once: `table "customers" on table "customers"` did not. */
93
+ export const schemaDifferenceCause = (difference: SchemaDifference): string =>
94
+ difference.part === 'table'
95
+ ? `table "${difference.table}" ${difference.detail}`
96
+ : `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
97
+
92
98
  function findingFor(difference: SchemaDifference, fix: string): Finding {
93
- const cause = `${difference.part} "${difference.name}" on table "${difference.table}" ${difference.detail}`;
99
+ const cause = schemaDifferenceCause(difference);
94
100
  return difference.direction === 'unmigrated'
95
101
  ? { code: 'X_DB_SCHEMA_UNMIGRATED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR }
96
102
  : { code: 'X_DB_SCHEMA_UNDECLARED', cause, fix, docs: ERROR_DOCS_URL, at: MIGRATIONS_DIR };
package/src/script-csp.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // invisible in `x dev`, where the policy is report-only.
6
6
 
7
7
  import { cspHashSource } from '@ultimat3/http';
8
- import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
8
+ import { HYDRATE_RUNTIME_BODIES, STREAM_REVEAL_BODIES } from '@ultimat3/render';
9
9
 
10
10
  /**
11
11
  * Hashes, never a nonce: a `render: 'static'` page is a file on disk, so no per-response value can
@@ -16,5 +16,8 @@ import { HYDRATE_RUNTIME_BODIES } from '@ultimat3/render';
16
16
  * which `theme-boot.ts` derives from the same string it inlines.
17
17
  */
18
18
  export function inlineScriptSources(extra: readonly string[] = []): readonly string[] {
19
- return [...new Set([...HYDRATE_RUNTIME_BODIES.map(cspHashSource), ...extra])].sort();
19
+ // The stream reveal too: a `render: 'stream'` page swaps each Suspense boundary in with a
20
+ // constant inline `$X()` call, and unhashed it is blocked under the enforced policy.
21
+ const bodies = [...HYDRATE_RUNTIME_BODIES, ...STREAM_REVEAL_BODIES];
22
+ return [...new Set([...bodies.map(cspHashSource), ...extra])].sort();
20
23
  }
@@ -0,0 +1,59 @@
1
+ // A master-key rotation in an order a crash cannot turn into data loss. The new key is STAGED at
2
+ // `<key>.next` (0600) before anything is sealed with it, the committed file is sealed second, and
3
+ // the rename that makes the new key live is last. Interrupted anywhere, some key on disk opens the
4
+ // committed file — and `recoverRotation`, which every `x secrets` command runs first, finishes or
5
+ // abandons the move by asking the file which key it answers to.
6
+
7
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; // why: the staged key is written and renamed synchronously at 0600, and Bun has no mode-setting write or rename.
8
+ import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
9
+ import {
10
+ generateMasterKey,
11
+ isUltimateError,
12
+ masterKeyPath,
13
+ readSecretsFile,
14
+ SECRETS_KEY_MODE,
15
+ stagedMasterKeyPath,
16
+ writeSecretsFile,
17
+ } from '@ultimat3/core';
18
+
19
+ const fileKey = (root: string, hex: string): MasterKeyRef => ({
20
+ hex,
21
+ source: 'file',
22
+ at: masterKeyPath(root),
23
+ });
24
+
25
+ /** Stage, seal, then make live. Returns the key now in force. */
26
+ export async function rotateMasterKey(root: string, values: SecretValues): Promise<MasterKeyRef> {
27
+ const staged = stagedMasterKeyPath(root);
28
+ const hex = generateMasterKey();
29
+ rmSync(staged, { force: true });
30
+ // `wx`: the mode applies only when a write CREATES the file, and a leftover could be 0644.
31
+ writeFileSync(staged, `${hex}\n`, { encoding: 'utf-8', mode: SECRETS_KEY_MODE, flag: 'wx' });
32
+ const next = fileKey(root, hex);
33
+ await writeSecretsFile(root, values, next);
34
+ renameSync(staged, masterKeyPath(root));
35
+ return next;
36
+ }
37
+
38
+ /**
39
+ * A staged key left by an interrupted rotation: whichever of the two keys opens the committed file
40
+ * is the live one. The staged key wins only by opening it, and is then moved into place; otherwise
41
+ * it never sealed anything and is dropped. With no staged key this is `key`, unchanged.
42
+ */
43
+ export async function recoverRotation(root: string, key: MasterKeyRef): Promise<MasterKeyRef> {
44
+ const staged = stagedMasterKeyPath(root);
45
+ if (!existsSync(staged)) return key;
46
+ try {
47
+ await readSecretsFile(root, key);
48
+ rmSync(staged, { force: true });
49
+ return key;
50
+ } catch (error) {
51
+ if (!isUltimateError(error) || error.code !== 'X_SECRETS_KEY_MISMATCH') throw error;
52
+ const candidate = fileKey(root, readFileSync(staged, 'utf-8').trim());
53
+ // Throws the staged key's own mismatch when neither opens it — the committed file's problem,
54
+ // which `X_SECRETS_KEY_MISMATCH`'s fix (restore it from git) answers.
55
+ await readSecretsFile(root, candidate);
56
+ renameSync(staged, masterKeyPath(root));
57
+ return candidate;
58
+ }
59
+ }