@ultimat3/cli 24.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +64 -85
  130. package/src/role-sync.ts +65 -6
  131. package/src/root-env.ts +157 -28
  132. package/src/route-islands.ts +69 -0
  133. package/src/runtime-admin.ts +4 -1
  134. package/src/runtime-assets.ts +15 -2
  135. package/src/runtime-bindings.ts +23 -3
  136. package/src/runtime-cache.ts +8 -8
  137. package/src/runtime-hooks.ts +2 -1
  138. package/src/runtime-isr-outcome.ts +100 -0
  139. package/src/runtime-isr.ts +2 -2
  140. package/src/runtime-jobs.ts +78 -18
  141. package/src/runtime-live-feed.ts +1 -1
  142. package/src/runtime-mail.ts +26 -0
  143. package/src/runtime-mfa-warning.ts +2 -2
  144. package/src/runtime-overrides.ts +3 -3
  145. package/src/runtime-purge.ts +1 -1
  146. package/src/runtime-queue.ts +12 -12
  147. package/src/runtime-realtime.ts +5 -3
  148. package/src/runtime-render.ts +77 -82
  149. package/src/runtime-replica.ts +3 -8
  150. package/src/runtime-services.ts +33 -13
  151. package/src/runtime-storage-upload.ts +2 -2
  152. package/src/runtime-storage.ts +3 -3
  153. package/src/schema-diff.ts +1 -1
  154. package/src/secrets-rotation.ts +8 -9
  155. package/src/seo-routes.ts +2 -2
  156. package/src/serve-boot.ts +22 -11
  157. package/src/serve-drain.ts +3 -1
  158. package/src/serve-retire.ts +95 -0
  159. package/src/serve-web.ts +19 -8
  160. package/src/serve.ts +27 -10
  161. package/src/server-barrels.ts +6 -3
  162. package/src/shot-browser.ts +1 -1
  163. package/src/shot-locale.ts +6 -15
  164. package/src/shot-settle.ts +3 -3
  165. package/src/shot-theme.ts +1 -1
  166. package/src/shot-viewport.ts +152 -0
  167. package/src/signal-shred.ts +26 -12
  168. package/src/site-asset-routes.ts +2 -2
  169. package/src/site-seo.ts +6 -4
  170. package/src/source-files.ts +13 -3
  171. package/src/static-report.ts +5 -0
  172. package/src/style-routes.ts +2 -2
  173. package/src/sw-artifacts.ts +8 -8
  174. package/src/sw-precache-plan.ts +34 -4
  175. package/src/sync-authenticator.ts +3 -3
  176. package/src/sync-url.ts +1 -1
  177. package/src/templates/action.ts +4 -4
  178. package/src/templates/backfill.ts +4 -4
  179. package/src/templates/entity.ts +2 -2
  180. package/src/templates/github/ci.yml.ts +28 -18
  181. package/src/templates/github/image.yml.ts +92 -0
  182. package/src/templates/github/runner.ts +18 -0
  183. package/src/templates/guard-bare-error.ts +16 -2
  184. package/src/templates/guard-unzoned-date.ts +56 -6
  185. package/src/templates/index.ts +1 -1
  186. package/src/templates/job-body-test.ts +2 -2
  187. package/src/templates/job.ts +6 -6
  188. package/src/templates/query.ts +9 -8
  189. package/src/templates/resource-page.ts +9 -7
  190. package/src/templates/resource-service.ts +2 -2
  191. package/src/templates/route.ts +23 -8
  192. package/src/templates/scaffold-api.ts +2 -3
  193. package/src/templates/scaffold-app.ts +2 -1
  194. package/src/templates/scaffold-auth.ts +205 -70
  195. package/src/templates/scaffold-bin.ts +122 -0
  196. package/src/templates/scaffold-claude-commands.ts +11 -11
  197. package/src/templates/scaffold-container.ts +12 -6
  198. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  199. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  200. package/src/templates/scaffold-dashboard-example.ts +112 -172
  201. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  202. package/src/templates/scaffold-dashboard.ts +9 -5
  203. package/src/templates/scaffold-db-package.ts +2 -2
  204. package/src/templates/scaffold-docs.ts +20 -77
  205. package/src/templates/scaffold-domain-package.ts +0 -4
  206. package/src/templates/scaffold-entries.ts +4 -3
  207. package/src/templates/scaffold-env.ts +6 -3
  208. package/src/templates/scaffold-gitattributes.ts +59 -0
  209. package/src/templates/scaffold-helm-templates.ts +74 -18
  210. package/src/templates/scaffold-helm.ts +31 -4
  211. package/src/templates/scaffold-http.ts +1 -1
  212. package/src/templates/scaffold-i18n.ts +15 -2
  213. package/src/templates/scaffold-icon.ts +2 -2
  214. package/src/templates/scaffold-repo.ts +18 -15
  215. package/src/templates/scaffold-shell.ts +17 -2
  216. package/src/test-only-shipped.ts +193 -0
  217. package/src/test-select.ts +2 -1
  218. package/src/test-shards.ts +1 -4
  219. package/src/theme-boot.ts +2 -2
  220. package/src/theme-brand.ts +66 -0
  221. package/src/trusted-hops.ts +49 -0
  222. package/src/unscanned-admin.ts +2 -2
  223. package/src/verify-checks.ts +12 -2
  224. package/src/verify-coverage-run.ts +3 -2
  225. package/src/verify-deadline.ts +87 -10
  226. package/src/verify-merge.ts +48 -16
  227. package/src/verify-part-step.ts +10 -2
  228. package/src/verify-role-load-probe.ts +12 -2
  229. package/src/verify-role-load.ts +9 -4
  230. package/src/verify-run.ts +3 -0
  231. package/src/verify-step.ts +2 -0
  232. package/src/verify-test-run.ts +3 -2
  233. package/src/verify-tests.ts +0 -1
  234. package/src/worker-bundle.ts +2 -2
  235. package/src/workspace-checks.ts +53 -7
  236. package/src/workspace-graph.ts +3 -2
  237. package/src/write-line.ts +82 -7
  238. package/src/browser-launcher-fake-html.ts +0 -80
  239. package/src/browser-launcher-fake.ts +0 -168
  240. package/src/jobs-drain.ts +0 -230
  241. package/src/scaffold-typecheck.ts +0 -227
  242. package/src/thrown-by.ts +0 -24
@@ -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
@@ -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;
@@ -122,16 +126,19 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
122
126
  options.cwd,
123
127
  typeof args.flags.get('cwd') === 'string' ? String(args.flags.get('cwd')) : undefined,
124
128
  );
125
- // The ROOT's `.env*`, not the cwd's: Bun loaded the cwd's at startup, and a command run from
126
- // `apps/web` finds its root by walking up. Written into the process env too when that IS the env
127
- // passed in, because a package reading `Bun.env` directly must see what the command sees.
128
- const added = await rootEnvAdditions(cwd, options.env);
129
- 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
+ );
130
137
  const ctx: CommandContext = {
131
138
  args: helpArgs,
132
139
  cwd,
133
140
  runner: options.runner ?? exec,
134
- env: options.env === Bun.env ? options.env : { ...added, ...options.env },
141
+ env,
135
142
  bunVersion: options.bunVersion,
136
143
  ...(options.invocation === undefined ? {} : { invocation: options.invocation }),
137
144
  };
@@ -3,9 +3,9 @@
3
3
  // release reads any more. Reaching the server is `authStorageProbe`, deciding is
4
4
  // `authStorageFindings` — the split `doctor-sealed.ts` makes.
5
5
 
6
- import { BuiltinAdapter, countUnsealedMfaSecrets } from '@ultimat3/auth';
6
+ import { countUnsealedMfaSecrets, postgresAuthAdapter } from '@ultimat3/auth';
7
7
  import { ERROR_DOCS_URL } from '@ultimat3/core';
8
- import { createPostgresClient, raw } from '@ultimat3/db';
8
+ import { postgresClient, raw } from '@ultimat3/db';
9
9
  import type { Finding } from './output';
10
10
 
11
11
  /**
@@ -71,7 +71,7 @@ export function authStorageFindings(fact: AuthStorageFact): readonly Finding[] {
71
71
  */
72
72
  export async function authStorageProbe(url: string | undefined): Promise<AuthStorageFact> {
73
73
  if (url === undefined || url.trim() === '') return NO_AUTH_STORAGE_FACT;
74
- const client = createPostgresClient({ url, applicationName: 'x-doctor' });
74
+ const client = postgresClient({ url, applicationName: 'x-doctor' });
75
75
  try {
76
76
  const present = async (table: string): Promise<boolean> => {
77
77
  const rows = await client.query<{ readonly present: boolean }>(
@@ -84,7 +84,7 @@ export async function authStorageProbe(url: string | undefined): Promise<AuthSto
84
84
  if (await present(table)) retiredTables.push(table);
85
85
  }
86
86
  const unsealedMfaSecrets = (await present('x_users'))
87
- ? await countUnsealedMfaSecrets({ adapter: new BuiltinAdapter(client) })
87
+ ? await countUnsealedMfaSecrets({ adapter: postgresAuthAdapter(client) })
88
88
  : 0;
89
89
  return { unsealedMfaSecrets, retiredTables };
90
90
  } catch {
@@ -4,11 +4,12 @@
4
4
  // diagnostic over.
5
5
 
6
6
  import { ERROR_DOCS_URL } from '@ultimat3/core';
7
- import { describeRoutes } from '@ultimat3/render';
7
+ import { describePages } from '@ultimat3/render';
8
8
  import { loadApp } from './app-load';
9
9
  import { APP_CONFIG_FILE } from './app-root';
10
10
  import type { Finding } from './output';
11
11
  import { loadPwaArtifacts } from './pwa-artifacts';
12
+ import { quoteArg } from './shell-quote';
12
13
 
13
14
  /** A route as this check reads one: the URL it answers, and the surface that answers it. */
14
15
  export interface NavigableRoute {
@@ -46,7 +47,7 @@ export interface OfflineFallbackFact {
46
47
  * below already refuses `--surface app`, so the check and the remedy disagreed about one code.
47
48
  *
48
49
  * Residual, and NOT closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
49
- * either. `NavigableRoute` carries no render mode, and `describeRoutes()` has one — the narrower
50
+ * either. `NavigableRoute` carries no render mode, and `describePages()` has one — the narrower
50
51
  * check belongs with it.
51
52
  */
52
53
  const NAVIGABLE: ReadonlySet<string> = new Set(['site']);
@@ -100,13 +101,13 @@ export function offlineFallbackFinding(fact: OfflineFallbackFact): Finding | und
100
101
  const name = GENERATABLE.exec(fallback)?.[1];
101
102
  return name === undefined
102
103
  ? finding(cause, `set pwa.offline.fallback in ${APP_CONFIG_FILE} to a path a route serves`)
103
- : finding(cause, `x g route ${name} --surface site`);
104
+ : finding(cause, `x g route ${quoteArg(name)} --surface site`);
104
105
  }
105
106
 
106
107
  /**
107
108
  * The fact, read off a real app root. Both halves come from the framework's own answers — the
108
109
  * config through `loadPwaArtifacts` (the one reader of that file) and the routes through
109
- * `describeRoutes()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
110
+ * `describePages()` (the projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all
110
111
  * built from), so this check and the service worker cannot disagree about which routes exist.
111
112
  */
112
113
  export async function offlineFallbackProbe(root: string): Promise<OfflineFallbackFact> {
@@ -117,6 +118,6 @@ export async function offlineFallbackProbe(root: string): Promise<OfflineFallbac
117
118
  routes:
118
119
  app.findings.length > 0
119
120
  ? undefined
120
- : describeRoutes().map((route) => ({ path: route.path, surface: route.surface })),
121
+ : describePages().map((route) => ({ path: route.path, surface: route.surface })),
121
122
  };
122
123
  }
@@ -32,6 +32,13 @@ export interface DocumentOptions {
32
32
  * decided by `app.config.ts`, which the boot read and the renderer cannot.
33
33
  */
34
34
  readonly themeHead?: string;
35
+ /**
36
+ * The app's brand `<style>` from `theme-brand.ts`, or absent for an app with no
37
+ * `apps/web/shared/theme.ts`. Rendered AFTER the surface stylesheet `<link>`, never in the head
38
+ * block `themeHead` rides: the brand answers `theme.scss` at equal specificity, so only a later
39
+ * position in the cascade lets it win.
40
+ */
41
+ readonly brandHead?: string;
35
42
  /**
36
43
  * The page's sync target — `pageSync(…).head` — rendered as render's `clientSyncTags` on every
37
44
  * document this process serves. Principal-free, so a shareable document carries it too; absent
@@ -13,8 +13,8 @@ import { findingFrom } from './output';
13
13
  export type ReplicaIdentityWanted = (tables: ReadonlySet<string>) => readonly string[];
14
14
 
15
15
  /**
16
- * The app's own answer: its live queries' `subscribes:` plus every params channel's `records`
17
- * tables, read off the registries `loadApp` filled. One function with `db-generate.ts`, so the gate
16
+ * The app's own answer: every params channel's `records` tables, with its live queries'
17
+ * `subscribes:` validated but never granted (#518), read off the registries `loadApp` filled. One function with `db-generate.ts`, so the gate
18
18
  * can never expect an identity the generator would not grant, or miss one it would.
19
19
  */
20
20
  export const appReplicaIdentity: ReplicaIdentityWanted = (tables) =>
package/src/drift.ts CHANGED
@@ -53,16 +53,39 @@ async function declaredSchemaJson(root: string): Promise<string> {
53
53
  */
54
54
  export async function schemaHash(root: string): Promise<string> {
55
55
  const glob = new Bun.Glob(SCHEMA_GLOB);
56
- const paths: string[] = [];
56
+ const sources: SchemaSource[] = [];
57
57
  for await (const path of glob.scan({ cwd: root, absolute: false })) {
58
- if (!path.includes('.test.')) paths.push(path);
58
+ if (path.includes('.test.')) continue;
59
+ sources.push({ path, text: await Bun.file(join(root, path)).text() });
59
60
  }
60
- paths.sort();
61
+ return hashSchemaSources(await declaredSchemaJson(root), sources);
62
+ }
63
+
64
+ /** One file under `SCHEMA_GLOB`: its path relative to the app root, as the OS spelled it. */
65
+ export interface SchemaSource {
66
+ readonly path: string;
67
+ readonly text: string;
68
+ }
69
+
70
+ /**
71
+ * The hash itself, pure. The sidecar is committed and compared on every OS, so what a checkout
72
+ * spells differently is folded away first: `\` separators (a Windows glob) become `/` BEFORE the
73
+ * sort — `seed2.ts` and `seed/index.ts` order differently under the two — and CRLF text (Git for
74
+ * Windows' `core.autocrlf=true`) becomes LF. An LF tree on POSIX is hashed exactly as before, so no
75
+ * sidecar already recorded moves.
76
+ */
77
+ export function hashSchemaSources(schemaJson: string, sources: readonly SchemaSource[]): string {
78
+ const files = sources
79
+ .map(({ path, text }) => ({
80
+ path: path.replaceAll('\\', '/'),
81
+ text: text.replaceAll('\r\n', '\n'),
82
+ }))
83
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
61
84
  const hasher = new Bun.CryptoHasher('sha256');
62
- hasher.update(await declaredSchemaJson(root));
63
- for (const path of paths) {
85
+ hasher.update(schemaJson);
86
+ for (const { path, text } of files) {
64
87
  hasher.update(path);
65
- hasher.update(await Bun.file(join(root, path)).text());
88
+ hasher.update(text);
66
89
  }
67
90
  return hasher.digest('hex').slice(0, 16);
68
91
  }
@@ -228,6 +228,9 @@ export const CLI_OWNED_ERROR_CODES = [
228
228
  'X_TRUSTED_PROXY_HOPS_INVALID',
229
229
  'X_PERMISSION_BORROWED',
230
230
  'X_ERROR_CODE_UNTHROWN_STALE',
231
+ 'X_DEV_STATE_UNWRITABLE',
232
+ 'X_PACKAGE_TEST_ONLY_SHIPPED',
233
+ 'X_ISLAND_MODULE_DUPLICATED',
231
234
  ] as const;
232
235
 
233
236
  /**
@@ -412,6 +415,9 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
412
415
  X_PERMISSION_BORROWED: 'an app rule requires a permission only an imported package declared',
413
416
  X_ERROR_CODE_UNTHROWN_STALE:
414
417
  'a code UNTHROWN_CODES lists as thrown by nothing is thrown again — delete it from UNTHROWN_CODES in packages/cli/src/unthrown-codes.ts',
418
+ X_DEV_STATE_UNWRITABLE: 'x dev cannot claim its state directory',
419
+ X_PACKAGE_TEST_ONLY_SHIPPED: 'a test-only module ships in the tarball',
420
+ X_ISLAND_MODULE_DUPLICATED: 'An island chunk bundles one module more than once',
415
421
  };
416
422
 
417
423
  // One unconditional call, so a second package claiming one of the CLI's codes throws
@@ -1,15 +1,11 @@
1
- // The `style-src` sources that admit an app's own error pages. `error-pages.ts` serves
2
- // `apps/web/site/errors/<status>.html` verbatim, and such a page is a self-contained document —
3
- // it carries its own `<style>`, because the app's stylesheet bundle is not linked from it. Under
4
- // the enforced policy a container sends, `style-src 'self' <overlay hash>` blocked that block and
5
- // the page rendered unstyled; `x dev` sends the policy report-only, so the same page looked fine
6
- // on every author's machine. Hashed at boot, like the hydration runtime: a hash is a function of
7
- // the body, and these files do not change while a container runs.
1
+ // The inline `<style>` bodies of an app's own error pages, for `style-src` to admit. Such a page
2
+ // (`apps/web/site/errors/<status>.html`, served verbatim by `error-pages.ts`) carries its own
3
+ // `<style>` — no stylesheet bundle is linked from it — and under the container's enforced policy an
4
+ // unadmitted block is a page rendered unstyled; `x dev` is report-only, so it looked fine there.
8
5
 
9
6
  // why: Bun exposes no path-join primitive, and the directory is app-root-relative — the same
10
7
  // necessity `error-pages.ts` records.
11
8
  import { join } from 'node:path';
12
- import { cspHashSource } from '@ultimat3/http';
13
9
  import { ERROR_PAGE_DIR } from './error-pages';
14
10
 
15
11
  /**
@@ -26,18 +22,21 @@ export function inlineStyleBodies(html: string): readonly string[] {
26
22
  }
27
23
 
28
24
  /**
29
- * One hash per distinct `<style>` body across every error page the app ships. Read once at boot:
30
- * a page an author drops in while `x dev` runs is served (the reader is per request) but not yet
31
- * admitted, which the report-only policy there turns into a console report, not a blank page.
25
+ * Every distinct `<style>` body across every error page the app ships, sorted — BODIES, because
26
+ * `startRoles({ inlineStyles })` hashes what it is given (`style-csp.ts`). This returned the hashes
27
+ * until 2026-10, and the boot hashed them again: the policy admitted the hash of a hash, and every
28
+ * app error page carrying a `<style>` rendered unstyled under the container's enforced CSP. Read
29
+ * once at boot: a page an author drops in while `x dev` runs is served (the reader is per request)
30
+ * but not yet admitted, which the report-only policy there turns into a console report.
32
31
  */
33
- export async function errorPageStyleSources(root: string): Promise<readonly string[]> {
32
+ export async function errorPageStyleBodies(root: string): Promise<readonly string[]> {
34
33
  const dir = join(root, ERROR_PAGE_DIR);
35
- const sources = new Set<string>();
34
+ const bodies = new Set<string>();
36
35
  for (const name of await htmlFilesIn(dir)) {
37
36
  const html = await Bun.file(join(dir, name)).text();
38
- for (const body of inlineStyleBodies(html)) sources.add(cspHashSource(body));
37
+ for (const body of inlineStyleBodies(html)) bodies.add(body);
39
38
  }
40
- return [...sources].sort();
39
+ return [...bodies].sort();
41
40
  }
42
41
 
43
42
  /** `Bun.Glob.scan` throws `ENOENT` on a missing directory, and no error pages is the common case. */
package/src/exec.ts CHANGED
@@ -32,6 +32,12 @@ export interface ExecOptions {
32
32
  */
33
33
  readonly env?: Readonly<Record<string, string | undefined>>;
34
34
  readonly stdin?: string;
35
+ /**
36
+ * Told the child's pid the moment it exists. A gate step's deadline records it here
37
+ * (`verify-deadline.ts`): on Windows no process's environment is readable, so the tag sweep
38
+ * finds nothing and the pid recorded at spawn is the only handle on what the step started.
39
+ */
40
+ readonly onSpawn?: (pid: number) => void;
35
41
  }
36
42
 
37
43
  export type Runner = (command: readonly string[], options: ExecOptions) => Promise<ExecResult>;
@@ -112,6 +118,8 @@ export const exec: Runner = async (command, options) => {
112
118
  });
113
119
  }
114
120
  const proc = spawnOrRefuse([head, ...rest], options);
121
+ // Outside the refusal: a throwing observer is the caller's bug, never "could not run".
122
+ options.onSpawn?.(proc.pid);
115
123
  const [stdout, stderr, code] = await Promise.all([
116
124
  new Response(proc.stdout).text(),
117
125
  new Response(proc.stderr).text(),
package/src/favicon.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // it against the root is string work no `Bun.file` overload does — the same necessity
8
8
  // `runtime-assets.ts` records for `ICON_SOURCE`.
9
9
  import { join } from 'node:path';
10
- import { createRaster, encodeImage } from '@ultimat3/core';
10
+ import { blankRaster, encodeImage } from '@ultimat3/core';
11
11
  import type { CacheHint, Route, UltimateRequest } from '@ultimat3/http';
12
12
  import { applyCacheHeaders } from '@ultimat3/http';
13
13
 
@@ -47,7 +47,7 @@ const FAVICON_CACHE: CacheHint = { mode: 'public', maxAgeSeconds: 3600 };
47
47
  * is a third rung under a mechanism that has exactly two.
48
48
  */
49
49
  export function defaultFavicon(): Uint8Array {
50
- const raster = createRaster(DEFAULT_SIZE, DEFAULT_SIZE, 'favicon');
50
+ const raster = blankRaster(DEFAULT_SIZE, DEFAULT_SIZE, 'favicon');
51
51
  const { pixels } = raster;
52
52
  for (let i = 0; i < pixels.length; i += 4) {
53
53
  pixels[i] = MARK_LEVEL;