@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
package/src/cmd-verify.ts CHANGED
@@ -9,13 +9,14 @@
9
9
 
10
10
  import { nearestName } from '@ultimat3/core';
11
11
  import { requireAppRoot } from './app-root';
12
+ import { verifySpec } from './cmd-verify-spec';
12
13
  import type { CliCommand, CommandContext } from './command';
13
14
  import { BadFlagError } from './errors';
14
15
  import { readIntFlag } from './flag-number';
15
16
  import type { CommandResult } from './output';
16
17
  import type { ParsedArgs } from './parse';
17
18
  import { flagString } from './parse';
18
- import { WORKER_CEILING, WORKER_FLOOR, WORKER_OVERSUBSCRIBE } from './test-workers';
19
+ import { WORKER_CEILING, WORKER_FLOOR } from './test-workers';
19
20
  import { VERIFY_STEPS } from './verify-checks';
20
21
  import { runVerify } from './verify-run';
21
22
  import type { VerifyStepName } from './verify-step';
@@ -28,28 +29,7 @@ export { VERIFY_STEPS } from './verify-checks';
28
29
  export { runVerify } from './verify-run';
29
30
 
30
31
  export const verifyCommand: CliCommand = {
31
- spec: {
32
- name: 'verify',
33
- summary: 'the gate: typecheck, lint, boundaries, all tests, drift, contract, budgets',
34
- usage: 'x verify [--only <step>] [--workers N] [--json]',
35
- requiresApp: true,
36
- // Two flags, and only one of them narrows. `--workers` changes how wide the test steps
37
- // spread, never which steps run. `--only` runs one step and says so in both renderers —
38
- // never silently, which is the whole of what makes it safe to have.
39
- flags: [
40
- {
41
- name: 'workers',
42
- type: 'string',
43
- summary: `test processes per parallel step (default: ${WORKER_OVERSUBSCRIBE}x CPUs, min ${WORKER_FLOOR}, max ${WORKER_CEILING})`,
44
- },
45
- {
46
- name: 'only',
47
- type: 'string',
48
- summary:
49
- 'run ONE step by name — an iteration loop, NOT A GATE RUN; the gate is this command with no flag',
50
- },
51
- ],
52
- },
32
+ spec: verifySpec,
53
33
  async run(ctx: CommandContext): Promise<CommandResult> {
54
34
  const root = requireAppRoot('verify', ctx.cwd).dir;
55
35
  // Both readers before the run: an unrunnable flag must be refused in milliseconds, not after
package/src/db-seed.ts CHANGED
@@ -16,6 +16,7 @@ import { BadFlagError } from './errors';
16
16
  import type { Finding, JsonValue } from './output';
17
17
  import { findingFrom } from './output';
18
18
  import { hasPathSegment } from './path-segments';
19
+ import { isTest } from './source-files';
19
20
  import { renderTable } from './table';
20
21
 
21
22
  /**
@@ -102,10 +103,11 @@ export async function discoverSeeds(root: string): Promise<SeedDiscovery> {
102
103
  const seen = new Set<string>();
103
104
  for (const pattern of SEED_GLOBS) {
104
105
  for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
105
- if (hasPathSegment(absolute, 'node_modules') || absolute.includes('.test.')) continue;
106
+ const file = relative(root, absolute).split(sep).join('/');
107
+ // Root-relative, as `loadApp` tests it: an absolute path under `my.test.app/` is no test.
108
+ if (hasPathSegment(absolute, 'node_modules') || isTest(file)) continue;
106
109
  if (seen.has(absolute)) continue;
107
110
  seen.add(absolute);
108
- const file = relative(root, absolute).split(sep).join('/');
109
111
  let module: Record<string, unknown>;
110
112
  try {
111
113
  module = (await import(absolute)) as Record<string, unknown>;
@@ -22,14 +22,14 @@ import { json as jsonResponse } from '@ultimat3/http';
22
22
  import type { MemoryMailDriver } from '@ultimat3/mail';
23
23
  import { isMemoryDriver } from '@ultimat3/mail';
24
24
  import type { Manifest } from '@ultimat3/manifest';
25
- import { checkAppBoundaries } from './app-boundaries';
26
25
  import { appManifest, readAppManifest } from './app-manifest';
26
+ import { appBoundaryFindings } from './boundary-findings';
27
27
  import type { StatementLedger } from './dev-n-plus-one';
28
28
  import { devPolicyMatrix } from './dev-policy';
29
- import type { RunningServices } from './dev-runtime';
30
- import type { DevServices } from './dev-services';
31
29
  import type { TraceRecorder } from './dev-traces';
32
30
  import type { Finding } from './output';
31
+ import type { DevServices } from './runtime-bindings';
32
+ import type { RunningServices } from './runtime-services';
33
33
  import { loopFacts } from './statement-loop';
34
34
 
35
35
  export interface DevStatus {
@@ -195,7 +195,7 @@ const boundariesPanel = (input: DevDashboardInput): DevPanel<BoundariesPanelData
195
195
  titleKey: 'dev.panel.boundaries.title',
196
196
  questionKey: 'dev.panel.boundaries.question',
197
197
  async data(): Promise<BoundariesPanelData> {
198
- return { findings: await checkAppBoundaries(input.root) };
198
+ return { findings: await appBoundaryFindings(input.root) };
199
199
  },
200
200
  });
201
201
 
package/src/dev-lock.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  // The lock file is what makes the second one nameable at all: nothing else in the process can tell
15
15
  // "another dev server owns this directory" from "the database is broken".
16
16
 
17
- import { closeSync, mkdirSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
17
+ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { stringField, UltimateError } from '@ultimat3/core';
20
20
  import { exec, type Runner } from './exec';
@@ -75,11 +75,27 @@ export const isProcessAlive = (pid: number): boolean => {
75
75
  }
76
76
  };
77
77
 
78
+ /**
79
+ * The lock a LIVE `x dev` holds on this state directory, or `undefined` — for a command that must
80
+ * not run beside one (`x db reset` deletes the data directory that process has open). A missing,
81
+ * unreadable or stale lock is no holder: only `preflight` may clear one.
82
+ */
83
+ export const liveDevLock = (stateDir: string): DevLock | undefined => {
84
+ let raw: string;
85
+ try {
86
+ raw = readFileSync(lockPath(stateDir), 'utf-8');
87
+ } catch {
88
+ return undefined;
89
+ }
90
+ const lock = parseLock(raw);
91
+ return lock !== null && isProcessAlive(lock.pid) ? lock : undefined;
92
+ };
93
+
78
94
  /**
79
95
  * Refused before boot, so the failure names the process holding the directory.
80
96
  *
81
97
  * The lock is on the CHECKOUT, not on the database, and the cause says so. `x dev` is one process
82
- * running every role (`dev-roles.ts`), so a second one is unsupported whatever the services are.
98
+ * running every role (`role-start.ts`), so a second one is unsupported whatever the services are.
83
99
  * The embedded-Postgres sentence is appended only when the database actually IS embedded — with an
84
100
  * external `DATABASE_URL` it would name a mechanism that is not in play, which is the same defect
85
101
  * as the message this whole module replaced.
@@ -0,0 +1,21 @@
1
+ // Which port `x dev` binds: `--port`, else `PORT` from the environment, else 3000. The flag used to
2
+ // declare `default: '3000'`, and a default is indistinguishable from a caller's value — so the
3
+ // scaffold's own `.env.development` `PORT=` was read by nothing and every app booted on 3000.
4
+
5
+ import { PORT_RANGE, readIntFlag } from './flag-number';
6
+ import type { ParsedArgs } from './parse';
7
+ import { portFromEnv } from './serve';
8
+
9
+ export const DEFAULT_DEV_PORT = 3000;
10
+
11
+ /** The `metricsPortFor` shape: an explicit value, then the env, then the constant. */
12
+ export const devPortFor = (
13
+ args: ParsedArgs,
14
+ env: Readonly<Record<string, string | undefined>>,
15
+ ): number =>
16
+ readIntFlag(args, {
17
+ name: 'port',
18
+ command: 'dev',
19
+ ...PORT_RANGE,
20
+ example: `x dev --port ${DEFAULT_DEV_PORT}`,
21
+ }) ?? portFromEnv(env);
@@ -0,0 +1,119 @@
1
+ // The route table `x dev` serves, in mount order: the dashboard, the API, the assets a document
2
+ // names, the island and sync-worker scripts, and the app's pages last. Split from `cmd-dev.ts` at its
3
+ // 500-line ceiling; `serve.ts` composes the production table from the same builders.
4
+
5
+ import type { Route } from '@ultimat3/http';
6
+ import { describeRoutes } from '@ultimat3/render';
7
+ import type { Storage } from '@ultimat3/storage';
8
+ import { apiRoutes } from './api-routes';
9
+ import { mountAppMcp } from './app-mcp';
10
+ import type { DevDashboardInput } from './dev-dashboard';
11
+ import { devDashboardRoutes } from './dev-dashboard';
12
+ import { errorPageStyleSources } from './error-page-csp';
13
+ import type { IslandBundle } from './island-bundle';
14
+ import { islandHarnessRoutes } from './island-harness-route';
15
+ import { islandRoutes } from './island-routes';
16
+ import { loadIslandStates } from './island-states-load';
17
+ import { pageSync } from './page-sync';
18
+ import { loadPwaArtifacts } from './pwa-artifacts';
19
+ import { assetRoutes } from './runtime-assets';
20
+ import { appRoutes } from './runtime-render';
21
+ import { storageRoutes } from './runtime-storage';
22
+ import { styleBundle } from './style-bundle';
23
+ import { styleRoutes } from './style-routes';
24
+ import { serviceWorkerArtifacts } from './sw-artifacts';
25
+ import { serviceWorkerRoutes } from './sw-routes';
26
+ import type { ThemeBoot } from './theme-boot';
27
+ import { loadThemeMode, themeBoot } from './theme-boot';
28
+
29
+ export interface DevRouteTableInput {
30
+ readonly root: string;
31
+ readonly env: Readonly<Record<string, string | undefined>>;
32
+ readonly buildId: string;
33
+ readonly storage: Storage;
34
+ readonly dashboard: DevDashboardInput;
35
+ /** A getter: the watcher tick rebuilds the islands, and a captured bundle would serve the first. */
36
+ readonly islands: () => IslandBundle;
37
+ }
38
+
39
+ export interface DevRouteTable {
40
+ readonly routes: readonly Route[];
41
+ /** The theme boot, whose `cspSource` the web role admits. */
42
+ readonly theme: ThemeBoot;
43
+ /** The app's own error pages' inline styles, admitted the same way. */
44
+ readonly errorStyles: readonly string[];
45
+ /** Where the app's MCP endpoint was mounted, or `undefined`. */
46
+ readonly mcpPath: string | null;
47
+ }
48
+
49
+ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRouteTable> {
50
+ // Resolved once, before the first route. `undefined` for an app that is not installable: nothing
51
+ // is mounted, and the 0kb baseline is not spent on a `<link>` to a file that does not exist.
52
+ const pwa = await loadPwaArtifacts(input.root);
53
+ const theme = themeBoot(await loadThemeMode(input.root));
54
+ // The same call `serve.ts` makes, so the two boots cannot serve different sync targets.
55
+ const sync = await pageSync(input.root, input.env, input.buildId);
56
+ const errorStyles = await errorPageStyleSources(input.root);
57
+ // Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
58
+ // changes under a page it controls is the update path, and one per keystroke exercises it per save.
59
+ const serviceWorker =
60
+ pwa === undefined
61
+ ? undefined
62
+ : serviceWorkerArtifacts({
63
+ pwa,
64
+ buildId: input.buildId,
65
+ routes: describeRoutes(),
66
+ islands: input.islands(),
67
+ styles: styleBundle(),
68
+ scripts: sync.scripts,
69
+ });
70
+
71
+ // The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
72
+ // call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
73
+ // one of them asked. Warned once here when `expose` is true and nothing can be mounted.
74
+ const mcpMount = await mountAppMcp(input.root);
75
+ const routes: readonly Route[] = [
76
+ ...devDashboardRoutes(input.dashboard),
77
+ // The same API table the container serves: a read that answers here and 404s in production
78
+ // is exactly the drift one composition exists to prevent.
79
+ ...apiRoutes(),
80
+ ...mcpMount.routes,
81
+ // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
82
+ // variants every `srcset` promises. Mounted before the app's own routes so a page route can
83
+ // never shadow `/icons` or `/media`.
84
+ ...assetRoutes({
85
+ root: input.root,
86
+ storage: input.storage,
87
+ ...(pwa === undefined ? {} : { pwa }),
88
+ }),
89
+ ...storageRoutes({ storage: input.storage }),
90
+ // The chunks the documents below name. Mounted before the app's routes for the reason
91
+ // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
92
+ ...islandRoutes(() => input.islands()),
93
+ // And the stylesheet every one of those documents links. Read through the getter for the
94
+ // reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
95
+ // captured at boot would answer 404 for the href the document now carries.
96
+ ...styleRoutes(() => styleBundle()),
97
+ // `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
98
+ // lives here rather than in a second server because everything it needs is in THIS process:
99
+ // the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
100
+ // may have. The states are read per REQUEST — an author editing a state and re-running the
101
+ // command must not need a restart to see it.
102
+ ...islandHarnessRoutes({
103
+ islands: () => input.islands(),
104
+ states: () => loadIslandStates(input.root),
105
+ }),
106
+ ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
107
+ ...sync.routes,
108
+ ...appRoutes({
109
+ buildId: input.buildId,
110
+ resolveIsland: (file) => input.islands().resolverFor(file),
111
+ sync: sync.head,
112
+ persisted: sync.persisted,
113
+ themeHead: theme.head,
114
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
115
+ }),
116
+ ];
117
+
118
+ return { routes, theme, errorStyles, mcpPath: mcpMount.path };
119
+ }
package/src/dispatch.ts CHANGED
@@ -16,6 +16,7 @@ import { exitCodeFor, findingFrom, render } from './output';
16
16
  import type { ParsedArgs } from './parse';
17
17
  import { parseArgs, wantsJson } from './parse';
18
18
  import { commandFor, SPECS } from './registry';
19
+ import { rootEnvAdditions } from './root-env';
19
20
 
20
21
  export interface DispatchOptions {
21
22
  readonly argv: readonly string[];
@@ -120,14 +121,20 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
120
121
  ? { ...args, command: 'help', positionals: [args.command] }
121
122
  : args;
122
123
 
124
+ const cwd = resolveCwd(
125
+ options.cwd,
126
+ typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
127
+ );
128
+ // The ROOT's `.env*`, not the cwd's: Bun loaded the cwd's at startup, and a command run from
129
+ // `apps/web` finds its root by walking up. Written into the process env too when that IS the env
130
+ // passed in, because a package reading `Bun.env` directly must see what the command sees.
131
+ const added = await rootEnvAdditions(cwd, options.env);
132
+ if (options.env === Bun.env) Object.assign(Bun.env, added);
123
133
  const ctx: CommandContext = {
124
134
  args: helpArgs,
125
- cwd: resolveCwd(
126
- options.cwd,
127
- typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
128
- ),
135
+ cwd,
129
136
  runner: options.runner ?? exec,
130
- env: options.env,
137
+ env: options.env === Bun.env ? options.env : { ...added, ...options.env },
131
138
  bunVersion: options.bunVersion,
132
139
  ...(options.invocation === undefined ? {} : { invocation: options.invocation }),
133
140
  };
@@ -25,7 +25,7 @@ export const definesRootCustomProperties = (css: string): boolean => ROOT_CUSTOM
25
25
 
26
26
  export interface SurfaceDocument {
27
27
  readonly surface: Surface;
28
- /** Exactly the CSS `dev-render.ts` would inline into a document on this surface. */
28
+ /** Exactly the CSS `runtime-render.ts` would inline into a document on this surface. */
29
29
  readonly css: string;
30
30
  }
31
31
 
package/src/drift.ts CHANGED
@@ -11,7 +11,10 @@
11
11
 
12
12
  import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
- import { ERROR_DOCS_URL } from '@ultimat3/core';
14
+ // `canonicalJson` is core's, the one serializer every content hash in the framework uses. A private
15
+ // copy here wrote `"key":null` for an unset optional field where core drops the key, so the schema
16
+ // hash agreed with nothing else (plan 101 slice 18 h; 22.0.0 re-stamps every sidecar once).
17
+ import { canonicalJson, ERROR_DOCS_URL } from '@ultimat3/core';
15
18
  import { describeEntities } from '@ultimat3/entity';
16
19
  import { countDeclaredEntities } from './app-entities';
17
20
  import { loadApp } from './app-load';
@@ -23,23 +26,6 @@ import type { Finding } from './output';
23
26
  export const DB_PACKAGE = join('packages', 'db');
24
27
  const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
25
28
 
26
- /**
27
- * Canonical JSON: object keys sorted, arrays in their own order. The registry's description is a
28
- * BUILD INPUT committed to disk as a hash, so a field reordered inside `describe()` upstream would
29
- * otherwise move every app's hash and report drift over a framework upgrade nobody made.
30
- */
31
- function canonicalJson(value: unknown): string {
32
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
33
- if (typeof value === 'object' && value !== null) {
34
- const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
35
- a < b ? -1 : a > b ? 1 : 0,
36
- );
37
- return `{${entries.map(([key, held]) => `${JSON.stringify(key)}:${canonicalJson(held)}`).join(',')}}`;
38
- }
39
- // `undefined` has no JSON form and an optional field left unset must hash as absent, not throw.
40
- return JSON.stringify(value) ?? 'null';
41
- }
42
-
43
29
  /**
44
30
  * What the app's entities declare, as the registry describes them — the half `SCHEMA_GLOB` cannot
45
31
  * see. `x new` puts an entity at `apps/web/app/<feature>/entity.ts` and `packages/db/src/schema.ts`
@@ -46,22 +46,29 @@ export const CATALOG_PACKAGES = [
46
46
  ] as const;
47
47
 
48
48
  /**
49
- * The two packages the catalog may import WITHOUT `@ultimat3/cli` declaring them: they reach for a
50
- * JSX runtime an app has and a bare CLI process does not, so a hard dependency would make the CLI
51
- * uninstallable where the codes are merely absent today. Every other entry above is a real runtime
52
- * import and must be a declared dependency — `error-catalog.test.ts` holds the list to exactly that,
53
- * because an undeclared one resolves through workspace symlinks here and through nothing in an
54
- * installed app, where `x errors explain X_FLAG_EXPIRED` then refuses a code the wiki promises.
49
+ * The packages the catalog may import WITHOUT `@ultimat3/cli` declaring them. `admin` and `ui`
50
+ * reach for a JSX runtime an app has and a bare CLI process does not, so a hard dependency would
51
+ * make the CLI uninstallable where the codes are merely absent today. `scraping` is an app's own
52
+ * choice since 22.0.0: `x shot` drives Chrome over raw CDP and imports nothing from it, so its codes
53
+ * answer where the app installed it and are reported `unavailable` where it did not. Every other
54
+ * entry above is a real runtime import and must be a declared dependency — `error-catalog.test.ts`
55
+ * holds the list to exactly that, because an undeclared one resolves through workspace symlinks
56
+ * here and through nothing in an installed app, where `x errors explain X_FLAG_EXPIRED` then
57
+ * refuses a code the wiki promises.
55
58
  */
56
- export const CATALOG_OPTIONAL_HOSTS: readonly string[] = ['@ultimat3/admin', '@ultimat3/ui'];
59
+ export const CATALOG_OPTIONAL_HOSTS: readonly string[] = [
60
+ '@ultimat3/admin',
61
+ '@ultimat3/scraping',
62
+ '@ultimat3/ui',
63
+ ];
57
64
 
58
65
  export interface ErrorCatalog {
59
66
  /** Packages whose codes are now registered. */
60
67
  readonly loaded: readonly string[];
61
68
  /**
62
69
  * Packages this process could not *resolve*, so their codes are absent from the answer. The one
63
- * tolerated case is the optional host: `CATALOG_OPTIONAL_HOSTS` reach for a JSX
64
- * runtime an app has and a bare CLI process does not, and a list silently missing their codes is
70
+ * tolerated case is the optional host (`CATALOG_OPTIONAL_HOSTS`), which a bare CLI process may
71
+ * not have, and a list silently missing their codes is
65
72
  * worse than one that says which packages are missing. A package that resolved and then threw is
66
73
  * a defect, not a host gap, and goes to `failed`.
67
74
  */
@@ -30,6 +30,9 @@ export const CLI_OWNED_ERROR_CODES = [
30
30
  // `code: STALE` here is invisible to every reader of the code set, and silence there is
31
31
  // permissive — the DRYer the author, the less the gate sees (#277).
32
32
  'X_ERROR_CODE_UNRESOLVED',
33
+ // The fourth: registered, presented as live, and constructed by nothing — the state a code is left
34
+ // in when its last thrower moves on. The reference row has to say so in words.
35
+ 'X_ERROR_CODE_UNTHROWN',
33
36
  // Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
34
37
  // `x errors explain X_TYPECHECK_FAILED` refused a code `x verify` had just printed. A finding
35
38
  // carries an `X_*` code to the same reader a throw does; the registry is what makes that code
@@ -175,29 +178,26 @@ export const CLI_OWNED_ERROR_CODES = [
175
178
  'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
176
179
  'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
177
180
  'X_SHOT_ISLAND_MISSING',
178
- // The browser-backed e2e driver — `e2e-driver.ts` and the three modules under it. Owned by the
179
- // CLI because the ADAPTER is: `@ultimat3/testing` declares `PageLike` and may not import a
180
- // browser, `@ultimat3/scraping` owns the browser and may not import the harness, and neither
181
- // package can name a failure that only exists where the two meet.
182
- 'X_E2E_EVALUATE_UNSUPPORTED',
183
- 'X_E2E_EVALUATE_CAPTURED',
184
- 'X_E2E_EVALUATE_THREW',
185
- 'X_E2E_LOCATOR_EMPTY',
186
- 'X_E2E_LOCATOR_AMBIGUOUS',
187
- 'X_E2E_SERVICE_WORKER_ABSENT',
188
- // The raw-CDP browser under that driver — `cdp-launch.ts`, `cdp-connection.ts`,
189
- // `cdp-e2e-page.ts`, `cdp-browser.ts`. Four codes and not one, because the four repairs differ:
190
- // install a browser, read the browser's own stderr, look at the page, raise a deadline.
191
- 'X_CDP_BROWSER_MISSING',
192
- 'X_CDP_LAUNCH_FAILED',
193
- 'X_CDP_CALL_FAILED',
194
- 'X_CDP_TIMEOUT',
181
+ // The raw-CDP shot page's own refusals (`cdp-shot-errors.ts` holds the classes): which host it
182
+ // may open, which element it was pointed at, and which key it was asked to press.
183
+ 'X_SHOT_HOST_REFUSED',
184
+ 'X_SHOT_ELEMENT_MISSING',
185
+ 'X_SHOT_ELEMENT_UNREADY',
186
+ 'X_SHOT_KEY_INVALID',
195
187
  'X_GH_UNAVAILABLE',
196
188
  'X_GH_NOT_AUTHENTICATED',
197
189
  'X_GH_COMMAND_FAILED',
198
190
  'X_GH_RESPONSE_INVALID',
199
191
  'X_PR_NOT_FOUND',
200
192
  'X_CI_RUN_NOT_FOUND',
193
+ 'X_APP_NAME_EMPTY',
194
+ 'X_APP_EMPTY',
195
+ 'X_BOUNDARY_SURFACE_IMPORT',
196
+ 'X_PERMISSION_UNGRANTED',
197
+ 'X_JOB_UNREGISTERED',
198
+ 'X_FEATURE_UNKNOWN',
199
+ 'X_ROUTE_ASYNC_PAGE',
200
+ 'X_BUDGET_PARAMS_UNDECLARED',
201
201
  ] as const;
202
202
 
203
203
  /**
@@ -262,6 +262,8 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
262
262
  X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
263
263
  X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
264
264
  X_ERROR_CODE_UNRESOLVED: 'an error code is written as a name this repository cannot resolve',
265
+ X_ERROR_CODE_UNTHROWN:
266
+ 'a registered error code is constructed by nothing and its reference row does not say so',
265
267
  X_FRAMEWORK_SCHEMA_FAILED: 'a framework table could not be created at boot',
266
268
  X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
267
269
  X_STORAGE_SECRET_DEV: 'upload grants would be signed with the shipped development key',
@@ -310,7 +312,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
310
312
  X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
311
313
  X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
312
314
  X_PACKAGE_DUPLICATED: 'two copies of one registry-holding framework package are installed',
313
- X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
315
+ X_SHOT_BROWSER_MISSING: '`x shot` has no browser to launch',
314
316
  X_UI_SHOT_ROUTE_UNKNOWN: 'a ui.* tool was asked for a path no route answers',
315
317
  X_UI_SHOT_ROUTE_UNBUDGETED: 'a ui.* tool refused a route that declares no budget.js',
316
318
  X_UI_INTERACT_STEPS_INVALID: 'a ui.interact step list is over its bounds or malformed',
@@ -324,22 +326,25 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
324
326
  X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
325
327
  X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
326
328
  X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
327
- X_E2E_EVALUATE_UNSUPPORTED: 'a page.evaluate() closure cannot be sent into the browser',
328
- X_E2E_EVALUATE_CAPTURED: 'a page.evaluate() closure named a binding the page does not have',
329
- X_E2E_EVALUATE_THREW: 'an expression an e2e page ran threw inside the browser',
330
- X_E2E_LOCATOR_EMPTY: 'an e2e locator matched no element',
331
- X_E2E_LOCATOR_AMBIGUOUS: 'an e2e locator matched more than one element and was asked to click',
332
- X_E2E_SERVICE_WORKER_ABSENT: 'no service worker took control of the page within the budget',
333
- X_CDP_BROWSER_MISSING: 'no Chrome or Chromium is installed for the e2e driver to launch',
334
- X_CDP_LAUNCH_FAILED: 'the browser started and never announced a DevTools endpoint',
335
- X_CDP_CALL_FAILED: 'the browser refused a DevTools call',
336
- X_CDP_TIMEOUT: 'a DevTools call did not answer inside its deadline',
329
+ X_SHOT_HOST_REFUSED: "x shot was asked to open a host outside the run's allow list",
330
+ X_SHOT_ELEMENT_MISSING: 'no element matched the selector before the deadline',
331
+ X_SHOT_ELEMENT_UNREADY: 'the element matched and never became ready to act on',
332
+ X_SHOT_KEY_INVALID: 'a key chord the browser cannot press',
337
333
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
338
334
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
339
335
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
340
336
  X_GH_RESPONSE_INVALID: "gh's output is not the shape the command reads",
341
337
  X_PR_NOT_FOUND: 'no pull request for this checkout',
342
338
  X_CI_RUN_NOT_FOUND: 'no workflow run for this branch',
339
+ X_APP_NAME_EMPTY: 'the app name has no letters or digits',
340
+ X_APP_EMPTY: 'an app with an app.config.ts registered no primitive',
341
+ X_BOUNDARY_SURFACE_IMPORT: 'a surface imports one SURFACE_SPECS does not allow',
342
+ X_PERMISSION_UNGRANTED: 'an action, query or route requires a permission no role grants',
343
+ X_JOB_UNREGISTERED: 'a job reached the manifest under its positional anonymous name',
344
+ X_FEATURE_UNKNOWN: 'x g --feature names a slice that does not exist',
345
+ X_ROUTE_ASYNC_PAGE: 'a route exports an async Page',
346
+ X_BUDGET_PARAMS_UNDECLARED:
347
+ 'a dynamic route with a budget declares no prerender() paths to weigh it by',
343
348
  };
344
349
 
345
350
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -34,9 +34,8 @@ const isStatus = (status: number): boolean =>
34
34
  /**
35
35
  * The app's page for one status, or `undefined`.
36
36
  *
37
- * Read per REQUEST, never cached at boot, for `favicon.ts`'s reason: `x dev` is a running process
38
- * an author drops a file into, and a reader that captured "there was none" at startup would keep
39
- * answering the framework's page until the server was restarted.
37
+ * One read. How often it is asked is `errorPageHook`'s decision: per request under `x dev`, for
38
+ * `favicon.ts`'s reason, and once per status in a container.
40
39
  */
41
40
  export async function errorPageOverride(root: string, status: number): Promise<string | undefined> {
42
41
  if (!isStatus(status)) return undefined;
@@ -48,11 +47,24 @@ export async function errorPageOverride(root: string, status: number): Promise<s
48
47
  * `ServerHooks.errorPage`, bound to one app root. Installed by `startWeb` so `x dev` and the
49
48
  * container cannot answer a browser differently — the rule `assetRoutes` already holds for
50
49
  * `/favicon.ico`.
50
+ *
51
+ * `perRequest` is `x dev`'s: an author drops a file into a running process. A container's image
52
+ * cannot change under it, so there each status is read ONCE and kept — per request, a burst of 500s
53
+ * during an outage paid a file read each, on the path that was already failing (plan 101, 12 g).
51
54
  */
52
- export const errorPageHook =
53
- (root: string) =>
54
- (status: number): Promise<string | undefined> =>
55
- errorPageOverride(root, status);
55
+ export const errorPageHook = (root: string, options: { readonly perRequest: boolean }) => {
56
+ if (options.perRequest) {
57
+ return (status: number): Promise<string | undefined> => errorPageOverride(root, status);
58
+ }
59
+ const read = new Map<number, Promise<string | undefined>>();
60
+ return (status: number): Promise<string | undefined> => {
61
+ const known = read.get(status);
62
+ if (known !== undefined) return known;
63
+ const once = errorPageOverride(root, status);
64
+ read.set(status, once);
65
+ return once;
66
+ };
67
+ };
56
68
 
57
69
  /**
58
70
  * The document that goes into a static export: the app's file if it has one, the framework's page