@ultimat3/cli 24.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
@@ -16,14 +16,13 @@ import {
16
16
  systemClock,
17
17
  } from '@ultimat3/core';
18
18
  import type { RateLimitStore } from '@ultimat3/http';
19
- import { postgresRateLimitStore } from '@ultimat3/http';
19
+ import { adoptRateLimitStore, postgresRateLimitStore } from '@ultimat3/http';
20
20
  import type { EventBus, JobDriver, OutboxStore } from '@ultimat3/jobs';
21
21
  import type { MailDriver } from '@ultimat3/mail';
22
22
  import {
23
23
  isMemoryDriver,
24
24
  isUnconfiguredDriver,
25
25
  resetMailDriver,
26
- selectMailDriver,
27
26
  setMailDriver,
28
27
  } from '@ultimat3/mail';
29
28
  import type { Transport, TransportSelection } from '@ultimat3/realtime/server';
@@ -36,6 +35,7 @@ import { msg } from './messages';
36
35
  import type { DevServices, Env } from './runtime-bindings';
37
36
  import { cacheTiersOf, startCacheTiers } from './runtime-cache';
38
37
  import { type WorkerConfig, workerConfigOf } from './runtime-jobs';
38
+ import { selectAppMailDriver } from './runtime-mail';
39
39
  import { inboxRetentionOf } from './runtime-notify-retention';
40
40
  import type { RuntimeOverrides } from './runtime-overrides';
41
41
  import { installRetentionSweep } from './runtime-purge';
@@ -57,7 +57,8 @@ export interface RunningServices {
57
57
  */
58
58
  readonly realtime: RealtimeConfig;
59
59
  /**
60
- * `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role.
60
+ * `app.config.ts`'s `jobs.queues` / `concurrency` / `visibilityTimeoutMs`, for the `worker` role —
61
+ * the queues replaced by the boot env's `WORKER_QUEUES` when it is set (`runtime-jobs.ts`).
61
62
  * Optional because a `RunningServices` can be hand-built, and a test runtime with none keeps the
62
63
  * worker's own defaults — the answer it always got. `startServices` always sets it.
63
64
  */
@@ -87,7 +88,7 @@ export interface RunningServices {
87
88
  * `roles.web.replicas: 3` and `x new` scaffolds two.
88
89
  *
89
90
  * OPTIONAL because a `RunningServices` can be hand-built: a test's fixture runtime has a stub
90
- * client and no shared store, which is `createServer`'s memory store and `scope: 'process'` —
91
+ * client and no shared store, which is `httpServer`'s memory store and `scope: 'process'` —
91
92
  * the honest answer for it, and the one `startWeb` derives.
92
93
  */
93
94
  readonly rateLimitStore?: RateLimitStore;
@@ -301,6 +302,20 @@ async function release(steps: readonly (() => void | Promise<void>)[]): Promise<
301
302
  return failures;
302
303
  }
303
304
 
305
+ /**
306
+ * Every STOP path's form of `release`: every step runs, newest first, and the FIRST failure is
307
+ * rethrown — it is the cause, the rest its consequences. The one helper for this process's stops
308
+ * (`startServices`, `startRoles`, `bootServing`, `x dev`): a chain of `await`s with no `try`
309
+ * skipped every step after one rejection, and the replicator rejects a refused close on purpose.
310
+ * Steps are listed in BOOT order, as an unwind list is.
311
+ */
312
+ export async function releaseOrThrow(
313
+ steps: readonly (() => void | Promise<void>)[],
314
+ ): Promise<void> {
315
+ const failures = await release(steps);
316
+ if (failures.length > 0) throw failures[0];
317
+ }
318
+
304
319
  export async function startServices(
305
320
  services: DevServices,
306
321
  env: Env,
@@ -308,10 +323,14 @@ export async function startServices(
308
323
  /** `'verify'` for a serving role on an external database — `runtime-queue.ts`'s `SchemaMode`. */
309
324
  schema: SchemaMode = 'apply',
310
325
  ): Promise<RunningServices> {
326
+ // The app's config is loaded ONCE, here, first, and every section below is read out of that one
327
+ // validated object — mail's `retainMime` is the first reader, so it is loaded before mail.
328
+ const config = await loadAppConfig(services.root);
311
329
  // Before the queue: selection is pure — it parses `SMTP_URL` and builds a transport, it does
312
330
  // not dial. A typo'd credential must fail on the spot rather than after PGlite has started and
313
331
  // been unwound again, and it must fail at boot rather than on the first mail nobody receives.
314
- const selection = selectMailDriver(env);
332
+ // `mail.retainMime` rides along (`runtime-mail.ts`), refused here the same way.
333
+ const selection = selectAppMailDriver(env, config);
315
334
  // Same reason, same place: building a purge driver reads env and dials nothing, so a half-set
316
335
  // `FASTLY_API_TOKEN` without its service id fails here rather than on the first stale page.
317
336
  const cdn = selectPurgeDriver(env);
@@ -320,11 +339,9 @@ export async function startServices(
320
339
  // service starts, rather than quietly keeping the in-process bus. Which transport, which KV
321
340
  // bucket and which presence TTL is `@ultimat3/realtime`'s decision, and it is the same call a
322
341
  // `ROLE=sync` container makes, so this process cannot resolve the bus differently from the
323
- // container it stands in for. The app's config is loaded ONCE, here, and every section below is
324
- // read out of that one validated object.
325
- const config = await loadAppConfig(services.root);
342
+ // container it stands in for.
326
343
  const realtime = realtimeConfigOf(config);
327
- const workerConfig = workerConfigOf(config);
344
+ const workerConfig = workerConfigOf(config, env);
328
345
  const bus: TransportSelection = selectTransport(env, realtime);
329
346
  // `env`, not the ambient one: this function is HANDED the boot's environment and every other
330
347
  // reader here already uses it, so a queue that asked `process.env` would decide the standby from
@@ -358,6 +375,10 @@ export async function startServices(
358
375
  postgresAuthLimiter({ executor, clock: systemClock, policy }),
359
376
  );
360
377
  started.push(() => resetAuthLimiters());
378
+ // Where an action's or a query's declared `rateLimit:` is counted — on every surface, so a
379
+ // task or a one-off command spends from the fleet's table too, not one process' memory. Before
380
+ // `loadApp` for the auth limiters' reason; `startRoles` stacks a deployment's override over it.
381
+ started.push(adoptRateLimitStore(rateLimitStore));
361
382
  // The hourly sweep over the framework tables this boot is responsible for. Every one of them
362
383
  // ships a `purgeExpired()` that nothing called, so every row written was a row kept —
363
384
  // `x_rate_limit` takes one upsert per request the web role serves, assets included.
@@ -407,8 +428,8 @@ export async function startServices(
407
428
  started.push(() => resetMailDriver());
408
429
  const purge = overrides?.purge ?? cdn.driver;
409
430
  // Every tier this process reads through, plus the cross-instance invalidation hop, in one
410
- // call. The CDN tier used to be the only one registered here — so `createRedisTier`,
411
- // `createLruTier` and `createMemoTier` shipped with no caller at all and every replica
431
+ // call. The CDN tier used to be the only one registered here — so `redisTier`,
432
+ // `lruTier` and `memoTier` shipped with no caller at all and every replica
412
433
  // recomputed every cached read. Released with `resetTiers()`, which drops the whole registry:
413
434
  // this boot is the only thing that registers one, and a tier left behind would purge for a
414
435
  // process that has stopped.
@@ -446,8 +467,7 @@ export async function startServices(
446
467
  // the FIRST failure is rethrown because it is the cause and the rest are its consequences,
447
468
  // and every step still runs, so a refused shutdown never leaks into the next boot.
448
469
  async stop() {
449
- const failures = await release(started);
450
- if (failures.length > 0) throw failures[0];
470
+ await releaseOrThrow(started);
451
471
  },
452
472
  };
453
473
  } catch (error) {
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { finiteCount, readWithinLimit } from '@ultimat3/core';
7
7
  import type { RequestContext, Route, UltimateRequest } from '@ultimat3/http';
8
- import { json, NO_STORE } from '@ultimat3/http';
8
+ import { jsonResponse, NO_STORE } from '@ultimat3/http';
9
9
  import type { Storage } from '@ultimat3/storage';
10
10
  import {
11
11
  acceptSignedUpload,
@@ -74,7 +74,7 @@ async function acceptUpload(
74
74
  : { allowedContentTypes: [constraints.contentType] }),
75
75
  }),
76
76
  });
77
- return json({ key: stored.key }, { status: 201 });
77
+ return jsonResponse({ key: stored.key }, { status: 201 });
78
78
  }
79
79
 
80
80
  /**
@@ -7,12 +7,12 @@
7
7
  // `localDriver` SIGNS `/_storage/<disk>/<key>`, so a local `'/_storage'` here is a second statement
8
8
  // of one constant — and a signer and a reader that disagree serve 404 for every signed URL.
9
9
 
10
- import { actorOf } from '@ultimat3/action';
11
10
  import type { Actor } from '@ultimat3/core';
11
+ import { actorOf } from '@ultimat3/core';
12
12
  import type { CacheHint, RequestContext, Route, UltimateRequest } from '@ultimat3/http';
13
13
  import { asCtx, unauthenticated } from '@ultimat3/http';
14
14
  import type { KnownPermission } from '@ultimat3/policy';
15
- import { can, codeOf, evaluate, forbidden, reasonOf } from '@ultimat3/policy';
15
+ import { can, codeOf, evaluate, policyForbidden, reasonOf } from '@ultimat3/policy';
16
16
  import type { Storage, StorageRead } from '@ultimat3/storage';
17
17
  import {
18
18
  assertSafeKey,
@@ -88,7 +88,7 @@ export function authorizeStorageRead(input: StorageReadInput, ctx: RequestContex
88
88
  const reason = reasonOf(evaluation.decision) ?? 'denied';
89
89
  throw codeOf(evaluation.decision) === 'X_UNAUTHENTICATED'
90
90
  ? unauthenticated(ctx.url.pathname)
91
- : forbidden(policy.label, reason);
91
+ : policyForbidden(policy.label, reason);
92
92
  }
93
93
 
94
94
  /**
@@ -43,7 +43,7 @@ const RECORDED_ONLY = 'was recorded by the newest migration and no entity declar
43
43
  /** The difference's name — what `x db gen "record …"` is told to write down. */
44
44
  const REPLICA_IDENTITY_FULL = 'replica identity full';
45
45
  const REPLICA_IDENTITY_UNRECORDED =
46
- 'needs REPLICA IDENTITY FULL — a channel with params or a live query subscribes to it — and no ' +
46
+ 'needs REPLICA IDENTITY FULL — a channel with params routes deletes by its old row — and no ' +
47
47
  'migration recorded it';
48
48
 
49
49
  const columns = (names: readonly string[]): string => names.join(', ');
@@ -1,18 +1,19 @@
1
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
2
+ // `<key>.next` (owner-only: 0600, and an ACL on Windows) before anything is sealed with it, the committed file is sealed second, and
3
3
  // the rename that makes the new key live is last. Interrupted anywhere, some key on disk opens the
4
4
  // committed file — and `recoverRotation`, which every `x secrets` command runs first, finishes or
5
5
  // abandons the move by asking the file which key it answers to.
6
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.
7
+ import { existsSync, readFileSync, rmSync } from 'node:fs'; // why: Bun has no sync exists/remove; the key is WRITTEN and RENAMED only by core's owner-only writers.
8
8
  import type { MasterKeyRef, SecretValues } from '@ultimat3/core';
9
9
  import {
10
10
  generateMasterKey,
11
11
  isUltimateError,
12
12
  masterKeyPath,
13
+ promoteStagedMasterKey,
13
14
  readSecretsFile,
14
- SECRETS_KEY_MODE,
15
15
  stagedMasterKeyPath,
16
+ stageMasterKeyFile,
16
17
  writeSecretsFile,
17
18
  } from '@ultimat3/core';
18
19
 
@@ -24,14 +25,12 @@ const fileKey = (root: string, hex: string): MasterKeyRef => ({
24
25
 
25
26
  /** Stage, seal, then make live. Returns the key now in force. */
26
27
  export async function rotateMasterKey(root: string, values: SecretValues): Promise<MasterKeyRef> {
27
- const staged = stagedMasterKeyPath(root);
28
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' });
29
+ // A fresh temp file renamed over any leftover: a staged key left 0644 is replaced, never reused.
30
+ stageMasterKeyFile(root, hex);
32
31
  const next = fileKey(root, hex);
33
32
  await writeSecretsFile(root, values, next);
34
- renameSync(staged, masterKeyPath(root));
33
+ promoteStagedMasterKey(root);
35
34
  return next;
36
35
  }
37
36
 
@@ -53,7 +52,7 @@ export async function recoverRotation(root: string, key: MasterKeyRef): Promise<
53
52
  // Throws the staged key's own mismatch when neither opens it — the committed file's problem,
54
53
  // which `X_SECRETS_KEY_MISMATCH`'s fix (restore it from git) answers.
55
54
  await readSecretsFile(root, candidate);
56
- renameSync(staged, masterKeyPath(root));
55
+ promoteStagedMasterKey(root);
57
56
  return candidate;
58
57
  }
59
58
  }
package/src/seo-routes.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { DEFAULT_ENVIRONMENT, type Environment, tryResolveEnvironment } from '@ultimat3/core';
6
6
  import type { Route, UltimateRequest } from '@ultimat3/http';
7
- import { applyCacheHeaders, json, NO_STORE } from '@ultimat3/http';
7
+ import { applyCacheHeaders, jsonResponse, NO_STORE } from '@ultimat3/http';
8
8
  import { SITEMAP_PARTS_DIR } from '@ultimat3/seo';
9
9
  import { quoteArg } from './shell-quote';
10
10
  import { NO_SITE_SETTINGS, publicOrigin, type SiteSettings } from './site-config';
@@ -125,7 +125,7 @@ export function seoRoutes(options: SeoRoutesOptions): readonly Route[] {
125
125
  );
126
126
  }
127
127
  const index = new URL(SITEMAP_PATH, originOf(options, request)).href;
128
- const missing = json(
128
+ const missing = jsonResponse(
129
129
  {
130
130
  ok: false,
131
131
  error: {
package/src/serve-boot.ts CHANGED
@@ -16,7 +16,7 @@ import { warnIfIdempotencyProcessScoped } from './runtime-idempotency-scope';
16
16
  import { warnUnsealedMfaSecrets } from './runtime-mfa-warning';
17
17
  import { replicaOverrides } from './runtime-replica';
18
18
  import type { RunningServices } from './runtime-services';
19
- import { startServices } from './runtime-services';
19
+ import { releaseOrThrow, startServices } from './runtime-services';
20
20
  import { loadDrainConfig, loadHealthConfig } from './serve-drain';
21
21
  import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
22
22
  import { adoptPrebuiltStyles, reportBootBuilds, watchBootBuilds } from './serve-prebuilt';
@@ -49,16 +49,22 @@ const LISTENING_ROLES: readonly Role[] = ['web', 'sync'];
49
49
 
50
50
  /**
51
51
  * `app.config.ts`'s `drain`, applied to the WHOLE process rather than only to the web server that
52
- * `createServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
52
+ * `httpServer` hands it to. A role nothing routes to gets no readiness grace at all: the grace
53
53
  * holds `/readyz` at 503 with the listener open so the endpoints catch up, and a worker has neither
54
54
  * — it only went on claiming jobs for those seconds, then aborted them at the drain (s1-con #7).
55
+ *
56
+ * The budget (`deadlineMs`) is every role's: it is the time a worker's running job has to finish
57
+ * on a deploy, so it is the one key here a non-listening role needs MOST.
55
58
  */
56
59
  export function lifecycleForRole(
57
60
  role: Role,
58
61
  drain: Partial<DrainConfig> | undefined,
59
62
  ): LifecycleOptions {
60
- if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0 };
61
- return drain?.readinessGraceMs === undefined ? {} : { readinessGraceMs: drain.readinessGraceMs };
63
+ const budget = drain?.deadlineMs === undefined ? {} : { deadlineMs: drain.deadlineMs };
64
+ if (!LISTENING_ROLES.includes(role)) return { readinessGraceMs: 0, ...budget };
65
+ return drain?.readinessGraceMs === undefined
66
+ ? budget
67
+ : { readinessGraceMs: drain.readinessGraceMs, ...budget };
62
68
  }
63
69
 
64
70
  /**
@@ -181,13 +187,18 @@ async function bootRoles(boot: {
181
187
  running,
182
188
  runtime,
183
189
  async stop() {
184
- await running.stop();
185
- // After the server drained: a bust landing mid-drain still marks the pages it is serving.
186
- web?.release();
187
- await runtime.stop();
188
- // Last: the exporters outlive the roles they were recording, so the drain's own spans and
189
- // the final counter snapshot still have somewhere to go.
190
- stopOtlp();
190
+ // Every step runs and the first failure is rethrown (`releaseOrThrow`): a refused role stop
191
+ // used to leave the services, the PGlite lock and the exporter behind it. Listed in boot
192
+ // order, released newest first — the roles; then the web surface, after the server drained
193
+ // (a bust landing mid-drain still marks the pages it is serving); then the services; LAST
194
+ // the exporter, so the drain's own spans and the final counter snapshot still have
195
+ // somewhere to go.
196
+ await releaseOrThrow([
197
+ stopOtlp,
198
+ () => runtime.stop(),
199
+ () => web?.release(),
200
+ () => running.stop(),
201
+ ]);
191
202
  },
192
203
  };
193
204
  }
@@ -7,7 +7,9 @@ import { loadAppConfig } from './app-config-load';
7
7
  /** The declared section, or `undefined` for a root with no config file — core keeps its default. */
8
8
  export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
9
9
  const drain = (await loadAppConfig(root))?.drain;
10
- return drain === undefined ? undefined : { readinessGraceMs: drain.readinessGraceMs };
10
+ return drain === undefined
11
+ ? undefined
12
+ : { readinessGraceMs: drain.readinessGraceMs, deadlineMs: drain.deadlineMs };
11
13
  }
12
14
 
13
15
  /** `health.readiness`, or `undefined` for a root with no config file (core keeps its default). */
@@ -0,0 +1,95 @@
1
+ // `ROLE=worker`'s retire, on SIGUSR2 (issue #669): stop claiming, finish every held job however
2
+ // long it takes, abort nothing — then the ordinary drain, so the process exits 0. It is what a
3
+ // Kubernetes `preStop` sends before the SIGTERM a rollout ends in, for a job whose side effect may
4
+ // happen at most once and may outlast `drain.deadlineMs`. Framework-owned: no port, no app code.
5
+
6
+ import { drain, logger, renderThrowable } from '@ultimat3/core';
7
+ import type { Worker } from '@ultimat3/jobs';
8
+
9
+ /** The signal a worker retires on. Free in Bun and Node (SIGUSR1 is the inspector's). */
10
+ export const RETIRE_SIGNAL = 'SIGUSR2';
11
+
12
+ /** The two calls a retire makes on the worker. */
13
+ export type RetiringWorker = Pick<Worker, 'stats' | 'stop'>;
14
+
15
+ /** Where the signal is heard — `process` by default, a hand-raised source in a test. */
16
+ export interface RetireSignals {
17
+ on(signal: typeof RETIRE_SIGNAL, listener: () => void): void;
18
+ off(signal: typeof RETIRE_SIGNAL, listener: () => void): void;
19
+ }
20
+
21
+ export interface WorkerRetireOptions {
22
+ /** Default `process.platform`. `win32` installs nothing: Windows has no SIGUSR2. */
23
+ readonly platform?: string;
24
+ readonly signals?: RetireSignals;
25
+ /** What the retire ends in. Default core's `drain` — the hold then releases and exits 0. */
26
+ readonly drain?: (signal: string) => Promise<void>;
27
+ }
28
+
29
+ export interface WorkerRetire {
30
+ /** The worker the boot started (`null` when it started none) — a signal before this waits. */
31
+ adopt(worker: RetiringWorker | null): void;
32
+ /** Give the listener back: a boot that failed, or a hold that is over. */
33
+ disarm(): void;
34
+ }
35
+
36
+ const NOTHING: WorkerRetire = Object.freeze({ adopt: () => undefined, disarm: () => undefined });
37
+
38
+ /**
39
+ * Listen for `RETIRE_SIGNAL` NOW, before the boot: Bun's default for an unhandled SIGUSR2 ends the
40
+ * process, so a `preStop` that lands while the pod still boots must meet this listener, which waits
41
+ * for the worker and then retires it. One retire per process; a second signal joins it.
42
+ *
43
+ * Exits rather than holds, decided 2026-10-07: the `preStop` has no channel back but PID 1 itself,
44
+ * so "retired" has to be observable as "PID 1 is gone" — and a pod that has stopped claiming serves
45
+ * nothing it could keep holding for. A SIGTERM landing mid-retire is the ordinary drain: it binds
46
+ * the retire to `drain.deadlineMs` and cuts off at the margin (`packages/jobs/src/worker.ts`).
47
+ */
48
+ export function armWorkerRetire(options: WorkerRetireOptions = {}): WorkerRetire {
49
+ if ((options.platform ?? process.platform) === 'win32') return NOTHING;
50
+ const signals: RetireSignals = options.signals ?? process;
51
+ const finish = options.drain ?? drain;
52
+ let adopt: (worker: RetiringWorker | null) => void = () => undefined;
53
+ const adopted = new Promise<RetiringWorker | null>((resolve) => {
54
+ adopt = resolve;
55
+ });
56
+ let retiring = false;
57
+
58
+ const retire = async (): Promise<void> => {
59
+ const worker = await adopted;
60
+ try {
61
+ if (worker !== null) {
62
+ const before = await worker.stats();
63
+ logger.info('jobs.worker.retiring', {
64
+ signal: RETIRE_SIGNAL,
65
+ workerId: before.workerId,
66
+ inFlight: before.inFlight,
67
+ });
68
+ await worker.stop(RETIRE_SIGNAL);
69
+ const after = await worker.stats();
70
+ logger.info('jobs.worker.retired', {
71
+ signal: RETIRE_SIGNAL,
72
+ workerId: after.workerId,
73
+ state: after.state,
74
+ inFlight: after.inFlight,
75
+ });
76
+ }
77
+ } catch (error) {
78
+ logger.warn('jobs.worker.retire-failed', { error: renderThrowable(error) });
79
+ } finally {
80
+ await finish(RETIRE_SIGNAL);
81
+ }
82
+ };
83
+
84
+ const listener = (): void => {
85
+ if (retiring) return;
86
+ retiring = true;
87
+ // Never rejects: `retire` catches its own failure and always reaches the drain.
88
+ void retire();
89
+ };
90
+ signals.on(RETIRE_SIGNAL, listener);
91
+ return {
92
+ adopt: (worker) => adopt(worker),
93
+ disarm: () => signals.off(RETIRE_SIGNAL, listener),
94
+ };
95
+ }
package/src/serve-web.ts CHANGED
@@ -4,16 +4,18 @@
4
4
  // spared: render's server half, the PWA, SEO, MCP and island subsystems.
5
5
 
6
6
  import type { Route } from '@ultimat3/http';
7
- import { describeRoutes } from '@ultimat3/render';
7
+ import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
8
+
9
+ import { describePages } from '@ultimat3/render';
8
10
  import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
9
11
  import { loadSignInPath } from './app-auth';
10
12
  import { mountAppMcp } from './app-mcp';
11
- import { errorPageStyleSources } from './error-page-csp';
13
+ import { errorPageStyleBodies } from './error-page-csp';
12
14
  import { islandRoutes } from './island-routes';
13
15
  import type { LoadedIslands } from './island-store';
14
16
  import { loadOrBuildIslands } from './island-store';
15
17
  import { loadNavigation, pageNavigation } from './page-navigation';
16
- import { loadSpeculation, pageSpeculation } from './page-speculation';
18
+ import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
17
19
  import { pageSync } from './page-sync';
18
20
  import { loadPwaArtifacts } from './pwa-artifacts';
19
21
  import { adminMountRoutes } from './runtime-admin';
@@ -30,13 +32,14 @@ import { styleRoutes } from './style-routes';
30
32
  import { serviceWorkerArtifacts } from './sw-artifacts';
31
33
  import { serviceWorkerRoutes } from './sw-routes';
32
34
  import { loadThemeMode, themeBoot } from './theme-boot';
35
+ import { loadThemeBrand } from './theme-brand';
33
36
 
34
37
  /** Everything `startRoles` is handed that only a process serving documents has. */
35
38
  export interface WebSurface {
36
39
  readonly routes: readonly Route[];
37
40
  /** The theme boot and the speculation rules: the two inline bodies every document may carry. */
38
41
  readonly inlineScripts: readonly string[];
39
- /** The app's own error pages' `<style>` bodies, which the enforced policy must admit. */
42
+ /** The app's error pages' and its brand's `<style>` bodies, which the enforced policy admits. */
40
43
  readonly inlineStyles: readonly string[];
41
44
  /** Where a browser that opened a guarded page is sent — the declaration `x dev` reads. */
42
45
  readonly signInPath: string | null;
@@ -60,6 +63,8 @@ export async function webSurface(
60
63
  // prevent, and it is the one an operator cannot see without installing the app.
61
64
  const pwa = await loadPwaArtifacts(options.root);
62
65
  const theme = themeBoot(await loadThemeMode(options.root));
66
+ // The app's brand (`apps/web/shared/theme.ts`), or nothing: the same reader `x dev` calls.
67
+ const brand = await loadThemeBrand(options.root);
63
68
  // `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
64
69
  const site = await loadSiteSettings(options.root);
65
70
  const origin = publicOrigin(options.env, site);
@@ -73,8 +78,9 @@ export async function webSurface(
73
78
  const speculation = pageSpeculation({
74
79
  config: await loadSpeculation(options.root),
75
80
  client: declared.surfaces,
81
+ localeSegments: otherLocaleSegments(await appLocaleSet(options.root)),
76
82
  });
77
- // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
83
+ // The worker, from the SAME route table this process is about to serve — `describePages()` is
78
84
  // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
79
85
  // route added here cannot be missing from the precache manifest.
80
86
  const serviceWorker =
@@ -83,7 +89,7 @@ export async function webSurface(
83
89
  : serviceWorkerArtifacts({
84
90
  pwa,
85
91
  buildId,
86
- routes: describeRoutes(),
92
+ routes: describePages(),
87
93
  islands,
88
94
  styles: styleBundle(),
89
95
  scripts: [
@@ -135,6 +141,7 @@ export async function webSurface(
135
141
  ...adminMountRoutes({
136
142
  buildId: buildId,
137
143
  themeHead: theme.head,
144
+ ...(brand === undefined ? {} : { brandHead: brand.head }),
138
145
  ...(origin === undefined ? {} : { origin }),
139
146
  }),
140
147
  ...appRoutes({
@@ -144,6 +151,7 @@ export async function webSurface(
144
151
  persisted: sync.persisted,
145
152
  ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
146
153
  themeHead: theme.head,
154
+ ...(brand === undefined ? {} : { brandHead: brand.head }),
147
155
  ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
148
156
  // A `static` page is the same bytes for every request of this process: rendered once.
149
157
  memoStatic: true,
@@ -158,8 +166,11 @@ export async function webSurface(
158
166
  routes,
159
167
  inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
160
168
  // The enforced policy this process sends must admit the app's own error pages' `<style>` and
161
- // the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
162
- inlineStyles: await errorPageStyleSources(options.root),
169
+ // the brand's; `x dev` is report-only, so only here was a missing hash an unstyled page.
170
+ inlineStyles: [
171
+ ...(await errorPageStyleBodies(options.root)),
172
+ ...(brand === undefined ? [] : [brand.style]),
173
+ ],
163
174
  // Same declaration `x dev` reads. Without it a container answers a browser that opened a
164
175
  // guarded page with the problem document, rendered as raw JSON in the viewport.
165
176
  signInPath: await loadSignInPath(options.root),
package/src/serve.ts CHANGED
@@ -5,10 +5,10 @@
5
5
  // interface — every one by default, because a container is reached through a port mapping.
6
6
 
7
7
  import { assertNoDevSecretsOutsideLocal, logger } from '@ultimat3/core';
8
- import { assertNoDrift, checkDrift, migrate } from '@ultimat3/db';
8
+ import { assertNoSchemaDrift, checkDrift, migrate } from '@ultimat3/db';
9
9
  import { loadAppRuntime } from './app-runtime';
10
10
  import { acceptCreatedTables } from './db-accept-created';
11
- import { holdUntilShutdown } from './hold';
11
+ import { type BootedHold, holdWhileBooting } from './hold';
12
12
  import { startMetricsEndpoint } from './metrics-endpoint';
13
13
  import { readMigrations } from './migrations';
14
14
  import { resolveServices } from './runtime-bindings';
@@ -174,19 +174,36 @@ export async function runRole(input: ServeOptions): Promise<StartedApp> {
174
174
  // returned. `x db migrate` calls the same `runMigrations` and renders every difference as a
175
175
  // finding before exiting non-zero; a container that logged one and exited 0 would let the
176
176
  // deploy roll on over a schema nobody can reconstruct, which is the failure drift exists for.
177
- assertNoDrift(migrated.drift);
177
+ assertNoSchemaDrift(migrated.drift);
178
178
  return migrated;
179
179
  }
180
- const app = await serveApp({ ...options, role });
181
- logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
180
+ // The drain's handlers BEFORE the boot (`holdWhileBooting`): the worker claims inside
181
+ // `startRoles`, so a SIGTERM during boot has to be a drain of what started, never Bun's kill.
182
182
  // `exit` because this is the one entry point with nothing above it: `bin.ts` ends in
183
183
  // `process.exit(code)` and `apps/web/server.ts` — which is what awaits this — does not. One
184
184
  // non-unref'd interval anywhere in the app then holds an event loop that has nothing left to do,
185
185
  // until `terminationGracePeriodSeconds` runs out and the kubelet SIGKILLs a drained process.
186
- await holdUntilShutdown('serve', () => app.stop(), {
187
- exit: (code) => {
188
- process.exit(code);
189
- },
190
- })();
186
+ //
187
+ // A worker also retires on SIGUSR2 (`serve-retire.ts`) — armed before the boot for the same
188
+ // reason: Bun's default for an unhandled SIGUSR2 ends the process, and a `preStop` may land
189
+ // while the pod still boots. The retire ends in the same drain, so the same hold exits 0.
190
+ // Behind `await import()`, as the serving graph is: `ROLE=migrate` never loads it.
191
+ const retire = role === 'worker' ? (await import('./serve-retire')).armWorkerRetire() : undefined;
192
+ let booted: BootedHold<ServedApp>;
193
+ try {
194
+ booted = await holdWhileBooting('serve', () => serveApp({ ...options, role }), {
195
+ exit: (code) => {
196
+ retire?.disarm();
197
+ process.exit(code);
198
+ },
199
+ });
200
+ } catch (error) {
201
+ retire?.disarm();
202
+ throw error;
203
+ }
204
+ const { app, hold } = booted;
205
+ retire?.adopt(app.running.worker);
206
+ logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
207
+ await hold();
191
208
  return app;
192
209
  }
@@ -27,9 +27,12 @@ export interface BarrelImport {
27
27
  readonly entry: string;
28
28
  }
29
29
 
30
- /** `import` / `export … from` / `import()` of a VALUE from the barrel. `import type` runs nothing. */
30
+ /**
31
+ * `import` / `export … from` / `import()` of a VALUE from the barrel, and the side-effect
32
+ * `import '…'` that binds nothing yet evaluates the whole module. `import type` runs nothing.
33
+ */
31
34
  const IMPORTS =
32
- /\b(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3\s*\)/g;
35
+ /\b(?:import|export)\s+(?!type\s)[^;'"]*?\bfrom\s*(['"])([^'"]+)\1|\bimport\s*\(\s*(['"])([^'"]+)\3\s*\)|\bimport\s*(['"])([^'"]+)\5/g;
33
36
 
34
37
  export function barrelImports(
35
38
  source: string,
@@ -41,7 +44,7 @@ export function barrelImports(
41
44
  const lineAt = lineIndex(code);
42
45
  const found: BarrelImport[] = [];
43
46
  for (const match of code.matchAll(IMPORTS)) {
44
- const spec = match[2] ?? match[4] ?? '';
47
+ const spec = match[2] ?? match[4] ?? match[6] ?? '';
45
48
  const entry = barrels.get(spec);
46
49
  if (entry === undefined) continue;
47
50
  // `import { type A, type B } from …` binds nothing at runtime either.
@@ -96,7 +96,7 @@ export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
96
96
  flag: 'browser',
97
97
  command: 'shot',
98
98
  reason: `no executable at "${executablePath}"`,
99
- fix: 'x shot / --browser /usr/bin/chromium',
99
+ fix: 'x shot / --browser /usr/bin/chromium # macOS: --browser "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; Windows: --browser "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"',
100
100
  });
101
101
  }
102
102
  return { executablePath };
@@ -3,23 +3,14 @@
3
3
  // on the language of the machine's Chrome. A non-default locale is also a URL prefix (`/en/…`),
4
4
  // because on a `site/` route the unprefixed path is always the default locale whatever the header.
5
5
 
6
- import { loadAppConfig } from './app-config-load';
6
+ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
7
7
  import { BadFlagError } from './errors';
8
8
 
9
- export interface ShotLocales {
10
- readonly locales: readonly string[];
11
- readonly defaultLocale: string;
12
- }
13
-
14
- /** An app that declares nothing is `en` only — the framework's own default. */
15
- export const FALLBACK_SHOT_LOCALES: ShotLocales = { locales: ['en'], defaultLocale: 'en' };
16
-
17
- /** `app.config.ts`'s `locales` and `defaultLocale`, off the one loader (`app-config-load.ts`). */
18
- export async function loadShotLocales(root: string): Promise<ShotLocales> {
19
- const config = await loadAppConfig(root);
20
- if (config === undefined) return FALLBACK_SHOT_LOCALES;
21
- return { locales: config.locales, defaultLocale: config.defaultLocale };
22
- }
9
+ /**
10
+ * `@ultimat3/i18n/app-catalogs`'s `appLocaleSet(root)` — the one reader, and its one answer for an
11
+ * app that declares nothing (`UNDECLARED_LOCALES`, the framework default alone).
12
+ */
13
+ export type ShotLocales = AppLocaleSet;
23
14
 
24
15
  /** `--locale <l>`, refused by name when the app does not declare it — a typo costs no browser. */
25
16
  export function readLocaleFlag(value: string | undefined, app: ShotLocales): string | undefined {