@ultimat3/cli 23.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 (296) hide show
  1. package/CLAUDE.md +28 -13
  2. package/README.md +32 -7
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +30 -36
  6. package/src/api-registration.ts +89 -0
  7. package/src/app-artifacts.ts +1 -1
  8. package/src/app-auth.ts +4 -27
  9. package/src/app-boundaries.ts +17 -4
  10. package/src/app-config-load.ts +51 -0
  11. package/src/app-env.ts +26 -25
  12. package/src/app-load.ts +38 -14
  13. package/src/app-manifest.ts +4 -10
  14. package/src/app-mcp.ts +31 -46
  15. package/src/app-name.ts +52 -0
  16. package/src/app-permissions-borrowed.ts +123 -0
  17. package/src/app-permissions-site.ts +48 -0
  18. package/src/app-permissions.ts +7 -17
  19. package/src/app-root.ts +9 -5
  20. package/src/bin.ts +3 -7
  21. package/src/browser-launcher.ts +4 -2
  22. package/src/budgets-charged.ts +60 -0
  23. package/src/budgets-scan.ts +76 -0
  24. package/src/budgets-unmeasured.ts +91 -0
  25. package/src/budgets.ts +117 -154
  26. package/src/cdp-shot-driver.ts +20 -1
  27. package/src/cdp-shot-watch.ts +35 -31
  28. package/src/ci-log.ts +0 -0
  29. package/src/cmd-auth-spec.ts +16 -0
  30. package/src/cmd-auth.ts +65 -0
  31. package/src/cmd-build-spec.ts +7 -1
  32. package/src/cmd-build.ts +151 -22
  33. package/src/cmd-db-backfill.ts +26 -13
  34. package/src/cmd-db-branch.ts +2 -2
  35. package/src/cmd-db.ts +10 -6
  36. package/src/cmd-deploy-compose.ts +94 -0
  37. package/src/cmd-deploy-drain.ts +48 -0
  38. package/src/cmd-deploy-helm.ts +7 -6
  39. package/src/cmd-deploy-start-first.ts +178 -0
  40. package/src/cmd-deploy.ts +158 -36
  41. package/src/cmd-dev.ts +18 -356
  42. package/src/cmd-doctor-spec.ts +2 -9
  43. package/src/cmd-doctor.ts +57 -80
  44. package/src/cmd-env.ts +55 -16
  45. package/src/cmd-generate.ts +89 -32
  46. package/src/cmd-help.ts +19 -6
  47. package/src/cmd-i18n.ts +21 -17
  48. package/src/cmd-jobs-spec.ts +7 -22
  49. package/src/cmd-jobs.ts +33 -140
  50. package/src/cmd-manifest-spec.ts +1 -1
  51. package/src/cmd-manifest.ts +6 -25
  52. package/src/cmd-mcp.ts +71 -22
  53. package/src/cmd-new.ts +36 -8
  54. package/src/cmd-planned.ts +24 -8
  55. package/src/cmd-routes.ts +15 -5
  56. package/src/cmd-secrets.ts +47 -6
  57. package/src/cmd-shot-matrix.ts +22 -7
  58. package/src/cmd-shot-spec.ts +10 -2
  59. package/src/cmd-shot.ts +46 -41
  60. package/src/cmd-tasks.ts +2 -2
  61. package/src/cmd-test-spec.ts +1 -7
  62. package/src/cmd-test.ts +14 -30
  63. package/src/cmd-verify.ts +4 -4
  64. package/src/coverage-lcov.ts +4 -2
  65. package/src/db-backfill.ts +2 -2
  66. package/src/db-replay-engine.ts +4 -10
  67. package/src/db-subscribes.ts +27 -16
  68. package/src/dev-boot.ts +290 -0
  69. package/src/dev-child-watch.ts +63 -12
  70. package/src/dev-dashboard-guard.ts +76 -0
  71. package/src/dev-dashboard.ts +56 -30
  72. package/src/dev-lock.ts +100 -31
  73. package/src/dev-rebuild.ts +80 -0
  74. package/src/dev-restart-retire.ts +46 -0
  75. package/src/dev-route-table.ts +30 -8
  76. package/src/dev-supervisor.ts +114 -22
  77. package/src/dev-traces.ts +53 -20
  78. package/src/dispatch.ts +17 -13
  79. package/src/doctor-app-url.ts +39 -0
  80. package/src/doctor-auth.ts +95 -0
  81. package/src/doctor-offline.ts +6 -5
  82. package/src/document-options.ts +7 -0
  83. package/src/drift-replica-identity.ts +42 -0
  84. package/src/drift.ts +29 -6
  85. package/src/error-catalog.ts +6 -9
  86. package/src/error-codes.ts +29 -3
  87. package/src/error-page-csp.ts +14 -15
  88. package/src/error-unthrown.ts +29 -11
  89. package/src/errors.ts +54 -83
  90. package/src/exec.ts +8 -0
  91. package/src/favicon.ts +2 -2
  92. package/src/fix-command.ts +9 -1
  93. package/src/framework-env.ts +148 -0
  94. package/src/framework-schema-apply.ts +78 -0
  95. package/src/framework-schema-stamp.ts +96 -0
  96. package/src/framework-schema.ts +25 -3
  97. package/src/generate-catalog-locales.ts +79 -0
  98. package/src/generate-files.ts +4 -3
  99. package/src/generate-kinds.ts +18 -0
  100. package/src/generate-plural.ts +50 -0
  101. package/src/generate-shadow.ts +95 -0
  102. package/src/generate-write.ts +47 -13
  103. package/src/gh-target.ts +12 -7
  104. package/src/hold.ts +56 -1
  105. package/src/i18n-audit.ts +25 -9
  106. package/src/i18n-index.ts +5 -6
  107. package/src/i18n-registration.ts +7 -9
  108. package/src/import-scan.ts +28 -3
  109. package/src/index.ts +2 -6
  110. package/src/island-bundle.ts +57 -79
  111. package/src/island-capture.ts +1 -1
  112. package/src/island-duplicate-refusal.ts +57 -0
  113. package/src/island-duplicates.ts +221 -0
  114. package/src/island-entry-missing.ts +64 -0
  115. package/src/island-harness-route.ts +20 -4
  116. package/src/island-harness-script.ts +23 -2
  117. package/src/island-identity.ts +17 -14
  118. package/src/island-package-dedupe.ts +150 -0
  119. package/src/island-realtime.ts +36 -16
  120. package/src/island-routes.ts +2 -2
  121. package/src/island-runtime.ts +241 -0
  122. package/src/island-sources.ts +105 -0
  123. package/src/island-states-load.ts +6 -0
  124. package/src/island-store.ts +90 -6
  125. package/src/jobs-json.ts +0 -26
  126. package/src/jobs-report.ts +36 -20
  127. package/src/local-cli-handoff.ts +90 -0
  128. package/src/loopback-host.ts +13 -0
  129. package/src/manifest-staleness.ts +62 -0
  130. package/src/mcp-errors.ts +20 -4
  131. package/src/mcp-host.ts +22 -14
  132. package/src/mcp-test-run.ts +38 -0
  133. package/src/mcp-ui-inspect.ts +3 -2
  134. package/src/mcp-ui-interact.ts +3 -2
  135. package/src/mcp-ui.ts +8 -7
  136. package/src/measure-scope.ts +15 -10
  137. package/src/messages.ts +29 -16
  138. package/src/metrics-endpoint.ts +28 -3
  139. package/src/migration-extensions.ts +1 -1
  140. package/src/otlp-export.ts +2 -2
  141. package/src/output.ts +24 -0
  142. package/src/page-navigation.ts +7 -23
  143. package/src/page-speculation.ts +19 -31
  144. package/src/parse.ts +0 -8
  145. package/src/permission-grants.ts +3 -3
  146. package/src/posix-path.ts +30 -0
  147. package/src/prerender-locales.ts +6 -4
  148. package/src/prerender-out.ts +34 -7
  149. package/src/prerender-report.ts +58 -0
  150. package/src/prerender-served.ts +52 -0
  151. package/src/prerender.ts +71 -103
  152. package/src/pwa-artifacts.ts +33 -94
  153. package/src/pwa-manifest-locales.ts +9 -9
  154. package/src/registry.ts +2 -0
  155. package/src/retry-memo.ts +1 -1
  156. package/src/revalidated-response.ts +4 -3
  157. package/src/role-realtime.ts +7 -1
  158. package/src/role-replicator.ts +124 -29
  159. package/src/role-start.ts +69 -85
  160. package/src/role-sync.ts +119 -25
  161. package/src/root-env.ts +157 -28
  162. package/src/route-islands.ts +69 -0
  163. package/src/runtime-admin.ts +4 -1
  164. package/src/runtime-assets.ts +26 -9
  165. package/src/runtime-bindings.ts +27 -7
  166. package/src/runtime-cache.ts +46 -55
  167. package/src/runtime-hooks.ts +2 -1
  168. package/src/runtime-idempotency-scope.ts +23 -0
  169. package/src/runtime-isr-outcome.ts +100 -0
  170. package/src/runtime-isr.ts +36 -0
  171. package/src/runtime-jobs.ts +76 -41
  172. package/src/runtime-live-feed.ts +1 -1
  173. package/src/runtime-mail.ts +26 -0
  174. package/src/runtime-mfa-warning.ts +29 -0
  175. package/src/runtime-notify-retention.ts +10 -46
  176. package/src/runtime-overrides.ts +4 -4
  177. package/src/runtime-purge.ts +38 -16
  178. package/src/runtime-queue.ts +43 -22
  179. package/src/runtime-realtime.ts +11 -45
  180. package/src/runtime-render.ts +136 -114
  181. package/src/runtime-replica.ts +3 -8
  182. package/src/runtime-services.ts +49 -26
  183. package/src/runtime-storage-upload.ts +2 -2
  184. package/src/runtime-storage.ts +13 -8
  185. package/src/schema-diff.ts +16 -0
  186. package/src/schema-drift.ts +45 -15
  187. package/src/secrets-rotation.ts +8 -9
  188. package/src/seo-routes.ts +80 -9
  189. package/src/serve-boot.ts +51 -12
  190. package/src/serve-drain.ts +11 -33
  191. package/src/serve-env.ts +9 -6
  192. package/src/serve-retire.ts +95 -0
  193. package/src/serve-web.ts +31 -15
  194. package/src/serve.ts +27 -10
  195. package/src/server-barrels.ts +6 -3
  196. package/src/shot-browser.ts +1 -1
  197. package/src/shot-locale.ts +6 -33
  198. package/src/shot-settle.ts +3 -3
  199. package/src/shot-theme.ts +1 -1
  200. package/src/shot-viewport.ts +152 -0
  201. package/src/signal-shred.ts +26 -12
  202. package/src/site-asset-routes.ts +2 -2
  203. package/src/site-assets.ts +3 -5
  204. package/src/site-config.ts +9 -48
  205. package/src/site-seo.ts +7 -5
  206. package/src/sitemap-lastmod.ts +5 -0
  207. package/src/solid-loader.ts +12 -3
  208. package/src/source-files.ts +19 -3
  209. package/src/static-report.ts +5 -0
  210. package/src/stored-object-headers.ts +43 -0
  211. package/src/style-routes.ts +2 -2
  212. package/src/sw-artifacts.ts +8 -8
  213. package/src/sw-precache-plan.ts +34 -4
  214. package/src/sync-authenticator.ts +3 -3
  215. package/src/sync-url.ts +3 -3
  216. package/src/tasks-facts.ts +3 -29
  217. package/src/templates/action.ts +7 -4
  218. package/src/templates/backfill.ts +4 -4
  219. package/src/templates/entity.ts +2 -2
  220. package/src/templates/github/ci.yml.ts +28 -18
  221. package/src/templates/github/image.yml.ts +92 -0
  222. package/src/templates/github/runner.ts +18 -0
  223. package/src/templates/guard-bare-error.ts +16 -2
  224. package/src/templates/guard-unzoned-date.ts +56 -6
  225. package/src/templates/index.ts +1 -1
  226. package/src/templates/job-body-test.ts +2 -2
  227. package/src/templates/job.ts +6 -6
  228. package/src/templates/naming.ts +28 -0
  229. package/src/templates/query.ts +9 -8
  230. package/src/templates/resource-page.ts +9 -7
  231. package/src/templates/resource-service.ts +2 -2
  232. package/src/templates/route.ts +26 -14
  233. package/src/templates/scaffold-api.ts +2 -3
  234. package/src/templates/scaffold-app.ts +8 -3
  235. package/src/templates/scaffold-auth.ts +205 -70
  236. package/src/templates/scaffold-bin.ts +122 -0
  237. package/src/templates/scaffold-claude-commands.ts +11 -11
  238. package/src/templates/scaffold-container-compose.ts +10 -1
  239. package/src/templates/scaffold-container.ts +16 -8
  240. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  241. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  242. package/src/templates/scaffold-dashboard-example.ts +112 -172
  243. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  244. package/src/templates/scaffold-dashboard.ts +9 -5
  245. package/src/templates/scaffold-db-client.ts +4 -2
  246. package/src/templates/scaffold-db-package.ts +2 -2
  247. package/src/templates/scaffold-docs.ts +20 -77
  248. package/src/templates/scaffold-domain-package.ts +0 -4
  249. package/src/templates/scaffold-entries.ts +4 -3
  250. package/src/templates/scaffold-env.ts +6 -3
  251. package/src/templates/scaffold-gitattributes.ts +59 -0
  252. package/src/templates/scaffold-helm-fences.ts +76 -0
  253. package/src/templates/scaffold-helm-templates.ts +100 -19
  254. package/src/templates/scaffold-helm.ts +62 -5
  255. package/src/templates/scaffold-http.ts +1 -1
  256. package/src/templates/scaffold-i18n.ts +15 -2
  257. package/src/templates/scaffold-icon.ts +2 -2
  258. package/src/templates/scaffold-repo.ts +27 -16
  259. package/src/templates/scaffold-shell.ts +55 -37
  260. package/src/test-counts.ts +2 -6
  261. package/src/test-ignores.ts +25 -0
  262. package/src/test-only-shipped.ts +193 -0
  263. package/src/test-passes.ts +0 -15
  264. package/src/test-select.ts +7 -15
  265. package/src/test-shards.ts +40 -95
  266. package/src/test-workers.ts +26 -4
  267. package/src/theme-boot.ts +8 -33
  268. package/src/theme-brand.ts +66 -0
  269. package/src/trusted-hops-error.ts +17 -0
  270. package/src/trusted-hops.ts +49 -0
  271. package/src/unscanned-admin.ts +2 -2
  272. package/src/unthrown-codes.ts +26 -0
  273. package/src/verify-checks.ts +29 -48
  274. package/src/verify-coverage-run.ts +3 -2
  275. package/src/verify-deadline.ts +207 -33
  276. package/src/verify-merge.ts +108 -43
  277. package/src/verify-part-step.ts +98 -0
  278. package/src/verify-role-load-probe.ts +12 -2
  279. package/src/verify-role-load.ts +9 -4
  280. package/src/verify-run.ts +32 -8
  281. package/src/verify-shard.ts +13 -2
  282. package/src/verify-stalled.ts +152 -0
  283. package/src/verify-step.ts +2 -0
  284. package/src/verify-test-run.ts +3 -2
  285. package/src/verify-tests.ts +5 -3
  286. package/src/worker-bundle.ts +2 -2
  287. package/src/workspace-checks.ts +53 -7
  288. package/src/workspace-graph.ts +3 -2
  289. package/src/write-line.ts +82 -7
  290. package/src/browser-launcher-fake-html.ts +0 -80
  291. package/src/browser-launcher-fake.ts +0 -168
  292. package/src/build-errors.ts +0 -54
  293. package/src/jobs-drain.ts +0 -163
  294. package/src/scaffold-typecheck.ts +0 -227
  295. package/src/thrown-by.ts +0 -24
  296. package/src/verify-errors.ts +0 -58
@@ -0,0 +1,46 @@
1
+ // `x dev`'s restart, worker first (issue #677). A save that reaches a primitive-defining module
2
+ // restarts the child, and the drain that ends it used to be the FIRST thing it did: a job that ran
3
+ // past the drain budget was aborted `X_DRAINING` and requeued, so a non-idempotent one (a bank
4
+ // login) ran twice. The worker is retired first — `stop()` claims nothing and aborts nothing — and
5
+ // only then is the drain asked to release the port, the lock and the embedded database.
6
+
7
+ import { drain, logger, renderThrowable } from '@ultimat3/core';
8
+ import type { Worker } from '@ultimat3/jobs';
9
+ import { msg } from './messages';
10
+ import { writeErrorLine } from './write-line';
11
+
12
+ /** The two calls a retire makes, so a test hands in a worker without booting a queue. */
13
+ export type RetiringWorker = Pick<Worker, 'stats' | 'stop'>;
14
+
15
+ export interface RetireDeps {
16
+ /** The process drain the restart ends in. Default: core's `drain`. */
17
+ readonly drain: (reason: string) => Promise<void>;
18
+ /** Where the wait is said. Default: stderr — fd 1 may be `--json`'s one document. */
19
+ readonly say: (line: string) => void;
20
+ }
21
+
22
+ const DEFAULT_DEPS: RetireDeps = { drain, say: writeErrorLine };
23
+
24
+ /**
25
+ * Retire `worker`, then drain. Unbounded on purpose: in development a restart that waits for a
26
+ * long job is the cheaper failure than a side effect run twice, and the line it prints says how to
27
+ * stop waiting — Ctrl-C is a real drain, which binds the worker's teardown to the drain deadline
28
+ * and cuts off what is still running there. A retire that FAILS still drains: a restart left
29
+ * half-done is a child that stopped claiming and never exits.
30
+ */
31
+ export async function retireThenDrain(
32
+ worker: RetiringWorker | null,
33
+ deps: RetireDeps = DEFAULT_DEPS,
34
+ ): Promise<void> {
35
+ try {
36
+ if (worker !== null) {
37
+ const { inFlight } = await worker.stats();
38
+ if (inFlight > 0) deps.say(msg('cli.dev.restartWaiting', { count: inFlight }));
39
+ await worker.stop('x dev restart');
40
+ }
41
+ } catch (error) {
42
+ logger.warn('cli.dev.restart-retire-failed', { error: renderThrowable(error) });
43
+ } finally {
44
+ await deps.drain('restart');
45
+ }
46
+ }
@@ -4,21 +4,23 @@
4
4
 
5
5
  import type { RealtimeConfig } from '@ultimat3/core';
6
6
  import type { RateLimitStore, Route } from '@ultimat3/http';
7
- import { describeRoutes } from '@ultimat3/render';
7
+ import { appLocaleSet } from '@ultimat3/i18n/app-catalogs';
8
+ import { describePages } from '@ultimat3/render';
8
9
  import type { IsrController } from '@ultimat3/render/server';
10
+ import type { ImageTransformDriver } from '@ultimat3/seo';
9
11
  import type { Storage } from '@ultimat3/storage';
10
12
  import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
11
13
  import { mountAppMcp } from './app-mcp';
12
14
  import type { DevDashboardInput } from './dev-dashboard';
13
15
  import { devDashboardRoutes } from './dev-dashboard';
14
- import { errorPageStyleSources } from './error-page-csp';
16
+ import { errorPageStyleBodies } from './error-page-csp';
15
17
  import type { IslandBundle } from './island-bundle';
16
18
  import { islandHarnessRoutes } from './island-harness-route';
17
19
  import { islandRoutes } from './island-routes';
18
20
  import { loadIslandStates } from './island-states-load';
19
21
  import { loadNavigation, pageNavigation } from './page-navigation';
20
22
  import type { PageSpeculation } from './page-speculation';
21
- import { loadSpeculation, pageSpeculation } from './page-speculation';
23
+ import { loadSpeculation, otherLocaleSegments, pageSpeculation } from './page-speculation';
22
24
  import { pageSync } from './page-sync';
23
25
  import { loadPwaArtifacts } from './pwa-artifacts';
24
26
  import { adminMountRoutes } from './runtime-admin';
@@ -33,6 +35,7 @@ import { serviceWorkerArtifacts } from './sw-artifacts';
33
35
  import { serviceWorkerRoutes } from './sw-routes';
34
36
  import type { ThemeBoot } from './theme-boot';
35
37
  import { loadThemeMode, themeBoot } from './theme-boot';
38
+ import { loadThemeBrand } from './theme-brand';
36
39
 
37
40
  export interface DevRouteTableInput {
38
41
  readonly root: string;
@@ -53,6 +56,12 @@ export interface DevRouteTableInput {
53
56
  * empty it: a stored render outlives the module that produced it.
54
57
  */
55
58
  readonly isr?: IsrController | undefined;
59
+ /**
60
+ * `apps/<app>/runtime.ts`'s `images` driver, handed to `/media` exactly as the container hands it
61
+ * (`serve-web.ts`): dropped here, dev answered every variant from the builtin pipeline and a
62
+ * CDN-backed transform was first exercised in production.
63
+ */
64
+ readonly images?: ImageTransformDriver | undefined;
56
65
  }
57
66
 
58
67
  export interface DevRouteTable {
@@ -61,8 +70,11 @@ export interface DevRouteTable {
61
70
  readonly theme: ThemeBoot;
62
71
  /** The speculation rules, admitted the same way — `undefined` when no document carries any. */
63
72
  readonly speculation: PageSpeculation | undefined;
64
- /** The app's own error pages' inline styles, admitted the same way. */
65
- readonly errorStyles: readonly string[];
73
+ /**
74
+ * Every inline `<style>` body these routes put in a document: the app's error pages' and its
75
+ * brand's (`theme-brand.ts`) — what `startRoles({ inlineStyles })` hashes into `style-src`.
76
+ */
77
+ readonly inlineStyles: readonly string[];
66
78
  /** Where the app's default MCP endpoint was mounted, or `null`. */
67
79
  readonly mcpPath: string | null;
68
80
  /** Every mounted MCP endpoint's path, default first — one per population the app serves. */
@@ -74,6 +86,8 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
74
86
  // is mounted, and the 0kb baseline is not spent on a `<link>` to a file that does not exist.
75
87
  const pwa = await loadPwaArtifacts(input.root);
76
88
  const theme = themeBoot(await loadThemeMode(input.root));
89
+ // Read at boot, like the theme mode: a save to the theme module is applied on the next `x dev`.
90
+ const brand = await loadThemeBrand(input.root);
77
91
  // `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
78
92
  const site = await loadSiteSettings(input.root);
79
93
  const origin = publicOrigin(input.env, site);
@@ -87,8 +101,12 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
87
101
  const speculation = pageSpeculation({
88
102
  config: await loadSpeculation(input.root),
89
103
  client: declared.surfaces,
104
+ localeSegments: otherLocaleSegments(await appLocaleSet(input.root)),
90
105
  });
91
- const errorStyles = await errorPageStyleSources(input.root);
106
+ const inlineStyles = [
107
+ ...(await errorPageStyleBodies(input.root)),
108
+ ...(brand === undefined ? [] : [brand.style]),
109
+ ];
92
110
  // Built once at boot and NOT rebuilt with the islands on a watcher tick: a service worker that
93
111
  // changes under a page it controls is the update path, and one per keystroke exercises it per save.
94
112
  const serviceWorker =
@@ -97,7 +115,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
97
115
  : serviceWorkerArtifacts({
98
116
  pwa,
99
117
  buildId: input.buildId,
100
- routes: describeRoutes(),
118
+ routes: describePages(),
101
119
  islands: input.islands(),
102
120
  styles: styleBundle(),
103
121
  scripts: [
@@ -126,6 +144,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
126
144
  ...assetRoutes({
127
145
  root: input.root,
128
146
  storage: served,
147
+ ...(input.images === undefined ? {} : { images: input.images }),
129
148
  ...(pwa === undefined ? {} : { pwa }),
130
149
  }),
131
150
  ...storageRoutes({ storage: served }),
@@ -146,6 +165,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
146
165
  ...islandHarnessRoutes({
147
166
  islands: () => input.islands(),
148
167
  states: () => loadIslandStates(input.root),
168
+ devUrl: () => input.dashboard.status().url,
149
169
  }),
150
170
  ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
151
171
  ...sync.routes,
@@ -157,6 +177,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
157
177
  ...adminMountRoutes({
158
178
  buildId: input.buildId,
159
179
  themeHead: theme.head,
180
+ ...(brand === undefined ? {} : { brandHead: brand.head }),
160
181
  ...(origin === undefined ? {} : { origin }),
161
182
  }),
162
183
  ...appRoutes({
@@ -167,6 +188,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
167
188
  persisted: sync.persisted,
168
189
  ...(navigation.head === undefined ? {} : { navigation: navigation.head }),
169
190
  themeHead: theme.head,
191
+ ...(brand === undefined ? {} : { brandHead: brand.head }),
170
192
  ...(speculation === undefined ? {} : { speculationHead: speculation.head }),
171
193
  ...(origin === undefined ? {} : { origin }),
172
194
  ...(pwa === undefined
@@ -179,7 +201,7 @@ export async function devRouteTable(input: DevRouteTableInput): Promise<DevRoute
179
201
  routes,
180
202
  theme,
181
203
  speculation,
182
- errorStyles,
204
+ inlineStyles,
183
205
  mcpPath: mcpMount.path,
184
206
  mcpPaths: mcpMount.paths,
185
207
  };
@@ -5,17 +5,23 @@
5
5
  // of logging "reloaded" over stale code, and this loop boots a fresh one on the same port. Every
6
6
  // other save stays the in-process reload it was — a restart is paid only where nothing else works.
7
7
 
8
- // why: Bun has no path API; the restart line names files relative to the app root.
9
- import { relative } from 'node:path';
10
- import { drain } from '@ultimat3/core';
11
8
  import type { StalePin } from './app-reload-graph';
12
9
  import { devSpec } from './cmd-dev-spec';
13
- import type { DevChildGone } from './dev-child-watch';
14
- import { DEV_SUPERVISOR_PID_ENV, startDevChildWatch, supervisorPid } from './dev-child-watch';
10
+ import type { DevChildGone, DevDrainMessage } from './dev-child-watch';
11
+ import {
12
+ DEV_SUPERVISOR_PID_ENV,
13
+ devHardExitMs,
14
+ startDevChildWatch,
15
+ supervisorPid,
16
+ } from './dev-child-watch';
15
17
  import { devPortFor } from './dev-port';
18
+ import { type RetiringWorker, retireThenDrain } from './dev-restart-retire';
19
+ import { PORT_RANGE } from './flag-number';
16
20
  import { msg } from './messages';
17
21
  import type { Finding } from './output';
18
22
  import { parseArgs } from './parse';
23
+ import { posixRelative } from './posix-path';
24
+ import { writeErrorLine } from './write-line';
19
25
 
20
26
  /** Set on the child: it serves, and a pinned save makes it exit for a restart. */
21
27
  export const DEV_CHILD_ENV = 'ULTIMATE_DEV_CHILD';
@@ -48,12 +54,43 @@ export function devSupervision(
48
54
  }
49
55
  }
50
56
 
51
- /** A port nothing holds right now, asked of the OS — what `--port 0` is pinned to. */
52
- export function freeDevPort(): number {
57
+ /** A port the OS hands out for `0`, released at once — the candidate `freeDevPort` checks. */
58
+ const kernelPort = (): number => {
53
59
  const probe = Bun.serve({ port: 0, fetch: () => new Response() });
54
60
  const port = probe.port ?? 0;
55
61
  probe.stop(true);
56
62
  return port;
63
+ };
64
+
65
+ /** Whether `port` can be bound right now: `Bun.serve` refusing it is the answer, not an error. */
66
+ const portBindable = (port: number): boolean => {
67
+ try {
68
+ Bun.serve({ port, fetch: () => new Response() }).stop(true);
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ };
74
+
75
+ /** Kernel candidates tried for a free PAIR before the last one is handed back unchecked. */
76
+ const PAIR_ATTEMPTS = 16;
77
+
78
+ /**
79
+ * A port whose PAIR nothing holds right now — what `--port 0` is pinned to. `x dev` binds the web
80
+ * role on `port` and the sync role on `port + 1` (`syncPortFor`), so a port the kernel says is
81
+ * free proves nothing about its neighbour: asking for one alone failed the boot `X_PORT_IN_USE`
82
+ * on a busy box whenever the neighbour was taken (`cmd-dev-orphan.live.test.ts`, 2026-10-07).
83
+ */
84
+ export function freeDevPort(
85
+ pick: () => number = kernelPort,
86
+ bindable: (port: number) => boolean = portBindable,
87
+ ): number {
88
+ let port = 0;
89
+ for (let attempt = 0; attempt < PAIR_ATTEMPTS; attempt += 1) {
90
+ port = pick();
91
+ if (port > 0 && port < PORT_RANGE.max && bindable(port + 1)) return port;
92
+ }
93
+ return port;
57
94
  }
58
95
 
59
96
  /** `argv` with every `--port` removed and `--port <port>` appended. */
@@ -75,6 +112,35 @@ function withPort(argv: readonly string[], port: number): readonly string[] {
75
112
  export interface DevChild {
76
113
  readonly exited: Promise<number>;
77
114
  kill(signal: 'SIGINT' | 'SIGTERM'): void;
115
+ /** The IPC channel; absent or closed, `stopChild` falls back to the signal. */
116
+ send?(message: DevDrainMessage): void;
117
+ }
118
+
119
+ /**
120
+ * Asks the child to drain: a `drain` message over IPC, on every platform — on Windows
121
+ * `kill('SIGTERM')` is TerminateProcess, which skips the drain and leaves the embedded database
122
+ * unclean. The signal only when there is no channel to ask over (it closed, or a test's child) —
123
+ * or once `afterMs` has passed with the child still running: one still booting has no listener
124
+ * yet and drops the message, and a stop that never lands is a supervisor waiting forever.
125
+ */
126
+ export function stopChild(
127
+ child: DevChild,
128
+ signal: 'SIGINT' | 'SIGTERM',
129
+ afterMs: number = devHardExitMs(),
130
+ ): void {
131
+ if (child.send !== undefined) {
132
+ try {
133
+ child.send({ type: 'x-dev-drain', signal });
134
+ const fallback = setTimeout(() => child.kill(signal), afterMs);
135
+ // Never what keeps this process alive: awaiting the child's exit is.
136
+ fallback.unref();
137
+ void child.exited.then(() => clearTimeout(fallback));
138
+ return;
139
+ } catch {
140
+ // The channel closed under us: the child is exiting or gone, and the signal is all that's left.
141
+ }
142
+ }
143
+ child.kill(signal);
78
144
  }
79
145
 
80
146
  export interface SuperviseDevInput {
@@ -91,16 +157,23 @@ const spawnInherited = (
91
157
  command: readonly string[],
92
158
  env: Record<string, string | undefined>,
93
159
  ): DevChild =>
94
- Bun.spawn([...command], { stdin: 'inherit', stdout: 'inherit', stderr: 'inherit', env });
160
+ Bun.spawn([...command], {
161
+ stdin: 'inherit',
162
+ stdout: 'inherit',
163
+ stderr: 'inherit',
164
+ env,
165
+ // The channel `stopChild` drains over; the child sends nothing back.
166
+ ipc: () => undefined,
167
+ });
95
168
 
96
169
  /**
97
170
  * Runs children until one exits with anything but `DEV_RESTART_EXIT_CODE`, and answers that code.
98
- * SIGINT and SIGTERM are forwarded — a terminal's Ctrl-C reaches the child on its own too, and its
99
- * drain is idempotent — and once one arrived no child is started again: a Ctrl-C during a restart
100
- * is a stop, never a respawn.
171
+ * SIGINT and SIGTERM are forwarded as a `drain` message (`stopChild`) — a terminal's Ctrl-C reaches
172
+ * the child on its own too, and its drain is idempotent — and once one arrived no child is started
173
+ * again: a Ctrl-C during a restart is a stop, never a respawn.
101
174
  *
102
175
  * Every other way out stops the child too: an uncaught error or a `process.exit` here reaches the
103
- * `exit` listener, which SIGTERMs a child still running. The one way no listener sees is a SIGKILL
176
+ * `exit` listener, which asks a child still running to drain. The one way no listener sees is a SIGKILL
104
177
  * of this process — the child's own watch (`dev-child-watch.ts`) answers that, from its side, off
105
178
  * the pid this loop hands it.
106
179
  */
@@ -111,12 +184,12 @@ export async function superviseDev(input: SuperviseDevInput): Promise<number> {
111
184
  let stopping = false;
112
185
  const forward = (signal: 'SIGINT' | 'SIGTERM') => (): void => {
113
186
  stopping = true;
114
- child?.kill(signal);
187
+ if (child !== undefined) stopChild(child, signal);
115
188
  };
116
189
  const onInt = forward('SIGINT');
117
190
  const onTerm = forward('SIGTERM');
118
191
  const onExit = (): void => {
119
- if (running) child?.kill('SIGTERM');
192
+ if (running && child !== undefined) stopChild(child, 'SIGTERM');
120
193
  };
121
194
  process.on('SIGINT', onInt);
122
195
  process.on('SIGTERM', onTerm);
@@ -145,8 +218,8 @@ export function restartReason(root: string, pins: readonly StalePin[]): string {
145
218
  return pins
146
219
  .map(({ changed, pinned }) =>
147
220
  changed === pinned
148
- ? relative(root, pinned)
149
- : `${relative(root, changed)} under ${relative(root, pinned)}`,
221
+ ? posixRelative(root, pinned)
222
+ : `${posixRelative(root, changed)} under ${posixRelative(root, pinned)}`,
150
223
  )
151
224
  .join(', ');
152
225
  }
@@ -158,7 +231,7 @@ export function restartFinding(root: string, pins: readonly StalePin[]): Finding
158
231
  code: 'X_DEV_RESTART_REQUIRED',
159
232
  cause: `${restartReason(root, pins)} — a module that defines a primitive still holds the code before the save, and re-importing it would register a second definition`,
160
233
  fix: 'restart x dev (a supervised `x dev` restarts itself; an embedded startDev() passes onRestart)',
161
- at: first === undefined ? '' : relative(root, first.pinned),
234
+ at: first === undefined ? '' : posixRelative(root, first.pinned),
162
235
  };
163
236
  }
164
237
 
@@ -166,12 +239,20 @@ export function restartFinding(root: string, pins: readonly StalePin[]): Finding
166
239
  export interface ChildRestart {
167
240
  readonly options: { readonly onRestart?: (pins: readonly StalePin[]) => void };
168
241
  readonly exit: (code: number) => void;
242
+ /**
243
+ * The worker this child booted, handed over once `startDev` returns: a restart retires it before
244
+ * the drain, so a save never cancels a running job (`dev-restart-retire.ts`). `null` for a child
245
+ * running no worker role.
246
+ */
247
+ readonly adopt: (worker: RetiringWorker | null) => void;
169
248
  }
170
249
 
171
250
  /**
172
251
  * Only a supervised child restarts: it says why on stderr (fd 1 may be `--json`'s one document),
173
- * starts core's drain — the hold then releases the lock, the port and the embedded database — and
174
- * exits `DEV_RESTART_EXIT_CODE` once released. Anything else keeps the finding on `/_x`.
252
+ * retires its worker — every running job finishes, none is claimed — then starts core's drain (the
253
+ * hold then releases the lock, the port and the embedded database) and exits
254
+ * `DEV_RESTART_EXIT_CODE` once released. One restart per child however many saves land while it
255
+ * waits. Anything else keeps the finding on `/_x`.
175
256
  *
176
257
  * A child its supervisor named (`DEV_SUPERVISOR_PID_ENV`) also watches for that supervisor's death
177
258
  * and its app root's, and stops on either — exit 1 for a root that is gone, which the supervisor,
@@ -181,9 +262,14 @@ export function childRestart(
181
262
  root: string,
182
263
  env: Readonly<Record<string, string | undefined>>,
183
264
  watch: typeof startDevChildWatch = startDevChildWatch,
265
+ retire: (worker: RetiringWorker | null) => Promise<void> = retireThenDrain,
184
266
  ): ChildRestart {
185
- if (env[DEV_CHILD_ENV] !== '1') return { options: {}, exit: () => undefined };
267
+ if (env[DEV_CHILD_ENV] !== '1') {
268
+ return { options: {}, exit: () => undefined, adopt: () => undefined };
269
+ }
186
270
  let leaving: 'restart' | DevChildGone | undefined;
271
+ let worker: RetiringWorker | null = null;
272
+ let restarting = false;
187
273
  const watched =
188
274
  supervisorPid(env) === undefined
189
275
  ? undefined
@@ -193,12 +279,18 @@ export function childRestart(
193
279
  return {
194
280
  options: {
195
281
  onRestart: (pins) => {
196
- process.stderr.write(`${msg('cli.dev.restart', { reason: restartReason(root, pins) })}\n`);
282
+ writeErrorLine(msg('cli.dev.restart', { reason: restartReason(root, pins) }));
197
283
  leaving ??= 'restart';
198
284
  watched?.stopping(DEV_RESTART_EXIT_CODE);
199
- void drain('restart');
285
+ if (restarting) return;
286
+ restarting = true;
287
+ // Never rejects: a failed retire is logged and the drain still runs (`retireThenDrain`).
288
+ void retire(worker);
200
289
  },
201
290
  },
291
+ adopt: (booted) => {
292
+ worker = booted;
293
+ },
202
294
  exit: () => {
203
295
  if (leaving === 'restart') process.exit(DEV_RESTART_EXIT_CODE);
204
296
  if (leaving === 'root') process.exit(1);
package/src/dev-traces.ts CHANGED
@@ -127,49 +127,82 @@ function toTrace(root: ReadableSpan, spans: readonly ReadableSpan[]): RequestTra
127
127
 
128
128
  /**
129
129
  * Spans end innermost-first, so a trace is only whole once its root arrives — which is also the
130
- * moment the request finished. Grouping by trace id and reporting only groups that have an HTTP
131
- * root is what keeps a half-finished request, and a job's spans, out of a panel about requests.
130
+ * moment the request finished. Until then it is OPEN: unclassified, because the span that says
131
+ * "request" or "background" is the last one to end. A trace whose root arrives and is NOT a
132
+ * request's is forgotten the moment it ends; one whose root IS a request's is RETAINED.
133
+ *
134
+ * Two maps, two quotas, because only retained requests are what the limit promises to keep. One
135
+ * shared buffer let an open trace evict a retained request: at limit 1, a worker's `job.poll`
136
+ * ended its `db.update` first, that provisional trace took the only slot, and discarding
137
+ * `job.poll` a moment later could not bring the request back.
132
138
  */
133
139
  export function createTraceRecorder(options: { limit?: number } = {}): TraceRecorder {
134
- // `byTrace.size > NaN` is false on every pass, so an unchecked limit does not widen the buffer —
140
+ // `buffer.size > NaN` is false on every pass, so an unchecked limit does not widen the buffer —
135
141
  // it deletes the eviction loop, and a dev session then holds every span of every request it has
136
142
  // ever seen. At least 1: a recorder that retains nothing is what `/_x/timeline` reads.
137
143
  const limit = finiteCount('createTraceRecorder', 'limit', options.limit ?? DEFAULT_LIMIT, 1);
138
- // Insertion-ordered: the oldest trace id is the first key, which is the one eviction drops.
139
- const byTrace = new Map<string, ReadableSpan[]>();
144
+ // Insertion-ordered, both: the oldest trace id is the first key, which is the one eviction drops.
145
+ const retained = new Map<string, ReadableSpan[]>();
146
+ // Bounded too, by the same figure but separately: a root that never ends (a span leaked by app
147
+ // code) would otherwise hold its children forever. Overflowing it costs an in-flight trace its
148
+ // early spans, never a finished request.
149
+ const open = new Map<string, ReadableSpan[]>();
150
+
151
+ const admit = (buffer: Map<string, ReadableSpan[]>, traceId: string, spans: ReadableSpan[]) => {
152
+ buffer.set(traceId, spans);
153
+ // Bounded by TRACE, not by span: dropping half a request would leave a flame with holes.
154
+ // The cost is stated rather than capped — one trace's span array has no bound of its own, so
155
+ // a request issuing 50k statements holds 50k `ReadableSpan`s until it is evicted. That is a
156
+ // dev-only recorder (`serve.ts` installs none), and a per-trace cap would silently produce
157
+ // the holed flame this bound exists to prevent.
158
+ while (buffer.size > limit) {
159
+ const oldest = buffer.keys().next();
160
+ if (oldest.done === true) break;
161
+ buffer.delete(oldest.value);
162
+ }
163
+ };
140
164
 
141
165
  const record = (span: ReadableSpan): void => {
142
166
  const traceId = span.context.traceId;
143
- const spans = byTrace.get(traceId);
144
- if (spans === undefined) {
145
- byTrace.set(traceId, [span]);
146
- // Bounded by TRACE, not by span: dropping half a request would leave a flame with holes.
147
- // The cost is stated rather than capped — one trace's span array has no bound of its own, so
148
- // a request issuing 50k statements holds 50k `ReadableSpan`s until it is evicted. That is a
149
- // dev-only recorder (`serve.ts` installs none), and a per-trace cap would silently produce
150
- // the holed flame this bound exists to prevent.
151
- while (byTrace.size > limit) {
152
- const oldest = byTrace.keys().next();
153
- if (oldest.done === true) break;
154
- byTrace.delete(oldest.value);
155
- }
167
+ // Late spans of a request already retained (work it handed off under its own trace id) join it.
168
+ const kept = retained.get(traceId);
169
+ if (kept !== undefined) {
170
+ kept.push(span);
171
+ return;
172
+ }
173
+ // Taken out and put back last: an open trace still receiving spans is never the oldest key,
174
+ // so the open bound evicts the trace that went quiet, not the one mid-flight.
175
+ const spans = open.get(traceId) ?? [];
176
+ open.delete(traceId);
177
+ // A request's root: the trace is classified and complete, so it moves into the quota.
178
+ if (isHttpRoot(span)) {
179
+ spans.push(span);
180
+ admit(retained, traceId, spans);
156
181
  return;
157
182
  }
183
+ // A root that is not a request's — no parent, no HTTP facts — ends its trace, and the trace is
184
+ // a scheduler tick or a worker's job: not the panel's to show. Dropped here, not filtered at
185
+ // read time, because idle background roles minting a few per second would otherwise churn
186
+ // the buffers for nothing.
187
+ if (span.parentSpanId === undefined) return;
158
188
  spans.push(span);
189
+ if (spans.length === 1) admit(open, traceId, spans);
190
+ else open.set(traceId, spans);
159
191
  };
160
192
 
161
193
  return {
162
194
  exporter: { export: record },
163
195
  traces(): readonly RequestTrace[] {
164
196
  const traces: RequestTrace[] = [];
165
- for (const spans of byTrace.values()) {
197
+ for (const spans of retained.values()) {
166
198
  const root = httpRootOf(spans);
167
199
  if (root !== undefined) traces.push(toTrace(root, spans));
168
200
  }
169
201
  return traces.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
170
202
  },
171
203
  reset(): void {
172
- byTrace.clear();
204
+ retained.clear();
205
+ open.clear();
173
206
  },
174
207
  };
175
208
  }
package/src/dispatch.ts CHANGED
@@ -6,9 +6,9 @@ import { isAbsolute, resolve } from 'node:path';
6
6
  import { setLogStream } from '@ultimat3/core';
7
7
  import { requireAppRoot, requireBunVersion } from './app-root';
8
8
  import { createHelpCommand } from './cmd-help';
9
- import { plannedCommandFor } from './cmd-planned';
9
+ import { plannedCommandError, plannedCommandFor } from './cmd-planned';
10
10
  import type { CommandContext } from './command';
11
- import { CliNotImplementedError, UnknownCommandError } from './errors';
11
+ import { UnknownCommandError } from './errors';
12
12
  import type { Runner } from './exec';
13
13
  import { exec } from './exec';
14
14
  import type { CommandResult } from './output';
@@ -16,10 +16,14 @@ 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
+ import { applyRootEnv, rootEnvChanges } from './root-env';
20
20
 
21
21
  export interface DispatchOptions {
22
22
  readonly argv: readonly string[];
23
+ /**
24
+ * The directory the PROCESS started in (`bin.ts` passes `process.cwd()`): `--cwd` resolves
25
+ * against it, and it is the only directory whose `.env*` Bun loaded on its own.
26
+ */
23
27
  readonly cwd: string;
24
28
  readonly env: Readonly<Record<string, string | undefined>>;
25
29
  readonly bunVersion: string;
@@ -91,10 +95,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
91
95
  // X_NOT_IMPLEMENTED with a runnable fix. Substituted here rather than in `parse.ts`, which is
92
96
  // pure and knows nothing about what a command means; the precedent is the help swap below.
93
97
  const planned = plannedCommandFor(commandFor(options.argv[0] ?? '')?.spec.name);
94
- const failure =
95
- planned === undefined
96
- ? error
97
- : new CliNotImplementedError({ feature: `x ${planned.name}`, fix: planned.fix });
98
+ const failure = planned === undefined ? error : plannedCommandError(planned);
98
99
  // `wantsJson`, not `includes('--json')`: a typo'd flag or a typo'd command is exactly the case
99
100
  // an agent hits while always passing `-j`, and the short form rendered prose it then parsed.
100
101
  const result = errorResult(planned?.name ?? 'x', failure);
@@ -125,16 +126,19 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
125
126
  options.cwd,
126
127
  typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
127
128
  );
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);
129
+ // The ROOT's `.env*`, not the cwd's: Bun loaded the PROCESS cwd's at startup (`options.cwd`,
130
+ // before `--cwd` moved it), and a command run from `apps/web` finds its root by walking up.
131
+ // Applied to the process env too when that IS the env passed in (`applyRootEnv`), so a package
132
+ // reading `Bun.env` directly sees what the command sees — another app's keys removed included.
133
+ const env = applyRootEnv(
134
+ options.env,
135
+ await rootEnvChanges({ cwd, processCwd: options.cwd, env: options.env }),
136
+ );
133
137
  const ctx: CommandContext = {
134
138
  args: helpArgs,
135
139
  cwd,
136
140
  runner: options.runner ?? exec,
137
- env: options.env === Bun.env ? options.env : { ...added, ...options.env },
141
+ env,
138
142
  bunVersion: options.bunVersion,
139
143
  ...(options.invocation === undefined ? {} : { invocation: options.invocation }),
140
144
  };
@@ -0,0 +1,39 @@
1
+ // `x doctor`'s APP_URL rule, pure: a set APP_URL must be an origin a browser can match, and a
2
+ // loopback one must name the port `x dev` serves on — OAuth's redirect, absolute links and the sync
3
+ // node's admitted origin all read it, and a laptop origin on another port reaches another server.
4
+
5
+ import { ERROR_DOCS_URL, renderFixShellArg } from '@ultimat3/core';
6
+ import { isLoopbackHostname } from './loopback-host';
7
+ import type { Finding } from './output';
8
+ import { findingFrom } from './output';
9
+ import { syncOriginsFrom } from './sync-url';
10
+
11
+ /**
12
+ * Unset is no finding: `x dev` serves without one, and a production build warns on its own
13
+ * (`originWarning`). A public origin is not judged against the dev port — that is the deploy's
14
+ * address, not this machine's. The parse is the sync boot's own (`syncOriginsFrom`), so doctor
15
+ * refuses exactly the values the boot would.
16
+ */
17
+ export function appUrlFindings(raw: string | undefined, port: number): readonly Finding[] {
18
+ let origins: readonly string[];
19
+ try {
20
+ origins = syncOriginsFrom({ APP_URL: raw });
21
+ } catch (error) {
22
+ return [findingFrom(error)];
23
+ }
24
+ const origin = origins[0];
25
+ if (origin === undefined) return [];
26
+ const url = new URL(origin);
27
+ if (!isLoopbackHostname(url.hostname)) return [];
28
+ const declared = url.port === '' ? (url.protocol === 'https:' ? 443 : 80) : Number(url.port);
29
+ if (declared === port) return [];
30
+ const wanted = `${url.protocol}//${url.hostname}:${String(port)}`;
31
+ return [
32
+ {
33
+ code: 'X_APP_URL_PORT_MISMATCH',
34
+ cause: `APP_URL is ${origin}, but x dev serves this app on port ${String(port)} — OAuth redirects, absolute links and the sync origin would point at another server`,
35
+ fix: `export APP_URL=${renderFixShellArg(wanted, 'http://localhost:3000')}`,
36
+ docs: ERROR_DOCS_URL,
37
+ },
38
+ ];
39
+ }