@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
package/src/messages.ts CHANGED
@@ -86,6 +86,9 @@ const CATALOG = {
86
86
  'cli.dev.mail.refused': 'mail=refused({detail})',
87
87
  'cli.dev.hmr': 'reloaded {file} in {ms}ms',
88
88
  'cli.dev.restart': 'restarting: {reason} — a module that defines a primitive holds the old code',
89
+ // The restart's wait, said while it waits: a save never cancels a running job (issue #677).
90
+ 'cli.dev.restartWaiting':
91
+ 'restart waits for {count} running job(s) to finish — no new job is claimed; Ctrl-C stops now (a job still running is cut off at the drain deadline and requeued)',
89
92
  // A hard kill leaves the lock behind and that is normal, not a fault — worth one line so a
90
93
  // reader knows why the boot paused, and never a finding.
91
94
  'cli.dev.staleLock': 'cleared a stale dev.lock — the previous x dev did not shut down cleanly',
@@ -143,9 +146,6 @@ const CATALOG = {
143
146
  'cli.jobs.deadLetters': '{count} dead letter(s):',
144
147
  'cli.jobs.depth':
145
148
  '{ready} ready · {running} running · {delayed} delayed · {dead} dead across {queues} queue(s)',
146
- 'cli.jobs.drained': 'drained {count} job(s) from {from} to {to}',
147
- 'cli.jobs.drainedPartial':
148
- 'drained {count} job(s) from {from} to {to} — {skipped} left on {from}',
149
149
  'cli.jobs.listed': '{count} job(s)',
150
150
  'cli.jobs.nextPage': 'more — next page: x jobs ls --after {cursor}',
151
151
  'cli.jobs.noError': 'no error recorded',
@@ -156,24 +156,30 @@ const CATALOG = {
156
156
  'cli.jobs.resumed': 'queue {queue} resumed',
157
157
  'cli.jobs.retried': 'job {id} re-queued — {state}',
158
158
  'cli.jobs.shown': 'job {id} — {state}, attempt {attempt} of {attempts}',
159
- 'cli.jobs.skipped': '{count} job(s) left on {from} — re-run the drain once each is claimable:',
160
159
  'cli.manifest.blocked': 'manifest not written — {count} module(s) did not load',
161
160
  'cli.manifest.fresh': 'manifest is fresh',
162
161
  'cli.manifest.stale': 'manifest is stale',
163
162
  'cli.manifest.wrote': 'manifest written to {path} ({routes} routes, {actions} actions)',
164
163
  'cli.mcp.serving': 'mcp {transport} serving {tools} tools',
165
164
  'cli.mcp.scopes': ' scopes {scopes}',
166
- // `bin/setup` and nothing else: the scaffold ships it, `README.md` and `bin/check` both name it,
167
- // and it is the only spelling that is right on a fresh clone — it installs, writes
168
- // `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no migration, so the
169
- // drift step is red until it has), migrates and seeds. The four-command line this replaced named
170
- // `x dev` off a tree where nothing had installed the CLI yet, and skipped the seed entirely.
171
- // `bin/dev`, never `x dev`: `bun install` links the binary into `./node_modules/.bin` and
172
- // nowhere else, so the bare `x` this line printed is not on PATH in the shell it is pasted into
173
- // (proved with `env -i PATH=… command -v x`). The scaffold's own `bin/` wrappers are the form
174
- // that works from a fresh clone, and `bin/setup` already uses `bunx x` internally for this
175
- // reason.
176
- 'cli.new.done': 'created {name} — next: cd {name} && bin/setup && bin/dev',
165
+ // `bun run setup` and nothing else: the scaffold ships it (`bin/setup.ts`), `README.md` and
166
+ // `bun run check` both name it, and it is the only spelling that is right on a fresh clone — it
167
+ // installs, writes `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no
168
+ // migration, so the drift step is red until it has), migrates and seeds. The four-command line
169
+ // this replaced named `x dev` off a tree where nothing had installed the CLI yet, and skipped the
170
+ // seed entirely.
171
+ // `bun run dev`, never `x dev`: `bun install` links the binary into `./node_modules/.bin` and
172
+ // nowhere else, so a bare `x` is not on PATH in the shell it is pasted into (proved with
173
+ // `env -i PATH=… command -v x`); `bun run` puts that directory on PATH for the script. And
174
+ // `bun run`, never a `bin/` path: Bun runs the line identically in PowerShell, cmd and bash.
175
+ // `{cd}` is the resolved target as the caller's shell reaches it — `--dir` included, relative to
176
+ // the cwd when under it, shell-quoted (`cmd-new.ts`'s `cdTarget`). `cd {name}` entered the wrong
177
+ // directory, or none, whenever `--dir` put the app somewhere else.
178
+ 'cli.new.done': 'created {name} — next: cd {cd} && bun run setup && bun run dev',
179
+ // `--dry-run`'s own summary: nothing landed, so "created" was a claim an agent branching on the
180
+ // summary would act on. `{dir}` is the resolved target, `--dir` included.
181
+ 'cli.new.dryRun':
182
+ 'would create {name} in {dir} — nothing written; after a real run: cd {cd} && bun run setup && bun run dev',
177
183
  // The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
178
184
  // one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
179
185
  // command is a broken one — the same split `Finding.fix` already makes.
@@ -197,6 +203,8 @@ const CATALOG = {
197
203
  'cli.registry.described': '{kind} {name}',
198
204
  'cli.routes.count': '{count} routes',
199
205
  'cli.routes.empty': 'no routes in the manifest — run `x manifest` first',
206
+ 'cli.auth.sealed':
207
+ 'sealed {sealed} second-factor secret(s); {already} already sealed, {skipped} changed mid-run and left alone',
200
208
  'cli.tasks.count': '{count} task(s)',
201
209
  'cli.tasks.shown': '{name} — {cron} ({tz}), next {next}',
202
210
  'cli.affected.count':
@@ -233,6 +241,10 @@ const CATALOG = {
233
241
  'cli.shot.pageError': ' threw {message} {at}',
234
242
  'cli.shot.picture': ' picture {path}',
235
243
  'cli.shot.verdict': ' verdict {path}',
244
+ // `--viewport`: one route at several sizes. The summary is ONE size's verdict, so it says which
245
+ // size; each size line leads with its size and the per-size detail lines sit indented beneath.
246
+ 'cli.shot.viewport.summary': '{size}: {summary}',
247
+ 'cli.shot.viewport.line': '{size} {summary}',
236
248
  // `--island`. A component capture reports per STATE, so the summary counts pictures and the
237
249
  // lines name one state each; nothing here restates a fact `--json` does not carry.
238
250
  'cli.shot.island.ok':
@@ -302,7 +314,7 @@ const CATALOG = {
302
314
  // A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
303
315
  // ones are named, because "which suite has nothing to run here?" is the question a green gate
304
316
  // over a missing suite has to answer on its own line. Whole sentences per case rather than a
305
- // clause the caller glues on, the same shape `cli.jobs.drained`/`drainedPartial` already uses.
317
+ // clause the caller glues on: a catalog clause cannot know the sentence it lands in.
306
318
  'cli.verify.passSkipped':
307
319
  '{passed} of {count} steps passed in {ms}ms — {skipped} skipped: {names}',
308
320
  'cli.verify.failSkipped': '{failed} of {count} steps failed — {skipped} skipped: {names}',
@@ -313,6 +325,7 @@ const CATALOG = {
313
325
  // tests ran. Each test's own name carries the reason it skipped, and `bun test` prints only the
314
326
  // counts — so the line says how many and `x test <type>` prints the names.
315
327
  'cli.verify.allSkipped': 'found {skipped} test(s) and every one skipped itself',
328
+ 'cli.verify.warnings': '{count} warning(s) — --verbose or --json lists them',
316
329
  // The other shape the same counts can take: a suite that reported no test at all, skipped ones
317
330
  // included. Its own line and not `allSkipped` with a zero, because the repair differs — there is
318
331
  // no skipped test to read a reason off, so the files the step selected hold no test.
@@ -5,16 +5,20 @@
5
5
 
6
6
  import {
7
7
  finiteCount,
8
+ type HealthPayload,
9
+ healthzPayload,
8
10
  logger,
9
11
  METRICS_CONTENT_TYPE,
10
12
  METRICS_PATH,
11
13
  markListening,
12
14
  metricsText,
15
+ readyzPayload,
13
16
  startProcessMetrics,
14
17
  stringField,
15
18
  UltimateError,
16
19
  } from '@ultimat3/core';
17
20
  import { neighbouringPort } from './flag-number';
21
+ import { quoteArg } from './shell-quote';
18
22
 
19
23
  /**
20
24
  * A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
@@ -45,7 +49,7 @@ export class MetricsPortInUseError extends UltimateError {
45
49
  super({
46
50
  code: 'X_PORT_IN_USE',
47
51
  cause: `the metrics port ${input.port} is already bound, so no role could open its scrape listener`,
48
- fix: `METRICS_PORT=${neighbouringPort(input.port)} x dev --json`,
52
+ fix: `METRICS_PORT=${quoteArg(String(neighbouringPort(input.port)))} x dev --json`,
49
53
  });
50
54
  }
51
55
  }
@@ -111,7 +115,7 @@ export function bindScrapePort<T>(
111
115
  logger.warn('ultimate metrics port taken', {
112
116
  port,
113
117
  cause: `the metrics port ${String(port)} is already bound — another x dev, or a Prometheus — so this process listens on a free one`,
114
- fix: `METRICS_PORT=${String(neighbouringPort(port))} x dev --json`,
118
+ fix: `METRICS_PORT=${quoteArg(String(neighbouringPort(port)))} x dev --json`,
115
119
  });
116
120
  return bind(0);
117
121
  }
@@ -123,6 +127,18 @@ export interface MetricsEndpoint {
123
127
  stop(): void;
124
128
  }
125
129
 
130
+ /**
131
+ * `/healthz` and `/readyz` for the roles whose only socket this is — `worker`, `scheduler`,
132
+ * `replicator` — so the replicator's readiness check (`role-replicator.ts`) is read by a probe.
133
+ * The verdict only, never the report: this port is unauthenticated, and check names are topology.
134
+ */
135
+ function healthResponse(payload: HealthPayload, role: string): Response {
136
+ return Response.json(
137
+ { state: payload.body.state, ready: payload.body.ready, role },
138
+ { status: payload.status, headers: { 'cache-control': 'no-store' } },
139
+ );
140
+ }
141
+
126
142
  /**
127
143
  * Answers outside the request pipeline, exactly as `/healthz` and `/readyz` do in
128
144
  * `@ultimat3/http`'s `server.ts`: no auth, no rate limit, no locale negotiation. A saturated or
@@ -142,7 +158,16 @@ export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): Metr
142
158
  port: candidate,
143
159
  hostname: options.hostname ?? 'localhost',
144
160
  fetch(request: Request): Response {
145
- if (new URL(request.url).pathname !== METRICS_PATH) {
161
+ const url = new URL(request.url);
162
+ const role = options.role ?? 'unknown';
163
+ if (url.pathname === '/healthz') return healthResponse(healthzPayload(), role);
164
+ if (url.pathname === '/readyz') {
165
+ return healthResponse(
166
+ readyzPayload({ deep: url.searchParams.get('deep') === '1' }),
167
+ role,
168
+ );
169
+ }
170
+ if (url.pathname !== METRICS_PATH) {
146
171
  return new Response('not found', { status: 404 });
147
172
  }
148
173
  // `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
@@ -24,6 +24,6 @@ export function migrationExtensions(migrations: readonly Migration[]): readonly
24
24
  return [...names].sort();
25
25
  }
26
26
 
27
- /** The same list for an app on disk — what an embedded boot hands `createPgliteClient`. */
27
+ /** The same list for an app on disk — what an embedded boot hands `pgliteClient`. */
28
28
  export const appExtensions = async (root: string): Promise<readonly string[]> =>
29
29
  migrationExtensions(await readMigrations(root));
@@ -36,7 +36,7 @@ export const METRIC_EXPORT_INTERVAL_MS = 60_000;
36
36
  * merge into process-global state, so stopping the timer and dropping the drain hooks left the
37
37
  * first boot's exporter configured: a second `serveApp` in the same process exported its spans
38
38
  * into a released exporter — queued against a collector nothing will flush to, on a timer nothing
39
- * clears. `cmd-dev.ts`'s `stop()` hands back `noopExporter` for exactly this reason. Per signal and
39
+ * clears. `dev-boot.ts`'s `stop()` hands back `noopExporter` for exactly this reason. Per signal and
40
40
  * never unconditionally: `x dev` configures a trace RECORDER before calling this, and a boot that
41
41
  * installed no exporter must not uninstall one it never owned.
42
42
  */
@@ -52,7 +52,7 @@ export function startOtlpExport(env: Env = process.env): () => void {
52
52
  const exporter = otlpSpanExporter({ endpoint: traces });
53
53
  configureTelemetry({ exporter });
54
54
  // Pushed first, so the reversed run below applies it LAST — after the drain hook is dropped,
55
- // the same order `cmd-dev.ts` releases the recorder in.
55
+ // the same order `dev-boot.ts` releases the recorder in.
56
56
  releases.push(() => configureTelemetry({ exporter: noopExporter }));
57
57
  releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
58
58
  logger.info('ultimate otlp traces', { endpoint: traces });
package/src/output.ts CHANGED
@@ -29,6 +29,12 @@ export interface Finding {
29
29
  * and must never be run as this CLI's own instruction.
30
30
  */
31
31
  readonly source?: 'ci-log';
32
+ /**
33
+ * Structured facts behind `cause`, for a `--json` reader that would otherwise parse the sentence:
34
+ * `X_VERIFY_STEP_TIMEOUT` carries the processes it killed and the files still in flight. Never
35
+ * the only home of a fact — the human render prints `cause` and `fix`, and both already say it.
36
+ */
37
+ readonly meta?: { readonly [key: string]: JsonValue };
32
38
  }
33
39
 
34
40
  export interface StepResult {
@@ -44,6 +50,12 @@ export interface StepResult {
44
50
  * coverage is `data.coverage.unit`), where `--json` and the terminal both always have it.
45
51
  */
46
52
  readonly output?: string;
53
+ /**
54
+ * Advice that does not fail the step — a file inside `filesize`'s band below the ceiling. Unlike
55
+ * `output`, carried by both renderers on a PASS too: advice a green run hides reaches nobody
56
+ * until it has become the failure it warns about. One line of text per warning.
57
+ */
58
+ readonly warnings?: readonly string[];
47
59
  /** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
48
60
  readonly workers?: number;
49
61
  /** Why the width is what it is — `4 workers (budget 4.0 GB)`. */
@@ -224,6 +236,14 @@ export function renderHuman(result: CommandResult, verbose = false): string {
224
236
  ` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}${why(step)}`,
225
237
  );
226
238
  for (const finding of step.findings) out.push(renderFinding(finding, ' '));
239
+ // Escaped like every other line text reaches fd 1 through: a warning names a file path. A
240
+ // green terminal run shows the count, not the list: `filesize`'s band alone is ~150 lines,
241
+ // and a wall of advice on every run is read by nobody. `--json` always carries the list.
242
+ const warnings = step.warnings ?? [];
243
+ if (verbose) for (const warning of warnings) out.push(` ! ${singleLine(warning)}`);
244
+ else if (warnings.length > 0) {
245
+ out.push(` ! ${msg('cli.verify.warnings', { count: warnings.length })}`);
246
+ }
227
247
  // NOT escaped, and that is the one exception: `output` is this process's own captured
228
248
  // subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
229
249
  // already split on its real newlines rather than carrying them inside one entry.
@@ -250,6 +270,10 @@ export function renderJson(result: CommandResult, verbose = false): string {
250
270
  durationMs: step.durationMs,
251
271
  skipped: step.skipped === true,
252
272
  findings: step.findings,
273
+ // Never gated on `ok` or `--verbose`, unlike `output` below: see `StepResult.warnings`.
274
+ ...(step.warnings === undefined || step.warnings.length === 0
275
+ ? {}
276
+ : { warnings: [...step.warnings] }),
253
277
  ...(step.workers === undefined ? {} : { workers: step.workers }),
254
278
  ...(step.widthReason === undefined ? {} : { widthReason: step.widthReason }),
255
279
  ...(step.shard === undefined
@@ -6,12 +6,9 @@
6
6
  // check a soft visit gets before `load`. No surface opted in: nothing is built, served or named — a
7
7
  // `site/` page that did not ask stays 0kb (axiom 6).
8
8
 
9
- // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
10
- // necessity `theme-boot.ts` records.
11
- import { join } from 'node:path';
12
9
  import { clientScopeOf } from '@ultimat3/auth';
13
10
  import type { Ctx, NavigationSurface } from '@ultimat3/core';
14
- import { CLIENT_NAVIGATION_SCOPE_HEADER, NAVIGATION_SURFACES } from '@ultimat3/core';
11
+ import { CLIENT_NAVIGATION_SCOPE_HEADER } from '@ultimat3/core';
15
12
  import type { Route, RouteNavigation, UltimateRequest } from '@ultimat3/http';
16
13
  import { locationFor, navigationPurpose, relocate } from '@ultimat3/http';
17
14
  import type { ClientNavigationHead, HeadTag, RouteEntry } from '@ultimat3/render';
@@ -22,8 +19,7 @@ import {
22
19
  routeEntries,
23
20
  } from '@ultimat3/render';
24
21
  import { ssrHeaders } from '@ultimat3/render/server';
25
- import { APP_CONFIG_EXPORT } from './app-auth';
26
- import { APP_CONFIG_FILE } from './app-root';
22
+ import { loadAppConfig } from './app-config-load';
27
23
  import { FrameworkScriptBuildFailedError } from './errors';
28
24
  import {
29
25
  buildNavigationScript,
@@ -56,26 +52,14 @@ export interface NavigationDeclaration {
56
52
  readonly surfaces: readonly NavigationSurface[];
57
53
  }
58
54
 
59
- const isRecord = (value: unknown): value is Record<string, unknown> =>
60
- typeof value === 'object' && value !== null;
61
-
62
- const isSurface = (value: unknown): value is NavigationSurface =>
63
- NAVIGATION_SURFACES.some((surface) => surface === value);
64
-
65
55
  /**
66
- * `navigation.client` and `name`, as `defineConfig` validated them — no surfaces when the app says
67
- * nothing or has no config file. Structural, never `instanceof`, for `loadThemeMode`'s reason.
56
+ * `navigation.client` and `name`, off the one loader (`app-config-load.ts`) — no surfaces, and the
57
+ * app called `app`, for a root with no config file.
68
58
  */
69
59
  export async function loadNavigation(root: string): Promise<NavigationDeclaration> {
70
- const configPath = join(root, APP_CONFIG_FILE);
71
- if (!(await Bun.file(configPath).exists())) return { app: 'app', surfaces: [] };
72
- const module = (await import(configPath)) as Record<string, unknown>;
73
- const config = module[APP_CONFIG_EXPORT];
74
- if (!isRecord(config)) return { app: 'app', surfaces: [] };
75
- const app = typeof config['name'] === 'string' ? config['name'] : 'app';
76
- const navigation = config['navigation'];
77
- const client = isRecord(navigation) ? navigation['client'] : undefined;
78
- return { app, surfaces: Array.isArray(client) ? client.filter(isSurface) : [] };
60
+ const config = await loadAppConfig(root);
61
+ if (config === undefined) return { app: 'app', surfaces: [] };
62
+ return { app: config.name, surfaces: config.navigation.client };
79
63
  }
80
64
 
81
65
  /**
@@ -5,42 +5,27 @@
5
5
  // the next page's document on pointer rest, so a full-page navigation between prerendered pages
6
6
  // paints from memory — and the page still ships 0kb of JavaScript (axiom 6).
7
7
 
8
- // why: Bun exposes no path-join primitive, and the config path is app-root-relative — the same
9
- // necessity `theme-boot.ts` records.
10
- import { join } from 'node:path';
11
8
  import type { NavigationSurface, SpeculationConfig } from '@ultimat3/core';
12
- import { DEFAULT_SPECULATION, localeSegment, resolveSpeculation } from '@ultimat3/core';
9
+ import { DEFAULT_SPECULATION, localeSegment } from '@ultimat3/core';
13
10
  import { cspHashSource } from '@ultimat3/http';
14
- import { localeConfig, routedLocales } from '@ultimat3/i18n';
11
+ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
15
12
  import type { RouteDescriptor, RouteEntry } from '@ultimat3/render';
16
13
  import {
17
- describeRoutes,
14
+ describePages,
18
15
  renderHead,
19
16
  routeEntries,
20
17
  speculationPattern,
21
18
  speculationRulesBody,
22
19
  speculationRulesTag,
23
20
  } from '@ultimat3/render';
24
- import { APP_CONFIG_EXPORT } from './app-auth';
25
- import { APP_CONFIG_FILE } from './app-root';
26
-
27
- const isRecord = (value: unknown): value is Record<string, unknown> =>
28
- typeof value === 'object' && value !== null;
21
+ import { loadAppConfig } from './app-config-load';
29
22
 
30
23
  /**
31
- * `navigation.speculation` — the default (on, `'moderate'`) when the app says nothing or has no
32
- * config file. Structural, for `loadThemeMode`'s reason, and so possibly NOT what `defineConfig`
33
- * validated: what it finds goes through core's `resolveSpeculation`, the one validator, which
34
- * REFUSES (`X_CONFIG_INVALID`) a value `defineConfig` would refuse. Never coerced: an `'eager'`
35
- * quietly served as `'moderate'` is a rule the app did not write.
24
+ * `navigation.speculation` off the one loader (`app-config-load.ts`), which holds it to core's
25
+ * `resolveSpeculation` — the default (on, `'moderate'`) for a root with no config file.
36
26
  */
37
27
  export async function loadSpeculation(root: string): Promise<SpeculationConfig> {
38
- const configPath = join(root, APP_CONFIG_FILE);
39
- if (!(await Bun.file(configPath).exists())) return DEFAULT_SPECULATION;
40
- const module = (await import(configPath)) as Record<string, unknown>;
41
- const config = module[APP_CONFIG_EXPORT];
42
- const navigation = isRecord(config) ? config['navigation'] : undefined;
43
- return resolveSpeculation(isRecord(navigation) ? navigation['speculation'] : undefined);
28
+ return (await loadAppConfig(root))?.navigation.speculation ?? DEFAULT_SPECULATION;
44
29
  }
45
30
 
46
31
  /**
@@ -82,15 +67,18 @@ export interface PageSpeculationInput {
82
67
  readonly client: readonly NavigationSurface[];
83
68
  readonly entries?: readonly RouteEntry[];
84
69
  readonly described?: readonly RouteDescriptor[];
85
- /** The NON-default routed locales' URL segments. Read from `@ultimat3/i18n` when absent. */
86
- readonly localeSegments?: readonly string[];
70
+ /**
71
+ * The NON-default routed locales' URL segments — `otherLocaleSegments(appLocaleSet)`, the set the
72
+ * manifest, the worker and the prerender read. Every caller passes it: no build reads the
73
+ * ambient locale config.
74
+ */
75
+ readonly localeSegments: readonly string[];
87
76
  }
88
77
 
89
- const otherLocaleSegments = (): readonly string[] => {
90
- const fallback = localeSegment(localeConfig().fallback);
91
- return routedLocales()
92
- .map(localeSegment)
93
- .filter((segment) => segment !== fallback);
78
+ /** Every routed locale but the default, as the URL segment a prefix is spelled in. */
79
+ export const otherLocaleSegments = (set: AppLocaleSet): readonly string[] => {
80
+ const fallback = localeSegment(set.defaultLocale);
81
+ return set.locales.map(localeSegment).filter((segment) => segment !== fallback);
94
82
  };
95
83
 
96
84
  /**
@@ -101,9 +89,9 @@ const otherLocaleSegments = (): readonly string[] => {
101
89
  export function pageSpeculation(input: PageSpeculationInput): PageSpeculation | undefined {
102
90
  if (input.config.prefetch === false) return undefined;
103
91
  const entries = input.entries ?? routeEntries();
104
- const described = new Map((input.described ?? describeRoutes()).map((one) => [one.file, one]));
92
+ const described = new Map((input.described ?? describePages()).map((one) => [one.file, one]));
105
93
  const client = new Set<string>(input.client);
106
- const segments = input.localeSegments ?? otherLocaleSegments();
94
+ const segments = input.localeSegments;
107
95
  const include = entries
108
96
  .filter((entry) => {
109
97
  const facts = described.get(entry.file);
package/src/parse.ts CHANGED
@@ -8,14 +8,6 @@ import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './err
8
8
  // anywhere while still refusing with a fix line a shell reads as one argument.
9
9
  import { quoteArg } from './shell-quote';
10
10
 
11
- /**
12
- * The historical name for `@ultimat3/core`'s `nearestName`, kept because it shipped on this
13
- * package's exported surface and removing it would be a major for a rename. One implementation
14
- * behind both — this is a delegation, not the second copy of the algorithm that `@ultimat3/policy`
15
- * used to carry. New callers import `nearestName` from core.
16
- */
17
- export const nearest = nearestName;
18
-
19
11
  export type FlagValue = string | boolean;
20
12
 
21
13
  export interface FlagSpec {
@@ -7,7 +7,7 @@ import { listActions } from '@ultimat3/action';
7
7
  import type { Policy } from '@ultimat3/policy';
8
8
  import { roleDefinitions, rolesGranting } from '@ultimat3/policy';
9
9
  import { listQueries } from '@ultimat3/query';
10
- import { describeRoutes, routeEntries } from '@ultimat3/render';
10
+ import { describePages, routeEntries } from '@ultimat3/render';
11
11
  import type { Finding } from './output';
12
12
  import { quoteArg } from './shell-quote';
13
13
 
@@ -46,12 +46,12 @@ export function requirements(): readonly UngrantedRequirement[] {
46
46
  // A route a package MOUNTED — the admin's — is in no file and so in no `routeEntries()` row. Its
47
47
  // screen decides on every permission of the mount, not only the coarse gate its route config
48
48
  // carries, so each one is a requirement: a hand-written entity nobody granted `<table>:read`
49
- // for was a green gate and a 403 on its first open. `describeRoutes()` is memoized by the
49
+ // for was a green gate and a 403 on its first open. `describePages()` is memoized by the
50
50
  // registry, so this loop reads a list every other step already built.
51
51
  // One requirement per permission per mount, naming the first route that asks it: `posts:write`
52
52
  // gates the create form and the edit form, and one missing grant is one finding.
53
53
  const asked = new Map<string, { permission: string; by: string; first: string; more: number }>();
54
- for (const route of describeRoutes()) {
54
+ for (const route of describePages()) {
55
55
  if (route.mount === undefined) continue;
56
56
  for (const permission of route.mount.permissions) {
57
57
  const key = `${route.mount.by} ${permission}`;
@@ -0,0 +1,30 @@
1
+ // One answer to "this host path, written with `/`". `relative()` and `Bun.Glob` answer in the
2
+ // host's separator, so on Windows a `\` reached an import specifier (`x fix` wrote
3
+ // `import '..\\foo'`, which breaks every Linux teammate), a fix line and a sort key. Every such
4
+ // result passes through here — `bun run posix-relative` refuses a `relative(` that does not.
5
+
6
+ // why: Bun ships no path API; `node:path` owns `relative`, and a test hands in its `win32` half to
7
+ // prove the Windows answer on a Linux runner.
8
+ import * as hostPath from 'node:path';
9
+
10
+ /**
11
+ * The one method `posixRelative` asks of a path module — structural, not `PlatformPath`: the
12
+ * scaffold's typecheck resolves `node:path` without that name exported (TS2305).
13
+ */
14
+ export interface RelativeHost {
15
+ readonly relative: (from: string, to: string) => string;
16
+ }
17
+
18
+ /**
19
+ * `path` with every `\` written as `/`. Unconditional rather than keyed to the host's `sep`: a key,
20
+ * a specifier and a fix line are POSIX on every host, and a Windows-shaped path handed to a Linux
21
+ * process (a CI log, a test fixture) must come out the same as it would on Windows.
22
+ */
23
+ export function toPosix(path: string): string {
24
+ return path.replaceAll('\\', '/');
25
+ }
26
+
27
+ /** `relative(from, to)` in POSIX form. `host` is the platform's own path module but for a test. */
28
+ export function posixRelative(from: string, to: string, host: RelativeHost = hostPath): string {
29
+ return toPosix(host.relative(from, to));
30
+ }
@@ -2,7 +2,8 @@
2
2
  // paths and files `renderStatic` computed; every other locale's are served at `/<locale>/<path>`
3
3
  // and written to `<locale>/<file>` — the layout every static host resolves with no rewrite rule.
4
4
 
5
- import { localeSegment, localizedPath } from '@ultimat3/i18n';
5
+ import { localeSegment, localizePath } from '@ultimat3/core';
6
+ import type { AppLocaleSet } from '@ultimat3/i18n/app-catalogs';
6
7
  import type { StaticArtifact } from '@ultimat3/render/server';
7
8
 
8
9
  /** A `StaticArtifact` placed for one locale: its served path and its file under `<locale>/`. */
@@ -10,8 +11,7 @@ export type LocalizedArtifact = StaticArtifact & { readonly locale: string };
10
11
 
11
12
  /** The default's artifacts first, so a report lists the unprefixed page before its translations. */
12
13
  export async function localizedArtifacts(
13
- locales: readonly string[],
14
- defaultLocale: string,
14
+ { locales, defaultLocale }: AppLocaleSet,
15
15
  renderIn: (locale: string) => Promise<readonly StaticArtifact[]>,
16
16
  ): Promise<readonly LocalizedArtifact[]> {
17
17
  const out: LocalizedArtifact[] = [];
@@ -21,7 +21,9 @@ export async function localizedArtifacts(
21
21
  out.push({
22
22
  ...artifact,
23
23
  locale,
24
- path: isDefault ? artifact.path : localizedPath(artifact.path, locale, defaultLocale),
24
+ path: isDefault
25
+ ? artifact.path
26
+ : localizePath(artifact.path, locale, locales, defaultLocale),
25
27
  outputPath: isDefault
26
28
  ? artifact.outputPath
27
29
  : `${localeSegment(locale)}/${artifact.outputPath}`,
@@ -1,25 +1,52 @@
1
1
  // The export directory is the build's own, and emptied before every build: `prerenderSite` only
2
2
  // ever ADDED files, so a route deleted from the app kept shipping its last HTML from `.x/static`.
3
- // Refused when `out` is the app root or above it — an `rm -rf` there deletes the app.
3
+ // Emptied only when it is provably the build's: absent, empty, or marked by an earlier build —
4
+ // `--out apps` deleted the app's pages and `--out .x` its embedded dev database.
4
5
 
5
- import { rm } from 'node:fs/promises'; // why: Bun has no recursive directory delete.
6
- import { isAbsolute, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
6
+ import { lstat, readdir, rm } from 'node:fs/promises'; // why: Bun has no recursive delete or readdir.
7
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'; // why: Bun ships no path resolve/relative.
7
8
  import { UltimateError } from '@ultimat3/core';
8
9
 
10
+ /** Written into every export directory a build empties: the proof the next build may empty it. */
11
+ export const EXPORT_MARKER = '.x-export';
12
+
13
+ /**
14
+ * The export directory `x build --target static` chooses itself. Exempt from the marker: a build
15
+ * from before the marker existed left it full and unmarked, and the build owns this path by name.
16
+ */
17
+ const DEFAULT_EXPORT = join('.x', 'static');
18
+
19
+ const MARKER_TEXT =
20
+ 'Written by x build --target static. This directory is emptied before every static build.\n';
21
+
9
22
  /** True when `dir` is `root` itself or one of its ancestors. */
10
23
  const containsRoot = (dir: string, root: string): boolean => {
11
24
  const up = relative(resolve(dir), resolve(root));
12
25
  return up === '' || (up !== '..' && !up.startsWith(`..${sep}`) && !isAbsolute(up));
13
26
  };
14
27
 
15
- /** Empties `out` for a fresh export, or refuses a directory that holds the app. */
28
+ /** Why `out` is not the build's to empty, or `undefined` when it is. */
29
+ async function unsafeReason(out: string, root: string): Promise<string | undefined> {
30
+ if (containsRoot(out, root)) return 'is the app root or holds it';
31
+ const stat = await lstat(out).catch(() => undefined);
32
+ if (stat === undefined) return undefined;
33
+ if (!stat.isDirectory()) return 'is not a directory';
34
+ const entries = await readdir(out);
35
+ if (entries.length === 0 || entries.includes(EXPORT_MARKER)) return undefined;
36
+ if (resolve(out) === resolve(root, DEFAULT_EXPORT)) return undefined;
37
+ return `already holds ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} and no ${EXPORT_MARKER} a previous build wrote`;
38
+ }
39
+
40
+ /** Empties `out` for a fresh export and marks it, or refuses a directory the build did not write. */
16
41
  export async function clearPrerenderOut(out: string, root: string): Promise<void> {
17
- if (containsRoot(out, root)) {
42
+ const reason = await unsafeReason(out, root);
43
+ if (reason !== undefined) {
18
44
  throw new UltimateError({
19
- code: 'X_CLI_BAD_FLAG',
20
- cause: `the static export directory ${resolve(out)} is the app root or holds it, and every build empties its export directory first`,
45
+ code: 'X_BUILD_OUT_UNSAFE',
46
+ cause: `the static export directory ${resolve(out)} ${reason}, and every build empties its export directory first`,
21
47
  fix: 'x build --target static --out .x/static --json',
22
48
  });
23
49
  }
24
50
  await rm(out, { recursive: true, force: true });
51
+ await Bun.write(join(out, EXPORT_MARKER), MARKER_TEXT);
25
52
  }
@@ -0,0 +1,58 @@
1
+ // What `x build --target static` reports: every page it wrote, every route it did not and why, and
2
+ // where the stats and the inventory landed. Split from `prerender.ts`, which decides what is
3
+ // written; this file only names the shapes the report carries.
4
+
5
+ import type { SkippedRoute, UnmeasuredRoute } from './static-report';
6
+
7
+ export interface PrerenderedPage {
8
+ /** The DECLARED route, `/blog/:slug` — one route can write many pages, and the report groups them. */
9
+ readonly route: string;
10
+ /** The URL the page is served at — `/en/pricing` for a non-default locale. */
11
+ readonly path: string;
12
+ /** The locale the page was rendered in. */
13
+ readonly locale: string;
14
+ /** Relative to `out`, POSIX — under `<locale>/` for a non-default locale. */
15
+ readonly file: string;
16
+ readonly hash: string;
17
+ readonly bytes: number;
18
+ }
19
+
20
+ export interface PrerenderReport {
21
+ readonly out: string;
22
+ readonly buildId: string;
23
+ readonly pages: readonly PrerenderedPage[];
24
+ /**
25
+ * Every declared route that wrote no file, WITH the cause. A bare path list was the whole of
26
+ * #242: `.x/static/` held a partial site, the report said only which paths were missing, and a
27
+ * screenshot tool pointed at the directory filed "the island did not mount" against a route that
28
+ * had never been in the artifact. The reason is what tells an author whether an edit exists.
29
+ */
30
+ readonly skipped: readonly SkippedRoute[];
31
+ /**
32
+ * Routes whose budget this build could not weigh, with the reason. `X_BUDGET_UNMEASURED` is what
33
+ * the gate then reports for each; this is the half that says WHY, which a per-route finding read
34
+ * off a stats file cannot know.
35
+ */
36
+ readonly unmeasured: readonly UnmeasuredRoute[];
37
+ /** Where the measured stats landed, for the `budgets` gate step to read. */
38
+ readonly stats: string;
39
+ /** Where the emitted/skipped inventory landed, for `x build --target static` to read back. */
40
+ readonly report: string;
41
+ /** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
42
+ readonly islands: readonly string[];
43
+ /** Surface stylesheets emitted, one file each — the CSS half of the same question. */
44
+ readonly styles: readonly string[];
45
+ /**
46
+ * What the service worker could not express, and what its precache manifest weighs too much of.
47
+ *
48
+ * `PrecacheManifest.warnings` had no reader anywhere in the tree — the precache budget was, in
49
+ * `wiki/Troubleshooting.md`'s own words, "a designed thing that is not one" (#390). An install
50
+ * that stalls on a bad connection is invisible on a laptop and fatal on a phone, so the number
51
+ * has to reach the build's own report. Empty for an app with no service worker.
52
+ */
53
+ readonly serviceWorkerWarnings: readonly string[];
54
+ /** What this build could not get right on its own — today, a production build with no origin. */
55
+ readonly warnings: readonly string[];
56
+ /** The origin every absolute URL in the export was built against — hand it to `siteSeo`. */
57
+ readonly origin: string;
58
+ }