@ultimat3/cli 20.2.1 → 22.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 (237) hide show
  1. package/CLAUDE.md +84 -1582
  2. package/README.md +40 -2
  3. package/package.json +31 -31
  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-env.ts +2 -2
  8. package/src/app-load.ts +37 -5
  9. package/src/app-openapi.ts +27 -0
  10. package/src/app-permissions.ts +0 -0
  11. package/src/app-root.ts +1 -1
  12. package/src/async-pages.ts +32 -0
  13. package/src/bin.ts +7 -1
  14. package/src/boundary-findings.ts +36 -0
  15. package/src/browser-launcher-fake-html.ts +80 -0
  16. package/src/browser-launcher-fake.ts +165 -0
  17. package/src/browser-launcher-port.ts +159 -0
  18. package/src/browser-launcher.ts +26 -133
  19. package/src/budgets.ts +56 -13
  20. package/src/build-errors.ts +54 -0
  21. package/src/cdp-shot-a11y.ts +77 -0
  22. package/src/cdp-shot-clock.ts +14 -0
  23. package/src/cdp-shot-driver.ts +150 -0
  24. package/src/cdp-shot-element.ts +147 -0
  25. package/src/cdp-shot-errors.ts +62 -0
  26. package/src/cdp-shot-keys.ts +152 -0
  27. package/src/cdp-shot-page.ts +230 -0
  28. package/src/cdp-shot-watch.ts +241 -0
  29. package/src/cmd-affected-spec.ts +24 -0
  30. package/src/cmd-affected.ts +3 -19
  31. package/src/cmd-build-spec.ts +16 -0
  32. package/src/cmd-build.ts +46 -20
  33. package/src/cmd-ci-spec.ts +24 -0
  34. package/src/cmd-ci.ts +17 -21
  35. package/src/cmd-db-branch.ts +2 -2
  36. package/src/cmd-db-spec.ts +99 -0
  37. package/src/cmd-db.ts +15 -94
  38. package/src/cmd-deploy-helm.ts +136 -0
  39. package/src/cmd-deploy-spec.ts +40 -0
  40. package/src/cmd-deploy.ts +117 -42
  41. package/src/cmd-dev-spec.ts +25 -0
  42. package/src/cmd-dev.ts +78 -122
  43. package/src/cmd-docs-spec.ts +16 -0
  44. package/src/cmd-docs.ts +2 -11
  45. package/src/cmd-doctor-spec.ts +21 -0
  46. package/src/cmd-doctor.ts +4 -18
  47. package/src/cmd-env-spec.ts +18 -0
  48. package/src/cmd-env.ts +4 -13
  49. package/src/cmd-errors-spec.ts +23 -0
  50. package/src/cmd-errors.ts +3 -17
  51. package/src/cmd-fix-spec.ts +18 -0
  52. package/src/cmd-fix.ts +4 -13
  53. package/src/cmd-generate-spec.ts +31 -0
  54. package/src/cmd-generate.ts +46 -42
  55. package/src/cmd-i18n-spec.ts +16 -0
  56. package/src/cmd-i18n.ts +7 -14
  57. package/src/cmd-jobs-spec.ts +56 -0
  58. package/src/cmd-jobs.ts +3 -50
  59. package/src/cmd-manifest-spec.ts +15 -0
  60. package/src/cmd-manifest.ts +19 -20
  61. package/src/cmd-mcp-spec.ts +21 -0
  62. package/src/cmd-mcp.ts +2 -15
  63. package/src/cmd-new-spec.ts +33 -0
  64. package/src/cmd-new.ts +38 -33
  65. package/src/cmd-policy-spec.ts +13 -0
  66. package/src/cmd-policy.ts +2 -9
  67. package/src/cmd-pr-spec.ts +39 -0
  68. package/src/cmd-pr.ts +5 -65
  69. package/src/cmd-registries-spec.ts +32 -0
  70. package/src/cmd-registries.ts +4 -25
  71. package/src/cmd-routes-spec.ts +12 -0
  72. package/src/cmd-routes.ts +2 -7
  73. package/src/cmd-secrets-spec.ts +19 -0
  74. package/src/cmd-secrets.ts +22 -34
  75. package/src/cmd-shot-island.ts +12 -13
  76. package/src/cmd-shot-spec.ts +53 -0
  77. package/src/cmd-shot.ts +11 -60
  78. package/src/cmd-tasks-spec.ts +21 -0
  79. package/src/cmd-tasks.ts +2 -16
  80. package/src/cmd-test-spec.ts +54 -0
  81. package/src/cmd-test.ts +4 -49
  82. package/src/cmd-verify-spec.ts +28 -0
  83. package/src/cmd-verify.ts +3 -23
  84. package/src/db-seed.ts +4 -2
  85. package/src/dev-dashboard.ts +4 -4
  86. package/src/dev-lock.ts +18 -2
  87. package/src/dev-port.ts +21 -0
  88. package/src/dev-route-table.ts +119 -0
  89. package/src/dispatch.ts +12 -5
  90. package/src/document-styles.ts +1 -1
  91. package/src/drift.ts +4 -18
  92. package/src/error-catalog.ts +16 -9
  93. package/src/error-codes.ts +33 -28
  94. package/src/error-pages.ts +19 -7
  95. package/src/error-unthrown.ts +130 -0
  96. package/src/errors.ts +22 -29
  97. package/src/favicon.ts +2 -2
  98. package/src/fix-imports.ts +1 -1
  99. package/src/fix-scan.ts +2 -9
  100. package/src/flag-reads.ts +1 -2
  101. package/src/foreign-text.ts +36 -0
  102. package/src/framework-schema.ts +5 -6
  103. package/src/generate-feature.ts +42 -0
  104. package/src/generate-files.ts +7 -3
  105. package/src/generate-grants.ts +83 -0
  106. package/src/generate-kinds.ts +56 -4
  107. package/src/i18n-index.ts +59 -10
  108. package/src/icon-assets.ts +1 -1
  109. package/src/image-prepare.ts +14 -0
  110. package/src/import-scan.ts +63 -0
  111. package/src/index.ts +67 -334
  112. package/src/invocation-flags.ts +26 -0
  113. package/src/island-bundle.ts +38 -11
  114. package/src/island-capture.ts +6 -6
  115. package/src/island-harness-script.ts +6 -2
  116. package/src/island-realtime.ts +98 -0
  117. package/src/island-shot.ts +5 -0
  118. package/src/island-store.ts +131 -0
  119. package/src/island-verdict.ts +2 -2
  120. package/src/job-registration.ts +42 -0
  121. package/src/jobs-driver.ts +2 -2
  122. package/src/live-routes.ts +82 -42
  123. package/src/load-findings.ts +51 -0
  124. package/src/mcp-db-target.ts +1 -1
  125. package/src/mcp-errors.ts +30 -26
  126. package/src/mcp-host.ts +27 -12
  127. package/src/mcp-ui-diff.ts +27 -0
  128. package/src/mcp-ui-inspect.ts +4 -4
  129. package/src/mcp-ui-interact.ts +21 -15
  130. package/src/mcp-ui.ts +19 -15
  131. package/src/measure-database.ts +73 -0
  132. package/src/measure-paths.ts +74 -0
  133. package/src/measure-scope.ts +74 -0
  134. package/src/messages.ts +1 -3
  135. package/src/metrics-endpoint.ts +1 -1
  136. package/src/otlp-export.ts +1 -1
  137. package/src/output.ts +6 -0
  138. package/src/page-sync.ts +54 -0
  139. package/src/permission-grants.ts +86 -0
  140. package/src/prerender-out.ts +25 -0
  141. package/src/prerender.ts +121 -102
  142. package/src/pwa-artifacts.ts +3 -3
  143. package/src/realtime-browser-probe-fixture.ts +2 -2
  144. package/src/reexport-manifest.ts +2 -1
  145. package/src/registry.ts +80 -56
  146. package/src/role-realtime.ts +36 -0
  147. package/src/{dev-replicator.ts → role-replicator.ts} +1 -1
  148. package/src/{dev-roles-fixture.ts → role-start-fixture.ts} +9 -5
  149. package/src/role-start-types.ts +112 -0
  150. package/src/{dev-roles.ts → role-start.ts} +42 -115
  151. package/src/{dev-sync.ts → role-sync.ts} +23 -11
  152. package/src/root-env.ts +67 -0
  153. package/src/{dev-assets.ts → runtime-assets.ts} +7 -7
  154. package/src/{dev-services.ts → runtime-bindings.ts} +43 -17
  155. package/src/{dev-cache.ts → runtime-cache.ts} +2 -2
  156. package/src/runtime-jobs.ts +87 -0
  157. package/src/{dev-live-feed.ts → runtime-live-feed.ts} +20 -5
  158. package/src/{dev-notify-retention.ts → runtime-notify-retention.ts} +1 -1
  159. package/src/{dev-purge.ts → runtime-purge.ts} +2 -2
  160. package/src/{dev-queue.ts → runtime-queue.ts} +4 -4
  161. package/src/runtime-realtime.ts +55 -0
  162. package/src/{dev-render.ts → runtime-render.ts} +119 -20
  163. package/src/{dev-replica.ts → runtime-replica.ts} +2 -2
  164. package/src/{dev-runtime.ts → runtime-services.ts} +41 -17
  165. package/src/{dev-storage.ts → runtime-storage.ts} +4 -4
  166. package/src/scaffold-fixture.ts +28 -6
  167. package/src/scaffold-typecheck.ts +6 -3
  168. package/src/schema-drift.ts +7 -1
  169. package/src/script-csp.ts +5 -2
  170. package/src/secrets-rotation.ts +59 -0
  171. package/src/serve-boot.ts +192 -0
  172. package/src/serve-drain.ts +24 -0
  173. package/src/serve-entry.ts +6 -0
  174. package/src/serve-env.ts +116 -0
  175. package/src/serve-types.ts +55 -0
  176. package/src/serve.ts +44 -338
  177. package/src/shot-server.ts +2 -2
  178. package/src/shot-settle.ts +10 -1
  179. package/src/shot-theme.ts +3 -3
  180. package/src/shot-verdict.ts +16 -7
  181. package/src/signal-shred.ts +27 -0
  182. package/src/solid-loader.ts +26 -2
  183. package/src/static-report.ts +8 -1
  184. package/src/sw-artifacts.ts +13 -3
  185. package/src/sync-url.ts +31 -0
  186. package/src/templates/action.ts +30 -16
  187. package/src/templates/entity.ts +12 -7
  188. package/src/templates/index.ts +1 -1
  189. package/src/templates/job.ts +10 -7
  190. package/src/templates/policy.ts +20 -2
  191. package/src/templates/resource-create.ts +127 -0
  192. package/src/templates/resource-form-island.ts +76 -30
  193. package/src/templates/resource.ts +10 -4
  194. package/src/templates/route.ts +3 -0
  195. package/src/templates/scaffold-app.ts +4 -1
  196. package/src/templates/scaffold-auth.ts +3 -1
  197. package/src/templates/scaffold-container-compose.ts +184 -0
  198. package/src/templates/scaffold-container.ts +25 -145
  199. package/src/templates/scaffold-dashboard-example.ts +2 -2
  200. package/src/templates/scaffold-db-package.ts +16 -3
  201. package/src/templates/scaffold-demo-org.ts +41 -0
  202. package/src/templates/scaffold-entries.ts +1 -1
  203. package/src/templates/scaffold-env.ts +6 -0
  204. package/src/templates/scaffold-helm-templates.ts +66 -7
  205. package/src/templates/scaffold-helm.ts +27 -5
  206. package/src/templates/scaffold-i18n.ts +15 -10
  207. package/src/templates/scaffold-repo.ts +13 -10
  208. package/src/templates/scaffold-roles.ts +38 -10
  209. package/src/templates/slice-foundation.ts +1 -1
  210. package/src/templates/wrap.ts +4 -1
  211. package/src/test-passes.ts +2 -1
  212. package/src/test-workers.ts +26 -0
  213. package/src/ts-scan.ts +3 -6
  214. package/src/tsconfig-references.ts +1 -2
  215. package/src/verify-checks.ts +41 -39
  216. package/src/verify-e2e.ts +41 -0
  217. package/src/verify-run.ts +115 -50
  218. package/src/verify-step.ts +3 -3
  219. package/src/verify-tests.ts +22 -30
  220. package/src/verify-typecheck.ts +28 -0
  221. package/src/web-binding.ts +2 -2
  222. package/src/worker-bundle.ts +192 -0
  223. package/src/workspace-graph.ts +10 -33
  224. package/src/cdp-browser.ts +0 -100
  225. package/src/cdp-connection.ts +0 -211
  226. package/src/cdp-e2e-page.ts +0 -209
  227. package/src/cdp-errors.ts +0 -56
  228. package/src/cdp-launch.ts +0 -139
  229. package/src/e2e-dom-fixture.ts +0 -117
  230. package/src/e2e-driver.ts +0 -97
  231. package/src/e2e-errors.ts +0 -103
  232. package/src/e2e-evaluate.ts +0 -156
  233. package/src/e2e-locator.ts +0 -86
  234. package/src/e2e-page.ts +0 -150
  235. package/src/e2e-selection.ts +0 -182
  236. package/src/measurement-actor.ts +0 -26
  237. /package/src/{dev-hooks.ts → runtime-hooks.ts} +0 -0
@@ -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
+ }
@@ -0,0 +1,192 @@
1
+ // The web and background roles' boot, after the services are up: the app loaded, its route table
2
+ // built, the roles started. Split from `serve.ts` (the entry points) at its line ceiling; the rules
3
+ // that file's header states — the SAME boot `x dev` runs, minus the watcher, `/_x` and `dev: true`
4
+ // — are this file's.
5
+
6
+ import type { Role } from '@ultimat3/core';
7
+ import type { Route } from '@ultimat3/http';
8
+ import { describeRoutes } from '@ultimat3/render';
9
+ import { createIsrController } from '@ultimat3/render/server';
10
+ import { apiRoutes } from './api-routes';
11
+ import { loadSignInPath } from './app-auth';
12
+ import { loadApp } from './app-load';
13
+ import { appManifest } from './app-manifest';
14
+ import { mountAppMcp } from './app-mcp';
15
+ import { errorPageStyleSources } from './error-page-csp';
16
+ import { islandRoutes } from './island-routes';
17
+ import { loadOrBuildIslands } from './island-store';
18
+ import type { MetricsEndpoint } from './metrics-endpoint';
19
+ import { startOtlpExport } from './otlp-export';
20
+ import { pageSync } from './page-sync';
21
+ import { loadPwaArtifacts } from './pwa-artifacts';
22
+ import { startRoles } from './role-start';
23
+ import { assetRoutes } from './runtime-assets';
24
+ import { appRoutes } from './runtime-render';
25
+ import { replicaOverrides } from './runtime-replica';
26
+ import type { RunningServices } from './runtime-services';
27
+ import { storageRoutes } from './runtime-storage';
28
+ import { loadDrainConfig } from './serve-drain';
29
+ import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
30
+ import type { ServedApp, ServeOptions } from './serve-types';
31
+ import { styleBundle } from './style-bundle';
32
+ import { styleRoutes } from './style-routes';
33
+ import { serviceWorkerArtifacts } from './sw-artifacts';
34
+ import { serviceWorkerRoutes } from './sw-routes';
35
+ import { loadThemeMode, themeBoot } from './theme-boot';
36
+
37
+ /** The half of `serveApp` whose every acquisition is registered for rollback. */
38
+ export async function bootRoles(boot: {
39
+ readonly options: ServeOptions;
40
+ readonly role: Role;
41
+ readonly runtime: RunningServices;
42
+ readonly acquired: (() => void | Promise<void>)[];
43
+ /** The scrape listener `serveApp` opened before any of this work; `startRoles` adopts it. */
44
+ readonly metrics: MetricsEndpoint;
45
+ }): Promise<ServedApp> {
46
+ const { options, role, runtime, acquired, metrics } = boot;
47
+ // Importing the app's modules IS the registration: every route, action and job below is
48
+ // whatever this call put in the registries.
49
+ await loadApp(options.root);
50
+ // The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
51
+ // answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only
52
+ // when unstamped, because a stamped image already paid for it at build time and a replica's boot
53
+ // should not repeat it — the load above is the part every boot needs either way.
54
+ const stamped = options.env['BUILD_ID'];
55
+ const buildId =
56
+ stamped !== undefined && stamped.length > 0
57
+ ? stamped
58
+ : (await appManifest(options.root)).manifest.buildId;
59
+ // Before the first socket opens: everything above this line fails loudly into the container's
60
+ // own logs, everything below it is a served request, a claimed job or a routed frame.
61
+ configureReporting(options.env, buildId);
62
+ // Beside error reporting, and for the same reason it is here: `OTEL_EXPORTER_OTLP_ENDPOINT` is
63
+ // in the shipped chart and nothing read it, so every deployment that configured a collector got
64
+ // an empty dashboard. `x dev` keeps its own recorder — the `/_x` timeline is a different sink
65
+ // with a different lifetime — so this is the production boot's alone (axiom 6).
66
+ const stopOtlp = startOtlpExport(options.env);
67
+ acquired.push(stopOtlp);
68
+ // Only the web role serves pages, so only it builds islands and assembles a route table: a
69
+ // worker, scheduler, sync or replicator pod compiled every island of the app on every boot for
70
+ // nothing it would ever serve (plan 101, slice 12 e).
71
+ const web = role === 'web' ? await webSurface(options, runtime, buildId) : undefined;
72
+ const port = options.port ?? portFromEnv(options.env);
73
+ // An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
74
+ // fixed 9090 would fail the next suite to boot beside it. An environment that names the port
75
+ // still wins — that is the deploy talking.
76
+ const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
77
+ const drain = await loadDrainConfig(options.root);
78
+ const replicaOverride = replicaOverrides(options.runtime, runtime.services.db, options.env);
79
+ const running = await startRoles({
80
+ roles: [role],
81
+ port,
82
+ metricsPort,
83
+ metrics,
84
+ buildId,
85
+ runtime,
86
+ routes: web?.routes ?? [],
87
+ env: options.env,
88
+ // Same declaration `x dev` reads. Without it a container answers a browser that opened a
89
+ // guarded page with the problem document, rendered as raw JSON in the viewport.
90
+ signInPath: await loadSignInPath(options.root),
91
+ // The enforced policy this process sends must admit the app's own error pages' `<style>` and
92
+ // the theme boot the documents carry; `x dev` is report-only, so only here was it a blank page.
93
+ inlineStyles: web === undefined ? [] : await errorPageStyleSources(options.root),
94
+ inlineScripts: web === undefined ? [] : [web.themeCsp],
95
+ // The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
96
+ // process and `x dev` cannot answer a browser differently.
97
+ root: options.root,
98
+ http: containerBinding(options.env, options.hostname),
99
+ // `app.config.ts`'s drain section: the readiness grace the chart's grace period is sized for.
100
+ ...(drain === undefined ? {} : { drain }),
101
+ // The read-replica scope rides in FRONT of whatever the host supplied, or the host's own value
102
+ // passes through untouched. `DATABASE_REPLICA_URL` was read by no booted process before this:
103
+ // `defaultClient()` is the one composer of a replicated pair and it runs only when an app
104
+ // installed no client, which no framework boot leaves true (`runtime-queue.ts`).
105
+ ...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
106
+ });
107
+ acquired.push(() => running.stop());
108
+ return {
109
+ kind: 'served',
110
+ role,
111
+ url: running.url,
112
+ buildId,
113
+ running,
114
+ runtime,
115
+ async stop() {
116
+ await running.stop();
117
+ await runtime.stop();
118
+ // Last: the exporters outlive the roles they were recording, so the drain's own spans and
119
+ // the final counter snapshot still have somewhere to go.
120
+ stopOtlp();
121
+ },
122
+ };
123
+ }
124
+
125
+ /** What only the web role serves: its islands, its documents and every route beside them. */
126
+ async function webSurface(
127
+ options: ServeOptions,
128
+ runtime: RunningServices,
129
+ buildId: string,
130
+ ): Promise<{ readonly routes: readonly Route[]; readonly themeCsp: string }> {
131
+ // Read from the store `x build --target docker` wrote and VERIFIED against this app and this
132
+ // runtime (`island-store.ts`); built here only when there is no store to trust, which is correct
133
+ // and slower, and logged with its reason.
134
+ const islands = await loadOrBuildIslands(options.root);
135
+ // The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
136
+ // on a laptop and absent in the image is exactly the dev/prod difference this file exists to
137
+ // prevent, and it is the one an operator cannot see without installing the app.
138
+ const pwa = await loadPwaArtifacts(options.root);
139
+ const theme = themeBoot(await loadThemeMode(options.root));
140
+ // The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
141
+ // Before the service worker, which precaches those scripts.
142
+ const sync = await pageSync(options.root, options.env, buildId);
143
+ // The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
144
+ // the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
145
+ // route added here cannot be missing from the precache manifest.
146
+ const serviceWorker =
147
+ pwa === undefined
148
+ ? undefined
149
+ : serviceWorkerArtifacts({
150
+ pwa,
151
+ buildId,
152
+ routes: describeRoutes(),
153
+ islands,
154
+ styles: styleBundle(),
155
+ scripts: sync.scripts,
156
+ });
157
+ // The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
158
+ const mcpMount = await mountAppMcp(options.root);
159
+ const routes: readonly Route[] = [
160
+ ...apiRoutes(),
161
+ ...mcpMount.routes,
162
+ ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
163
+ ...assetRoutes({
164
+ root: options.root,
165
+ storage: runtime.storage,
166
+ ...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
167
+ ...(pwa === undefined ? {} : { pwa }),
168
+ }),
169
+ ...storageRoutes({ storage: runtime.storage }),
170
+ ...islandRoutes(() => islands),
171
+ // The surface stylesheets the documents link. Built from the registry the `loadApp` above
172
+ // filled, so this process serves exactly the CSS it renders against.
173
+ ...styleRoutes(() => styleBundle()),
174
+ // The page's one socket: its worker script, served beside the islands for their reason.
175
+ ...sync.routes,
176
+ ...appRoutes({
177
+ buildId,
178
+ resolveIsland: (file) => islands.resolverFor(file),
179
+ sync: sync.head,
180
+ persisted: sync.persisted,
181
+ themeHead: theme.head,
182
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
183
+ // Only when a store was supplied. `createIsrController` defaults to a per-process memory
184
+ // store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
185
+ // the fleet while the other eleven keep serving the page it just invalidated.
186
+ ...(options.runtime?.isrStore === undefined
187
+ ? {}
188
+ : { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
189
+ }),
190
+ ];
191
+ return { routes, themeCsp: theme.cspSource };
192
+ }
@@ -0,0 +1,24 @@
1
+ // `app.config.ts`'s `drain` section, read for the production boot — the same import every other
2
+ // config reader here makes (`theme-boot.ts`, `app-auth.ts`), because the config is a module.
3
+
4
+ import type { DrainConfig } from '@ultimat3/core';
5
+ import { APP_CONFIG_EXPORT } from './app-auth';
6
+ import { APP_CONFIG_FILE } from './app-root';
7
+
8
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
9
+ typeof value === 'object' && value !== null;
10
+
11
+ /**
12
+ * The declared section, or `undefined` when the app has none (a config not built by
13
+ * `defineConfig`, or no config file): `@ultimat3/http` then keeps core's own default. A value that
14
+ * is not a number is left for core to refuse, where the rule lives, rather than dropped here.
15
+ */
16
+ export async function loadDrainConfig(root: string): Promise<Partial<DrainConfig> | undefined> {
17
+ const path = `${root}/${APP_CONFIG_FILE}`;
18
+ if (!(await Bun.file(path).exists())) return undefined;
19
+ const module = (await import(path)) as Record<string, unknown>;
20
+ const config = module[APP_CONFIG_EXPORT];
21
+ const drain = isRecord(config) ? config['drain'] : undefined;
22
+ if (!isRecord(drain) || !Object.hasOwn(drain, 'readinessGraceMs')) return undefined;
23
+ return { readinessGraceMs: drain['readinessGraceMs'] as number };
24
+ }
@@ -0,0 +1,6 @@
1
+ // `@ultimat3/cli/serve`: what a container's `apps/web/server.ts` imports, and nothing else. The
2
+ // `@ultimat3/cli` barrel is 1,349 modules — every command, every template, the e2e driver — and a
3
+ // production process that booted through it loaded all of them to call one function.
4
+
5
+ export { runRole } from './serve';
6
+ export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve-types';
@@ -0,0 +1,116 @@
1
+ // What a container's environment decides about a boot: which role, which port, which interface,
2
+ // and where errors are reported. Split from `serve.ts`, which re-exports every name here.
3
+
4
+ import type { Role } from '@ultimat3/core';
5
+ import { configureErrorReporting, isRole, ROLES, sentryErrorReporter } from '@ultimat3/core';
6
+ import { PortInvalidError, RoleUnknownError } from './errors';
7
+ import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
8
+ import type { WebBinding } from './role-start';
9
+ import type { Env } from './runtime-bindings';
10
+
11
+ export const DEFAULT_PORT = 3000;
12
+
13
+ /**
14
+ * Every interface, which is what a role binds when nothing says otherwise: a container bound to
15
+ * loopback is unreachable through its own port mapping. `HOST` and `ServeOptions.hostname` are the
16
+ * two ways of saying otherwise — see `hostnameFromEnv`.
17
+ */
18
+ export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
19
+
20
+ /**
21
+ * The interface the `web` and `sync` roles bind, and the metrics endpoint with them (`WebBinding`
22
+ * is one decision). Read the way `PORT` is: empty or whitespace is the default.
23
+ *
24
+ * Exists because a container had exactly one binding, `0.0.0.0`, and an app whose auth mode is
25
+ * "nobody logs in, one implicit actor" must refuse a public interface — so it could not run in a
26
+ * container at all. `HOST=127.0.0.1` is unreachable through `docker run -p` (the proxy connects to
27
+ * the container's bridge address, never its loopback); it is reachable where the container shares
28
+ * the host's network namespace (`--network host`), or through a sidecar and `ssh -L` inside it —
29
+ * which is the exposure such an app wants. Not `HOSTNAME`: Docker sets that to the container id.
30
+ */
31
+ export function hostnameFromEnv(env: Env): string {
32
+ const raw = env['HOST']?.trim();
33
+ return raw === undefined || raw.length === 0 ? CONTAINER_BINDING.hostname : raw;
34
+ }
35
+
36
+ /** What `serveApp` hands `startRoles`: the caller's hostname, else `HOST`, else every interface. */
37
+ export const containerBinding = (env: Env, hostname?: string): WebBinding => ({
38
+ dev: false,
39
+ hostname: hostname ?? hostnameFromEnv(env),
40
+ });
41
+
42
+ /**
43
+ * `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
44
+ * to `web` would start a process that serves nothing the operator asked for and reports healthy.
45
+ */
46
+ export function roleFromEnv(env: Env): Role {
47
+ const raw = env['ROLE'] ?? 'web';
48
+ if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
49
+ return raw;
50
+ }
51
+
52
+ /**
53
+ * `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
54
+ * partially-parsed port is a deploy that binds somewhere nobody asked for.
55
+ */
56
+ function portValue(env: Env, name: string, fallback: number): number {
57
+ const raw = env[name];
58
+ if (raw === undefined || raw.trim().length === 0) return fallback;
59
+ const port = Number(raw.trim());
60
+ if (!Number.isInteger(port) || port < 0 || port > 65_535)
61
+ throw new PortInvalidError({ value: raw, name });
62
+ return port;
63
+ }
64
+
65
+ /** Every PaaS injects `PORT` and routes traffic to exactly it. */
66
+ export function portFromEnv(env: Env): number {
67
+ return portValue(env, 'PORT', DEFAULT_PORT);
68
+ }
69
+
70
+ /**
71
+ * The scrape port, deliberately its own env var and not `PORT + n`: an operator who moves the app
72
+ * port must not silently move the port their Prometheus is configured against, and the roles that
73
+ * set no `PORT` at all — `worker`, `scheduler`, `replicator` — still need this one.
74
+ */
75
+ export function metricsPortFromEnv(env: Env): number {
76
+ return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
77
+ }
78
+
79
+ /**
80
+ * The scrape port a boot uses, given the app port it already resolved. One expression, and it is
81
+ * exported because `x dev` is the second caller: `cmd-dev.ts` passed no `metricsPort` at all, so
82
+ * `METRICS_PORT` was honoured in the container and ignored on a laptop — the dev/prod parity break
83
+ * `role-start.ts`'s own header forbids, and a second copy of this rule would be the same break
84
+ * one edit later.
85
+ *
86
+ * An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
87
+ * fixed 9090 would fail the next suite to boot beside it. An environment that names the port still
88
+ * wins — that is the deploy talking.
89
+ */
90
+ export const metricsPortFor = (env: Env, port: number, override?: number): number =>
91
+ override ?? (port === 0 && env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(env));
92
+
93
+ /**
94
+ * The one env var that turns error monitoring on, and the only vendor-shaped name in the boot
95
+ * path. Not a platform primitive (axiom 7): the value is a URL to whatever the operator runs, the
96
+ * wire format behind it is documented and self-hostable, and `SENTRY_DSN` is what every monitor
97
+ * that speaks it already documents — inventing a second spelling would mean an operator's existing
98
+ * tooling sets a variable this framework ignores. Exactly the precedent
99
+ * `OTEL_EXPORTER_OTLP_ENDPOINT` already sets in `docker/helm/values.yaml`.
100
+ */
101
+ export const ERROR_DSN_KEY = 'SENTRY_DSN';
102
+
103
+ /**
104
+ * Switch reporting on for this process. Unset DSN leaves core's no-op reporter in place, so a
105
+ * laptop and a CI run pay nothing and page nobody — and the release every event carries is the
106
+ * build id this boot already computed, never a second identity for the same deploy.
107
+ */
108
+ export function configureReporting(env: Env, buildId: string): void {
109
+ const dsn = env[ERROR_DSN_KEY]?.trim();
110
+ configureErrorReporting({
111
+ release: buildId,
112
+ // A malformed DSN throws here, at boot, rather than at the first outage: a monitor that was
113
+ // never connected looks exactly like an app that never failed.
114
+ ...(dsn === undefined || dsn.length === 0 ? {} : { reporter: sentryErrorReporter({ dsn }) }),
115
+ });
116
+ }
@@ -0,0 +1,55 @@
1
+ // The shapes a production boot takes and answers. Split from `serve.ts`, which re-exports them.
2
+
3
+ import type { Role } from '@ultimat3/core';
4
+ import type { DriftReport, MigrationReport } from '@ultimat3/db';
5
+ import type { RunningRoles } from './role-start';
6
+ import type { Env } from './runtime-bindings';
7
+ import type { RuntimeOverrides } from './runtime-overrides';
8
+ import type { RunningServices } from './runtime-services';
9
+
10
+ export interface ServeOptions {
11
+ readonly root: string;
12
+ readonly env: Env;
13
+ /** Overrides `ROLE`; `runRole` reads the environment when this is absent. */
14
+ readonly role?: Role;
15
+ /** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
16
+ readonly port?: number;
17
+ /** Overrides `METRICS_PORT`, on the same terms. */
18
+ readonly metricsPort?: number;
19
+ /**
20
+ * Overrides `HOST`: the interface the HTTP roles bind. An app that must never answer on a public
21
+ * interface passes `'127.0.0.1'` here rather than trusting the deployment to set the variable.
22
+ */
23
+ readonly hostname?: string;
24
+ /**
25
+ * The drivers this deployment supplies instead of the ones the environment would select.
26
+ *
27
+ * This field is why `apps/web/server.ts` can stay three lines and still run a custom queue, a
28
+ * shared ISR store or an app's own middleware. Before it there was nowhere to hand the framework
29
+ * a driver, so the only way was an ambient setter from an app module — which `loadApp` imports
30
+ * AFTER `startServices` has captured its own, giving a process that enqueues to one queue and
31
+ * claims from another. `startRoles` now refuses that split outright.
32
+ */
33
+ readonly runtime?: RuntimeOverrides;
34
+ }
35
+
36
+ export interface ServedApp {
37
+ readonly kind: 'served';
38
+ readonly role: Role;
39
+ /** `http://…` for the web role; null for the roles that open no HTTP socket. */
40
+ readonly url: string | null;
41
+ readonly buildId: string;
42
+ readonly running: RunningRoles;
43
+ readonly runtime: RunningServices;
44
+ stop(): Promise<void>;
45
+ }
46
+
47
+ export interface MigratedApp {
48
+ readonly kind: 'migrated';
49
+ readonly role: 'migrate';
50
+ readonly report: MigrationReport;
51
+ /** The post-condition: the live schema against the ledger this run just wrote. */
52
+ readonly drift: DriftReport;
53
+ }
54
+
55
+ export type StartedApp = ServedApp | MigratedApp;