@ultimat3/cli 24.0.0 → 25.1.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 (243) 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 +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
@@ -5,6 +5,10 @@
5
5
  // 100% CPU, measured at ~50 MB/s of heap growth). Found on a laptop, 2026-09-29: two children of
6
6
  // `cmd-dev-restart.live.test.ts`, six hours old, ppid 1, cwd deleted, one at 7 GB.
7
7
  //
8
+ // Both facts and the stop itself hold on Windows: the supervisor is asked for by pid
9
+ // (`process.kill(pid, 0)`), since a Windows child's ppid never changes, and a stop arrives as a
10
+ // `drain` message over IPC — `kill('SIGTERM')` there is TerminateProcess, with no drain at all.
11
+ //
8
12
  // And a stop that does not finish is finished for it. A drain is bounded by core's deadline, but a
9
13
  // loop starved by a spin like the one above reaches its timers late or never, so every stop this
10
14
  // child begins — a signal, the watch below, a restart — carries a hard exit past that bound.
@@ -29,8 +33,8 @@ export interface DevChildWatchInput {
29
33
  readonly env: Readonly<Record<string, string | undefined>>;
30
34
  /** Called once, when the child decides to leave. */
31
35
  readonly leave: (why: DevChildGone) => void;
32
- /** Test seams: the defaults read the process and the disk. */
33
- readonly parentPid?: () => number;
36
+ /** Test seams: the defaults read the process table and the disk. */
37
+ readonly supervisorAlive?: (pid: number) => boolean;
34
38
  readonly rootExists?: (root: string) => boolean;
35
39
  readonly intervalMs?: number;
36
40
  }
@@ -43,21 +47,46 @@ export function supervisorPid(
43
47
  return Number.isInteger(said) && said > 0 ? said : undefined;
44
48
  }
45
49
 
50
+ /** `process.kill(pid, 0)`'s answer: delivered, or refused for permission — both mean it exists. */
51
+ export function pidAlive(
52
+ pid: number,
53
+ kill: (pid: number, signal: 0) => void = process.kill,
54
+ ): boolean {
55
+ try {
56
+ kill(pid, 0);
57
+ return true;
58
+ } catch (error) {
59
+ return (error as { readonly code?: unknown } | null)?.code === 'EPERM';
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Whether the supervisor at `pid` still runs this child. POSIX asks the ppid — immune to a reused
65
+ * pid, and a supervisor that died before the first tick is still caught, since the child is
66
+ * reparented. Windows never reparents (the ppid stays the dead supervisor's forever), so there the
67
+ * process table is asked directly.
68
+ */
69
+ export function defaultSupervisorAlive(
70
+ platform: string = process.platform,
71
+ ): (pid: number) => boolean {
72
+ return platform === 'win32' ? (pid) => pidAlive(pid) : (pid) => process.ppid === pid;
73
+ }
74
+
46
75
  /**
47
- * Polls both facts until one fails, then calls `leave` once and stops. Answers the stop.
48
- * `process.ppid` rather than a pipe from the supervisor: stdin is the terminal's (`inherit`), and
49
- * a supervisor that died before the first tick is still caught — its pid is never ours again.
76
+ * Polls both facts until one fails, then calls `leave` once and stops. Answers the stop. Polled
77
+ * rather than a pipe from the supervisor: stdin is the terminal's (`inherit`), and IPC closing is
78
+ * not a fact Bun reports to the child.
50
79
  */
51
80
  export function watchDevChild(input: DevChildWatchInput): () => void {
52
81
  const expected = supervisorPid(input.env);
53
- const parent = input.parentPid ?? (() => process.ppid);
82
+ const alive = input.supervisorAlive ?? defaultSupervisorAlive();
54
83
  const exists = input.rootExists ?? existsSync;
55
84
  let left = false;
56
85
  const timer = setInterval(
57
86
  () => {
58
87
  if (left) return;
59
88
  const why: DevChildGone | undefined =
60
- expected !== undefined && parent() !== expected
89
+ expected !== undefined && !alive(expected)
61
90
  ? 'supervisor'
62
91
  : exists(input.root)
63
92
  ? undefined
@@ -116,10 +145,26 @@ export function hardExit(
116
145
  };
117
146
  }
118
147
 
148
+ /** What the supervisor sends for a stop: the signal it would have forwarded on POSIX. */
149
+ export interface DevDrainMessage {
150
+ readonly type: 'x-dev-drain';
151
+ readonly signal: 'SIGINT' | 'SIGTERM';
152
+ }
153
+
154
+ export function isDevDrainMessage(message: unknown): message is DevDrainMessage {
155
+ if (typeof message !== 'object' || message === null) return false;
156
+ const { type, signal } = message as Record<string, unknown>;
157
+ return type === 'x-dev-drain' && (signal === 'SIGINT' || signal === 'SIGTERM');
158
+ }
159
+
160
+ /** The exit code a stop for `signal` answers: 128 + its number, as a shell reports it. */
161
+ export const signalExitCode = (signal: 'SIGINT' | 'SIGTERM'): number =>
162
+ signal === 'SIGINT' ? 130 : 143;
163
+
119
164
  /**
120
- * The child's watch, wired to core's drain: the supervisor's death or the root's, and SIGINT or
121
- * SIGTERM, each begin a stop that ends within `devHardExitMs` however the drain fares. `onGone`
122
- * marks why, so the hold's exit can answer a code.
165
+ * The child's watch, wired to core's drain: the supervisor's death or the root's, SIGINT or
166
+ * SIGTERM, and the supervisor's `drain` message each begin a stop that ends within
167
+ * `devHardExitMs` however the drain fares. `onGone` marks why, so the hold's exit can answer a code.
123
168
  */
124
169
  export function startDevChildWatch(
125
170
  root: string,
@@ -128,8 +173,14 @@ export function startDevChildWatch(
128
173
  ): { readonly stopping: (code: number) => void } {
129
174
  const stopping = hardExit((code) => process.exit(code));
130
175
  // Beside core's own handlers, never instead of them: the drain is theirs, the deadline is this.
131
- process.on('SIGINT', () => stopping(130));
132
- process.on('SIGTERM', () => stopping(143));
176
+ process.on('SIGINT', () => stopping(signalExitCode('SIGINT')));
177
+ process.on('SIGTERM', () => stopping(signalExitCode('SIGTERM')));
178
+ // The supervisor's stop, on every platform: the same drain a signal begins, by message.
179
+ process.on('message', (message: unknown) => {
180
+ if (!isDevDrainMessage(message)) return;
181
+ stopping(signalExitCode(message.signal));
182
+ void drain(message.signal);
183
+ });
133
184
  watchDevChild({
134
185
  root,
135
186
  env,
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { proveSameOrigin, renderFixShellArg, UltimateError } from '@ultimat3/core';
6
6
  import type { UltimateRequest } from '@ultimat3/http';
7
- import { csrfBlocked, HttpError, json as jsonResponse, selfOrigin } from '@ultimat3/http';
7
+ import { csrfBlocked, HttpError, jsonResponse, selfOrigin } from '@ultimat3/http';
8
8
  import { t } from '@ultimat3/schema';
9
9
  import { isLoopbackHostname } from './loopback-host';
10
10
 
@@ -24,7 +24,7 @@ import {
24
24
  import { recentInvalidations } from '@ultimat3/cache';
25
25
  import type { Role } from '@ultimat3/core';
26
26
  import type { Route, UltimateRequest } from '@ultimat3/http';
27
- import { json as jsonResponse } from '@ultimat3/http';
27
+ import { jsonResponse } from '@ultimat3/http';
28
28
  import type { MemoryMailDriver } from '@ultimat3/mail';
29
29
  import { isMemoryDriver } from '@ultimat3/mail';
30
30
  import type { Manifest } from '@ultimat3/manifest';
@@ -38,6 +38,7 @@ import type { Finding } from './output';
38
38
  import type { DevServices } from './runtime-bindings';
39
39
  import type { RunningServices } from './runtime-services';
40
40
  import { loopFacts } from './statement-loop';
41
+ import { styleBundle } from './style-bundle';
41
42
 
42
43
  export interface DevStatus {
43
44
  readonly url: string;
@@ -241,6 +242,9 @@ export function devDashboardRoutes(input: DevDashboardInput): readonly Route[] {
241
242
  basePath: DEV_BASE_PATH,
242
243
  panels,
243
244
  sources: devSources(input),
245
+ // The drawn tabs render ui's components, whose rules ride the `app/` surface's bundle — the
246
+ // same URL an `app/` document links, asked per request because a rebuild moves the hash.
247
+ stylesheetHref: () => styleBundle().hrefFor('app'),
244
248
  ...(input.env === undefined ? {} : { env: input.env }),
245
249
  });
246
250
 
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, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
17
+ import { linkSync, mkdirSync, readFileSync, renameSync, 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';
@@ -142,6 +142,26 @@ export class DevLockUnreadableError extends UltimateError {
142
142
  }
143
143
  }
144
144
 
145
+ /**
146
+ * The state directory refused the claim itself: it could not be created, the lock could not be
147
+ * staged in it, or the filesystem would not hard-link the staged file into place. None of that is
148
+ * another `x dev` — only `EEXIST` is — and escaping raw it reached the operator as
149
+ * `X_CLI_UNEXPECTED` with `fix: x doctor --json`, which says nothing about `.x/`.
150
+ */
151
+ export class DevStateUnwritableError extends UltimateError {
152
+ constructor(input: { readonly stateDir: string; readonly error: unknown }) {
153
+ // Read the field, never cast and dereference: `unknown` is what a catch hands over.
154
+ const errno = stringField(input.error, 'code') ?? 'an unreported errno';
155
+ super({
156
+ code: 'X_DEV_STATE_UNWRITABLE',
157
+ cause: `${input.stateDir} could not be created, written, or hard-linked into (${errno}), so x dev cannot take its lock — a read-only checkout, a .x/ this user may not write, or a filesystem without hard links`,
158
+ fix: `ls -ld ${quoteArg(input.stateDir)} # make it writable by this user, on a filesystem that supports hard links, then re-run x dev`,
159
+ meta: { stateDir: input.stateDir, errno },
160
+ sourceError: input.error,
161
+ });
162
+ }
163
+ }
164
+
145
165
  /** Whatever is listening, as far as the OS will say. Both fields absent when it will not say. */
146
166
  export interface PortHolder {
147
167
  readonly pid?: number;
@@ -151,7 +171,8 @@ export interface PortHolder {
151
171
 
152
172
  /**
153
173
  * Who holds the port. `ss` first because it is present on every Linux box the framework targets and
154
- * needs no elevation for your own processes; `lsof` is the macOS answer.
174
+ * needs no elevation for your own processes; `lsof` is the macOS answer; `netstat -ano` plus
175
+ * `tasklist` the Windows one.
155
176
  *
156
177
  * Through `exec.ts`, like every other subprocess the CLI runs, so a test injects a fake `Runner`
157
178
  * rather than racing a real socket.
@@ -159,7 +180,7 @@ export interface PortHolder {
159
180
  * EVERY PROBE IS CAUGHT SEPARATELY. `exec` refuses a missing program with `X_CLI_UNEXPECTED`, and
160
181
  * letting that escape would mean a box without `ss` gets the CLI's catch-all instead of
161
182
  * `X_PORT_IN_USE` — the exact substitution this module exists to end. A missing `ss` must fall
162
- * through to `lsof`, and a box with neither still gets the refusal, just without a pid in it.
183
+ * through to `lsof`, and a box with none still gets the refusal, just without a pid in it.
163
184
  */
164
185
  export const portHolder = async (port: number, runner: Runner = exec): Promise<PortHolder> => {
165
186
  const probe = async (command: readonly string[]): Promise<string> => {
@@ -185,9 +206,38 @@ export const portHolder = async (port: number, runner: Runner = exec): Promise<P
185
206
  if (pid !== undefined && /^\d+$/.test(pid)) {
186
207
  return command === undefined ? { pid: Number(pid) } : { command, pid: Number(pid) };
187
208
  }
188
- return {};
209
+
210
+ // Windows has neither: `netstat -ano` names the listening pid, `tasklist` its image.
211
+ const listener = netstatListener(await probe(['netstat', '-ano', '-p', 'TCP']), port);
212
+ if (listener === undefined) return {};
213
+ const image = tasklistImage(
214
+ await probe(['tasklist', '/FI', `PID eq ${String(listener)}`, '/FO', 'CSV', '/NH']),
215
+ );
216
+ return image === undefined ? { pid: listener } : { command: image, pid: listener };
189
217
  };
190
218
 
219
+ /**
220
+ * The pid `netstat -ano` reports LISTENING on `port`, any local address: `TCP 127.0.0.1:3000
221
+ * 0.0.0.0:0 LISTENING 4242`, and `[::1]:3000` for IPv6. The state column is the word Windows
222
+ * prints in English on every locale this was read on; an unrecognised line is skipped.
223
+ */
224
+ export function netstatListener(text: string, port: number): number | undefined {
225
+ for (const line of text.split(/\r?\n/)) {
226
+ const [proto, local, , state, pid] = line.trim().split(/\s+/);
227
+ if (proto !== 'TCP' || state !== 'LISTENING' || pid === undefined || !/^\d+$/.test(pid)) {
228
+ continue;
229
+ }
230
+ if (local?.endsWith(`:${String(port)}`) === true) return Number(pid);
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ /** The image name off `tasklist /FO CSV /NH`'s one row — `"bun.exe","4242",…` — or `undefined`. */
236
+ export function tasklistImage(text: string): string | undefined {
237
+ const name = /^"([^"]+)","\d+"/m.exec(text.trim())?.[1];
238
+ return name === undefined || name === '' ? undefined : name;
239
+ }
240
+
191
241
  /**
192
242
  * The port is bound by something that is not us. Named separately from the lock case because the
193
243
  * remedy is different: a stranger's port is worked around with `--port`, while a second `x dev` on
@@ -241,33 +291,46 @@ export const isPortBound = (port: number, hostname: string): boolean => {
241
291
  }
242
292
  };
243
293
 
294
+ /** A sibling of the lock, unique to this process and call, where its bytes are staged. */
295
+ const stagingPath = (path: string): string =>
296
+ `${path}.${process.pid}.${crypto.randomUUID().slice(0, 8)}.tmp`;
297
+
298
+ const lockText = (lock: DevLock): string => `${JSON.stringify(lock, null, 2)}\n`;
299
+
244
300
  /**
245
301
  * Take the lock, or answer `false` because someone else holds it.
246
302
  *
247
- * `wx` is the whole mechanism: the create and the exclusivity are ONE syscall, so two boots racing
248
- * this cannot both come back `true`. A check followed by a write is what this replaces, and the
249
- * window between those two was seconds wide — `startDev` boots embedded Postgres, the queue, the
250
- * transport and the app's modules before anything was written down.
303
+ * Written whole to a staging file first, then `linkSync`ed into place: the link is the create AND
304
+ * the exclusivity in ONE syscall (`EEXIST` when the name is taken), so two boots racing this cannot
305
+ * both come back `true` — and the lock path never exists without its contents. It was `openSync(
306
+ * path, 'wx')` and THEN a write; a racing preflight that read the file between the two parsed an
307
+ * empty lock as stale, unlinked the live claim and took the slot itself.
251
308
  *
252
- * Only `EEXIST` is "someone else has it". Anything else — a read-only checkout, a `.x/` nobody may
253
- * write — is rethrown as it arrives: it is the same failure `writeLock` would have raised seconds
254
- * later, and inventing a code for it here would be a second answer to one condition.
309
+ * Only `EEXIST` is "someone else has it". Any other refusal of the staging write or the link — a
310
+ * `.x/` this user may not write, a filesystem without hard links — is `X_DEV_STATE_UNWRITABLE`
311
+ * naming the directory and the errno, the original kept as its `sourceError`.
255
312
  */
256
- function claimExclusive(path: string, lock: DevLock): boolean {
257
- let fd: number;
313
+ function claimExclusive(stateDir: string, path: string, lock: DevLock): boolean {
314
+ const staged = stagingPath(path);
258
315
  try {
259
- fd = openSync(path, 'wx');
316
+ writeFileSync(staged, lockText(lock));
260
317
  } catch (error) {
261
- // Same rule as `isProcessAlive` above: read the field, never cast and dereference.
262
- if (stringField(error, 'code') === 'EEXIST') return false;
263
- throw error;
318
+ throw new DevStateUnwritableError({ stateDir, error });
264
319
  }
265
320
  try {
266
- writeFileSync(fd, `${JSON.stringify(lock, null, 2)}\n`);
321
+ linkSync(staged, path);
322
+ return true;
323
+ } catch (error) {
324
+ // Same rule as `isProcessAlive` above: read the field, never cast and dereference.
325
+ if (stringField(error, 'code') === 'EEXIST') return false;
326
+ throw new DevStateUnwritableError({ stateDir, error });
267
327
  } finally {
268
- closeSync(fd);
328
+ try {
329
+ unlinkSync(staged);
330
+ } catch {
331
+ // Already gone; the staging name is unique to this call, so nothing else can need it.
332
+ }
269
333
  }
270
- return true;
271
334
  }
272
335
 
273
336
  /** Whatever is on disk right now, or `null` if it is absent or half-written. */
@@ -321,7 +384,11 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
321
384
  const release = (): void => clearLock(input.stateDir);
322
385
  // The lock lives inside the state directory, which the database has not created yet — this runs
323
386
  // before anything boots, which is the whole point of it.
324
- mkdirSync(input.stateDir, { recursive: true });
387
+ try {
388
+ mkdirSync(input.stateDir, { recursive: true });
389
+ } catch (error) {
390
+ throw new DevStateUnwritableError({ stateDir: input.stateDir, error });
391
+ }
325
392
  const mine: DevLock = {
326
393
  pid: process.pid,
327
394
  port: input.port,
@@ -332,7 +399,7 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
332
399
  };
333
400
  let clearedStale = false;
334
401
 
335
- if (!claimExclusive(path, mine)) {
402
+ if (!claimExclusive(input.stateDir, path, mine)) {
336
403
  const held = await readLock(path);
337
404
  if (held !== null && alive(held.pid)) {
338
405
  throw new DevAlreadyRunningError({
@@ -349,7 +416,7 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
349
416
  // Already gone, or not ours to remove. The claim below is what decides.
350
417
  }
351
418
  clearedStale = true;
352
- if (!claimExclusive(path, mine)) {
419
+ if (!claimExclusive(input.stateDir, path, mine)) {
353
420
  // Lost the race for the slot we just cleared. `held` is the best identity available — a
354
421
  // half-written file parses as `null` for microseconds — and refusing on a stale pid beats
355
422
  // the alternative, which is two processes writing one single-writer data directory.
@@ -388,7 +455,12 @@ export const preflight = async (input: PreflightInput): Promise<PreflightResult>
388
455
  * with the address the server actually bound, which is the one field the preflight could not know.
389
456
  */
390
457
  export const writeLock = async (stateDir: string, lock: DevLock): Promise<void> => {
391
- await Bun.write(lockPath(stateDir), `${JSON.stringify(lock, null, 2)}\n`);
458
+ // Staged and renamed over the claim, never rewritten in place: a truncate-then-write leaves an
459
+ // empty lock for an instant, and a preflight reading it then would clear a live claim as stale.
460
+ const path = lockPath(stateDir);
461
+ const staged = stagingPath(path);
462
+ writeFileSync(staged, lockText(lock));
463
+ renameSync(staged, path);
392
464
  };
393
465
 
394
466
  /** Remove it. Safe to call twice — shutdown paths overlap, and a throw here would mask the real one. */
@@ -0,0 +1,80 @@
1
+ // The `x dev` reload: one save in, the manifest, the findings and the island bundles rebuilt as
2
+ // one generation, the ISR store emptied, and a save this process cannot serve handed to the
3
+ // supervisor as a restart. Coalesced, so a tick arriving mid-build is one trailing rebuild.
4
+
5
+ import type { Manifest } from '@ultimat3/manifest';
6
+ import type { IsrController } from '@ultimat3/render/server';
7
+ import { appManifest } from './app-manifest';
8
+ import type { StalePin } from './app-reload-graph';
9
+ import { takeStalePins } from './app-reload-graph';
10
+ import type { ReloadTrigger } from './dev-reload';
11
+ import { coalesceReloads } from './dev-reload';
12
+ import { restartFinding } from './dev-supervisor';
13
+ import type { IslandBundle } from './island-bundle';
14
+ import { buildIslands } from './island-bundle';
15
+ import type { Finding } from './output';
16
+ import { findingFrom } from './output';
17
+
18
+ export interface DevState {
19
+ manifest: Manifest;
20
+ reloads: number;
21
+ /** A save that will not build. Replaced on every attempt, so a fixed file clears it. */
22
+ reloadFinding: Finding | undefined;
23
+ /**
24
+ * Modules that would not import and primitives that would not register, as of the LAST scan —
25
+ * the boot's, then each rebuild's. A page saved with a syntax error is a finding here until the
26
+ * save that fixes it, and the boot's list alone would never show it.
27
+ */
28
+ appFindings: readonly Finding[];
29
+ /**
30
+ * The client entries, rebuilt on the same tick as the manifest. An island is the one module this
31
+ * process never imports, so a fresh `Bun.build` is the whole of its reload. The route module
32
+ * beside it is re-imported by that same scan (`app-load.ts`) — the two are one generation, or
33
+ * a save serves a new island under an old page, which is what it did until 2026-09-07.
34
+ */
35
+ islands: IslandBundle;
36
+ }
37
+
38
+ /** What a rebuild reports to, read off `StartDevOptions`. */
39
+ export interface DevRebuildOptions {
40
+ readonly root: string;
41
+ readonly onReload?: (file: string, durationMs: number) => void;
42
+ readonly onRestart?: (pins: readonly StalePin[]) => void;
43
+ }
44
+
45
+ /** The watcher's `onChange`: rebuild `state` for the saved `file`, never rejecting. */
46
+ export function devRebuilder(
47
+ options: DevRebuildOptions,
48
+ state: DevState,
49
+ isr: IsrController,
50
+ ): ReloadTrigger {
51
+ // One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
52
+ // trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
53
+ return coalesceReloads(
54
+ async (file) => {
55
+ const started = performance.now();
56
+ const [{ manifest, findings }, islands] = await Promise.all([
57
+ appManifest(options.root),
58
+ buildIslands(options.root),
59
+ ]);
60
+ state.manifest = manifest;
61
+ state.appFindings = findings;
62
+ state.islands = islands;
63
+ for (const path of isr.store().paths()) isr.store().delete(path);
64
+ state.reloads += 1;
65
+ // Read on EVERY rebuild, so a pin one save reached is never reported against the next save.
66
+ const pins = takeStalePins();
67
+ state.reloadFinding =
68
+ pins.length > 0 && options.onRestart === undefined
69
+ ? restartFinding(options.root, pins)
70
+ : undefined;
71
+ if (pins.length > 0 && options.onRestart !== undefined) options.onRestart(pins);
72
+ else options.onReload?.(file, Math.round(performance.now() - started));
73
+ },
74
+ // Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
75
+ // a finding on `/_x`, never an unhandled rejection that takes the dev server down.
76
+ (error: unknown, file: string) => {
77
+ state.reloadFinding = { ...findingFrom(error), at: file };
78
+ },
79
+ );
80
+ }
@@ -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
  };