@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/budgets.ts CHANGED
@@ -8,15 +8,20 @@
8
8
 
9
9
  import { existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
- import { ERROR_DOCS_URL } from '@ultimat3/core';
11
+ import { ERROR_DOCS_URL, formatBytes } from '@ultimat3/core';
12
12
  import type { Manifest, RouteFact } from '@ultimat3/manifest';
13
+ import { describePages, parseByteBudget, themeScriptBody } from '@ultimat3/render';
14
+ import type { ChargedFile } from './budgets-charged';
15
+ import { chargedClause, chargedMeta, heaviestFirst } from './budgets-charged';
13
16
  import {
14
- describeRoutes,
15
- formatBytes,
16
- parseByteBudget,
17
- SPECULATION_RULES_TYPE,
18
- themeScriptBody,
19
- } from '@ultimat3/render';
17
+ artifactPath,
18
+ carriesJson,
19
+ ENTRY_ATTR,
20
+ loadedBy,
21
+ SCRIPT_TAG,
22
+ SRC_ATTR,
23
+ } from './budgets-scan';
24
+ import { builtUnmeasuredFinding } from './budgets-unmeasured';
20
25
  import type { Finding } from './output';
21
26
  import type { UnmeasuredRoute } from './static-report';
22
27
  import { SW_REGISTER_PATH } from './sw-artifacts';
@@ -39,17 +44,15 @@ export interface RouteStats {
39
44
  * older build simply did not count it, and `checkBudgets` reads `jsBytes` either way.
40
45
  */
41
46
  readonly frameworkJsBytes?: number;
42
- /**
43
- * **Written by nothing, `As of 2026-08`.** `apps/web/prerender.ts` is the only producer of this
44
- * file and it emits static HTML — there is no browser in the build to observe a paint. So the
45
- * comparison in `checkBudgets` below is reachable only for an app that writes its own stats, and
46
- * `x new` no longer scaffolds an `lcp` budget for exactly that reason: a budget the build cannot
47
- * weigh passes silently the moment a stats row exists, which is the false green this file's
48
- * header is about. `RouteBudget.lcp` still accepts one — that key is `@ultimat3/render`'s.
49
- */
50
- readonly lcpMs?: number;
51
47
  /** Import chain that pulled the heaviest module into this route. */
52
48
  readonly heaviestChain?: readonly string[];
49
+ /**
50
+ * Every app file `jsBytes` charged, heaviest first, at the URL the browser fetches. Optional
51
+ * for `frameworkJsBytes`' reason: a row from an older build has none, and absent is not empty.
52
+ */
53
+ readonly charged?: readonly ChargedFile[];
54
+ /** The app's inline script bytes inside `jsBytes` — the part no file in `charged` accounts for. */
55
+ readonly inlineJsBytes?: number;
53
56
  }
54
57
 
55
58
  export interface BuildStats {
@@ -67,8 +70,12 @@ export interface BuildStats {
67
70
  * `examples/dummy` was charged 250 B for `/x-sw-register.js` by a file written before
68
71
  * `FRAMEWORK_SCRIPTS` exempted it. `2`: that exemption and the page-boot decision (ledger #28).
69
72
  * `3`: an island entry's imported chunks are charged, each once per document (shared chunks).
73
+ * `4`: the exempt theme script's bytes moved — it also stamps `data-theme-default` (plan 101, 09).
74
+ * `5`: a URL is weighed at the path a browser resolves it to (`\` is `/`, `//host` is another
75
+ * origin), so two spellings of one fetch are charged once; and an app/ page is weighed as SERVED
76
+ * — its page boot charged, the runtime chunk that boot makes unfetched not (plan 101 sweep 9).
70
77
  */
71
- export const BUILD_STATS_RULES = 3;
78
+ export const BUILD_STATS_RULES = 5;
72
79
 
73
80
  const chainOf = (stats: RouteStats): string =>
74
81
  stats.heaviestChain === undefined ? 'unknown import chain' : stats.heaviestChain.join(' -> ');
@@ -79,7 +86,7 @@ const jsBudgetOf = (route: RouteFact): number | null => parseByteBudget(route.bu
79
86
  export type RouteFileOf = (url: string) => string | undefined;
80
87
 
81
88
  const declaredFileOf: RouteFileOf = (url) =>
82
- describeRoutes().find((route) => route.path === url && route.mount === undefined)?.file;
89
+ describePages().find((route) => route.path === url && route.mount === undefined)?.file;
83
90
 
84
91
  const KB = 1024;
85
92
 
@@ -94,14 +101,6 @@ function exceededJsFix(url: string, file: string | undefined, bytes: number): st
94
101
  return `edit ${where} — set budget: { js: '${String(kb)}kb' } with // measured: ${String(bytes)} B (x build --target static) — why: <the function it buys> directly above it; or x routes --json for the chain and move the heavy import behind hydrate: 'interaction'`;
95
102
  }
96
103
 
97
- /** Which budgets the route declared, for a cause line that names what went unmeasured. */
98
- function declaredBudgets(js: number | null, lcp: number | undefined): string {
99
- const labels: string[] = [];
100
- if (js !== null) labels.push('JS');
101
- if (lcp !== undefined) labels.push('LCP');
102
- return labels.join(' and ');
103
- }
104
-
105
104
  /**
106
105
  * Compare declared budgets against measured stats. A declared budget with no measurement is a
107
106
  * finding, never a pass: a route that clears the gate without ever being weighed is exactly the
@@ -117,8 +116,13 @@ function declaredBudgets(js: number | null, lcp: number | undefined): string {
117
116
  * second build's. Reporting the first as the second is what sends a reader to re-run a build that
118
117
  * already did everything it was going to do.
119
118
  */
120
- function unmeasuredFinding(url: string, declared: string, built: boolean, stale = false): Finding {
121
- if (stale) {
119
+ function unmeasuredFinding(
120
+ url: string,
121
+ declared: string,
122
+ stats: BuildStats | undefined,
123
+ unmeasured: readonly UnmeasuredRoute[],
124
+ ): Finding {
125
+ if (stats?.stale === true) {
122
126
  return {
123
127
  code: 'X_BUDGET_UNMEASURED',
124
128
  cause: `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} was written by an earlier measurement rule than this gate's (v${String(BUILD_STATS_RULES)}), so its numbers are not this gate's to read`,
@@ -127,54 +131,19 @@ function unmeasuredFinding(url: string, declared: string, built: boolean, stale
127
131
  at: url,
128
132
  };
129
133
  }
134
+ // A build ran: the report's account of this route decides the instruction
135
+ // (`budgets-unmeasured.ts`) — its own code, an actor edit, or the report.
136
+ if (stats !== undefined) {
137
+ const entry = unmeasured.find((one) => one.path === url);
138
+ return builtUnmeasuredFinding(url, declared, BUILD_STATS_FILE, entry);
139
+ }
130
140
  return {
131
141
  code: 'X_BUDGET_UNMEASURED',
132
- cause: built
133
- ? `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} has no row for it, so the build ran and could not weigh it`
134
- : `${url} declares a ${declared} budget and no build has written ${BUILD_STATS_FILE} in this repo`,
142
+ cause: `${url} declares a ${declared} budget and no build has written ${BUILD_STATS_FILE} in this repo`,
135
143
  // `--target static` is load-bearing and `x build` alone was a fix that changes nothing: the
136
144
  // flag defaults to `docker`, and only the static target runs `apps/web/prerender.ts`, which is
137
- // the one caller of `writeBuildStats`. When a build already ran, the second half is where the
138
- // answer is — the report names every route it could not weigh, and why.
139
- fix: built
140
- ? `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`
141
- : 'x build --target static --json && x verify --json',
142
- docs: ERROR_DOCS_URL,
143
- at: url,
144
- };
145
- }
146
-
147
- /**
148
- * The ONE code a failed measurement is reported under by its own name rather than as
149
- * `X_BUDGET_UNMEASURED`. Its cause is complete — the island, the prop, its bytes, the cap — and
150
- * its fix is an edit to the page, so the step's own "run x build and read the list" would put a
151
- * second command between the author and a sentence the build had already composed. Every other
152
- * render failure keeps the generic finding: a `TypeError` from a `load` that wanted a request is
153
- * a reason to read the report, not an instruction.
154
- *
155
- * ONE code and not "any coded error", deliberately. `X_NO_CONTEXT`, `X_UNAUTHENTICATED` and
156
- * `X_DB_UNAVAILABLE` from a measurement render are facts about the BUILD's environment, and
157
- * reporting them under their own codes would tell the author to fix a database the gate never
158
- * had. The list grows by a decision, per code, here.
159
- */
160
- const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set([
161
- 'X_ISLAND_PROPS_INVALID',
162
- // A dynamic route that lists no `prerender()` path: the edit is in the route, not the build.
163
- 'X_BUDGET_PARAMS_UNDECLARED',
164
- ]);
165
-
166
- /**
167
- * The build's own finding for a route it could not weigh, when that failure is an instruction.
168
- * Read off the static report's `unmeasured` list — the same list `X_BUDGET_UNMEASURED`'s `fix:`
169
- * sends its reader to, now read by the step itself for the one code it can act on.
170
- */
171
- function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Finding | undefined {
172
- const entry = unmeasured.find((one) => one.path === url);
173
- if (entry?.code === undefined || !REPORTED_BY_OWN_CODE.has(entry.code)) return undefined;
174
- return {
175
- code: entry.code,
176
- cause: entry.cause ?? entry.reason,
177
- fix: entry.fix ?? `x build --target static --json # its "unmeasured" list has ${url}`,
145
+ // the one caller of `writeBuildStats`.
146
+ fix: 'x build --target static --json && x verify --json',
178
147
  docs: ERROR_DOCS_URL,
179
148
  at: url,
180
149
  };
@@ -188,7 +157,7 @@ function ownCodeFinding(url: string, unmeasured: readonly UnmeasuredRoute[]): Fi
188
157
  * `unmeasured` is the static report's list of routes the build rendered and could not weigh, with
189
158
  * each failure's code when it had one. Optional because the report is written beside the stats
190
159
  * and can be absent for the same reason; with it, a route whose measurement failed on a code in
191
- * `REPORTED_BY_OWN_CODE` is reported under that code, with the build's own cause and fix.
160
+ * `REPORTED_BY_OWN_CODE` (`budgets-unmeasured.ts`) is reported under that code, with the build's own cause and fix.
192
161
  *
193
162
  * `fileOf` names the file an `X_BUDGET_EXCEEDED` fix edits: the step runs after the app loaded,
194
163
  * so render's route table already holds it. A test hands its own.
@@ -210,37 +179,21 @@ export function checkBudgets(
210
179
  if (unweighable.has(route.url)) continue;
211
180
  const measured = byPath.get(route.url);
212
181
  const js = jsBudgetOf(route);
213
- const lcp = route.budget?.lcp;
214
182
  if (measured === undefined) {
215
- if (js !== null || lcp !== undefined) {
216
- findings.push(
217
- ownCodeFinding(route.url, unmeasured) ??
218
- unmeasuredFinding(
219
- route.url,
220
- declaredBudgets(js, lcp),
221
- stats !== undefined,
222
- stats?.stale === true,
223
- ),
224
- );
183
+ if (js !== null) {
184
+ findings.push(unmeasuredFinding(route.url, 'JS', stats, unmeasured));
225
185
  }
226
186
  continue;
227
187
  }
228
188
  if (js !== null && measured.jsBytes > js) {
189
+ const meta = chargedMeta(measured.jsBytes, measured.charged, measured.inlineJsBytes);
229
190
  findings.push({
230
191
  code: 'X_BUDGET_EXCEEDED',
231
- cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
192
+ cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}${chargedClause(measured.charged, measured.inlineJsBytes)}`,
232
193
  fix: exceededJsFix(route.url, fileOf(route.url), measured.jsBytes),
233
194
  docs: ERROR_DOCS_URL,
234
195
  at: route.url,
235
- });
236
- }
237
- if (lcp !== undefined && measured.lcpMs !== undefined && measured.lcpMs > lcp) {
238
- findings.push({
239
- code: 'X_BUDGET_EXCEEDED',
240
- cause: `${route.url} LCP ${measured.lcpMs}ms over the ${lcp}ms budget`,
241
- fix: `raise the budget in defineRoute, or switch render to 'isr' to serve it prebuilt`,
242
- docs: ERROR_DOCS_URL,
243
- at: route.url,
196
+ ...(meta === undefined ? {} : { meta }),
244
197
  });
245
198
  }
246
199
  }
@@ -255,59 +208,29 @@ export async function readBuildStats(root: string): Promise<BuildStats | undefin
255
208
  return read.measuredBy === BUILD_STATS_RULES ? read : { routes: [], stale: true };
256
209
  }
257
210
 
258
- const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
259
- const SRC_ATTR = /\ssrc="(?<src>[^"]*)"/;
260
- const TYPE_ATTR = /\stype="(?<type>[^"]*)"/;
261
-
262
- /**
263
- * `application/ld+json`, `application/json`, any `…+json`: the body is data, not code — the rule
264
- * `@ultimat3/render`'s `head.ts` already states, restated because its `carriesJson` reads a
265
- * `HeadTag` and is not exported, and this side has an attribute string off the emitted document.
266
- * Without it a page shipping only `meta.ld` structured data and island props measured 8kb of JS
267
- * and failed a 2kb budget with a `fix:` naming an import chain that does not exist.
268
- */
269
- const carriesJson = (attrs: string): boolean => {
270
- // Everything from the first `;` is a MIME PARAMETER and not the type: a real document writes
271
- // `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
272
- // test alone charged an SEO structured-data block as executable JavaScript all over again.
273
- const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
274
- const declared = type.trim().toLowerCase();
275
- // Speculation rules are JSON under a type that does not say so: read by the browser's
276
- // prefetcher, never executed — the one script a 0kb page may carry for free.
277
- return declared.endsWith('json') || declared === SPECULATION_RULES_TYPE;
278
- };
279
-
280
- /**
281
- * An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
282
- * as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
283
- * never for the code that runtime boots. The entry attribute is that module URL, so it is read as
284
- * exactly what it is: a file the browser will execute.
285
- */
286
- const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
287
-
288
- /**
289
- * Static imports and `import()` both. A chunk reached by `import()` was INLINED into its island
290
- * before islands were split, so charging it keeps a route's number what it was; and it is fetched
291
- * the moment the island needs it, which is before the page does what it was loaded for.
292
- */
293
- const LOADS: ReadonlySet<string> = new Set(['import-statement', 'dynamic-import']);
211
+ /** What `measureDocumentJs` needs beyond the document and the artifact — both for served pages. */
212
+ export interface MeasureOptions {
213
+ /**
214
+ * Scripts a document names that the PROCESS serves and the static artifact does not carry, by
215
+ * URL: the page boot (`/_x/page-boot/<id>.js`). Charged like any `<script src>` — ledger #28.
216
+ */
217
+ readonly served?: ReadonlyMap<string, string>;
218
+ /**
219
+ * The page boot's path prefix and the runtime chunks it supplies (#505): on a document carrying
220
+ * the boot an island awaits the runtime the boot installs and never imports the chunk, so the
221
+ * chunk is not charged there. A document without the boot is charged for it as before.
222
+ */
223
+ readonly boot?: { readonly prefix: string; readonly supplies: ReadonlySet<string> };
224
+ }
294
225
 
295
- /** Same-origin paths `code`, served at `url`, makes the browser load. Cross-origin and bare are not this build's. */
296
- function loadedBy(url: string, code: string): readonly string[] {
297
- let imports: readonly { readonly path: string; readonly kind: string }[];
298
- try {
299
- imports = new Bun.Transpiler({ loader: 'js' }).scanImports(code);
300
- } catch {
301
- // A file that does not parse as JavaScript imports nothing this gate can name; its own bytes
302
- // are already charged.
303
- return [];
226
+ /** Whether a `<script src>` in `html` is the page boot `boot.prefix` names. */
227
+ function carriesBoot(html: string, boot: MeasureOptions['boot']): boolean {
228
+ if (boot === undefined) return false;
229
+ for (const match of html.matchAll(SCRIPT_TAG)) {
230
+ const src = SRC_ATTR.exec(match.groups?.['attrs'] ?? '')?.groups?.['src'];
231
+ if (src !== undefined && artifactPath(src)?.startsWith(boot.prefix) === true) return true;
304
232
  }
305
- const base = new URL(url, 'https://artifact.invalid');
306
- return imports
307
- .filter((one) => LOADS.has(one.kind) && /^\.{0,2}\//.test(one.path))
308
- .map((one) => new URL(one.path, base))
309
- .filter((target) => target.origin === base.origin)
310
- .map((target) => target.pathname);
233
+ return false;
311
234
  }
312
235
 
313
236
  /** One executable module the document names, and what it weighs on disk. */
@@ -321,6 +244,8 @@ export interface MeasuredJs {
321
244
  readonly jsBytes: number;
322
245
  /** The framework's injected runtime, counted separately so it is reported and never charged. */
323
246
  readonly frameworkBytes: number;
247
+ /** The app's inline script bytes — the part of `jsBytes` that is not a file in `entries`. */
248
+ readonly inlineBytes: number;
324
249
  /** Every APP `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
325
250
  readonly entries: readonly MeasuredEntry[];
326
251
  }
@@ -372,9 +297,14 @@ export const FRAMEWORK_INLINE_SCRIPTS: ReadonlySet<string> = new Set(
372
297
  * from the emitted HTML rather than from the declared graph, because the graph is what a route
373
298
  * *says* it ships and this gate exists to catch the case where those two disagree.
374
299
  */
375
- export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
300
+ export async function measureDocumentJs(
301
+ html: string,
302
+ out: string,
303
+ options: MeasureOptions = {},
304
+ ): Promise<MeasuredJs> {
376
305
  let jsBytes = 0;
377
306
  let frameworkBytes = 0;
307
+ let inlineBytes = 0;
378
308
  const entries: MeasuredEntry[] = [];
379
309
  // Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
380
310
  // once however many times the document names it, so `budget.js` — a byte budget — counts it
@@ -390,13 +320,21 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
390
320
  // is not in this set for the same reason: two identical inline bodies are two copies of the
391
321
  // bytes in the document, so both are charged.
392
322
  const fetched = new Set<string>();
393
- const weigh = async (url: string): Promise<void> => {
323
+ const served = options.served ?? new Map<string, string>();
324
+ // The runtime chunks this document's page boot makes unfetched: `awaitPageRuntime`'s rule, read
325
+ // the way it reads it — a `<script src>` under the boot's path is in the document.
326
+ const bootSupplied = carriesBoot(html, options.boot) ? (options.boot?.supplies ?? null) : null;
327
+ const weigh = async (named: string): Promise<void> => {
394
328
  // Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
395
- if (!url.startsWith('/') || fetched.has(url)) return;
329
+ const url = artifactPath(named);
330
+ if (url === undefined || fetched.has(url)) return;
396
331
  fetched.add(url);
332
+ // A script the process serves and the artifact does not carry (the page boot) is weighed from
333
+ // the bytes the build holds; everything else from the file the artifact will serve.
334
+ const held = served.get(url);
397
335
  const file = Bun.file(join(out, url.slice(1)));
398
- const exists = await file.exists();
399
- const bytes = exists ? file.size : 0;
336
+ const exists = held !== undefined || (await file.exists());
337
+ const bytes = held === undefined ? (exists ? file.size : 0) : Buffer.byteLength(held, 'utf8');
400
338
  // Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
401
339
  // Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
402
340
  // to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
@@ -411,7 +349,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
411
349
  // and each is a fetch the browser makes before the island is whole. Through `weigh`, so a chunk
412
350
  // two islands share is charged once and a cycle ends at the first repeat.
413
351
  if (exists) {
414
- for (const next of loadedBy(url, await file.text())) await weigh(next);
352
+ for (const next of loadedBy(url, held ?? (await file.text()))) {
353
+ if (bootSupplied?.has(next) !== true) await weigh(next);
354
+ }
415
355
  }
416
356
  };
417
357
 
@@ -422,8 +362,12 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
422
362
  if (src === undefined) {
423
363
  const body = match.groups?.['body'] ?? '';
424
364
  const bytes = Buffer.byteLength(body, 'utf8');
425
- if (FRAMEWORK_INLINE_SCRIPTS.has(body)) frameworkBytes += bytes;
426
- else jsBytes += bytes;
365
+ if (FRAMEWORK_INLINE_SCRIPTS.has(body)) {
366
+ frameworkBytes += bytes;
367
+ } else {
368
+ jsBytes += bytes;
369
+ inlineBytes += bytes;
370
+ }
427
371
  continue;
428
372
  }
429
373
  await weigh(src);
@@ -433,7 +377,26 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
433
377
  if (url === undefined) continue;
434
378
  await weigh(url);
435
379
  }
436
- return { jsBytes, frameworkBytes, entries };
380
+ return { jsBytes, frameworkBytes, inlineBytes, entries };
381
+ }
382
+
383
+ /**
384
+ * The stats row for one measured document — the ONE place a row is assembled, so the static and
385
+ * the weigh-only branch of the build cannot record different facts about the same kind of page.
386
+ */
387
+ export function routeStatsRow(
388
+ path: string,
389
+ measured: MeasuredJs,
390
+ chain: readonly string[] | undefined,
391
+ ): RouteStats {
392
+ return {
393
+ path,
394
+ jsBytes: measured.jsBytes,
395
+ frameworkJsBytes: measured.frameworkBytes,
396
+ ...(chain === undefined ? {} : { heaviestChain: chain }),
397
+ charged: heaviestFirst(measured.entries),
398
+ inlineJsBytes: measured.inlineBytes,
399
+ };
437
400
  }
438
401
 
439
402
  /**
@@ -91,7 +91,9 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
91
91
  throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
92
92
  }
93
93
  const launched = await launch(options.executablePath, timeoutMs);
94
- return { connection: launched.connection, end: async () => launched.close() };
94
+ // Awaited by `close` below: `x shot` exits right after its last picture, and a reap still
95
+ // running then is a Chrome child left behind and a profile directory it re-creates.
96
+ return { connection: launched.connection, end: () => launched.close() };
95
97
  };
96
98
 
97
99
  return {
@@ -120,10 +122,22 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
120
122
  detail: 'the browser opened a page and answered no target or session id',
121
123
  });
122
124
  }
125
+ // The BROWSER's own session, for the allow list on every target the page opens — a popup
126
+ // is a new target the page session's `Fetch` never pauses. A browser that refuses the
127
+ // attach refuses the session: a rule that would hold on one tab only is not a rule.
128
+ const browserAttached = await connection.send('Target.attachToBrowserTarget');
129
+ const browserSessionId = field(browserAttached.result, 'sessionId');
130
+ if (browserSessionId === undefined) {
131
+ throw new CdpCallFailedError({
132
+ method: 'Target.attachToBrowserTarget',
133
+ detail: 'the browser answered no session id, so a popup could not be screened',
134
+ });
135
+ }
123
136
  // Watching BEFORE the domains are on, so the first request is already counted.
124
137
  const watch = watchPage({
125
138
  connection,
126
139
  sessionId,
140
+ browserSessionId,
127
141
  clock: init.clock,
128
142
  allowHosts: init.rules.allowHosts,
129
143
  });
@@ -134,6 +148,11 @@ export function cdpShotDriver(options: CdpShotDriverOptions): ShotDriver {
134
148
  await on('Page.enable');
135
149
  await on('Network.enable');
136
150
  await on('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }] });
151
+ await connection.send(
152
+ 'Fetch.enable',
153
+ { patterns: [{ urlPattern: '*', requestStage: 'Request' }] },
154
+ browserSessionId,
155
+ );
137
156
  // Before the first navigation, like the metrics below: a header set after `goto` would
138
157
  // photograph a document negotiated in the machine's own language.
139
158
  if (init.headers !== undefined && Object.keys(init.headers).length > 0) {
@@ -2,6 +2,7 @@
2
2
  // tail with a count of what the bound threw away. And the allow list, enforced in the browser:
3
3
  // every request is paused (`Fetch.requestPaused`) and refused unless its host is allowed, so an
4
4
  // injected `<img src="http://169.254.169.254/…">` never leaves — it is recorded as `refused`.
5
+ // Paused at the BROWSER too, so a popup the page opens is decided by the same `hostDecision`.
5
6
  import { hostDecision } from '@ultimat3/core';
6
7
  import type { CdpConnection } from '@ultimat3/testing';
7
8
  import type {
@@ -123,14 +124,22 @@ export interface PageWatch {
123
124
  * Subscribe BEFORE the domains are enabled (the caller's order), so the first request of the first
124
125
  * navigation is already counted. Only events on `sessionId` are read: a remote browser carries
125
126
  * other people's pages.
127
+ *
128
+ * `browserSessionId` is the browser target's session, whose `Fetch` pauses what page-level
129
+ * interception never sees: a `target=_blank` link or a `window.open` is a NEW target, and on the
130
+ * page session alone it reached an off-list host unrefused. Its pauses are decided by the same
131
+ * `hostDecision` and answered on the session they arrived on. A page request is decided at the
132
+ * page first — an allowed one is continued again here, a refused one never arrives — so each
133
+ * refusal is one ring entry.
126
134
  */
127
135
  export function watchPage(input: {
128
136
  readonly connection: CdpConnection;
129
137
  readonly sessionId: string;
138
+ readonly browserSessionId?: string | undefined;
130
139
  readonly clock: ShotClock;
131
140
  readonly allowHosts: readonly string[];
132
141
  }): PageWatch {
133
- const { connection, sessionId } = input;
142
+ const { connection, sessionId, browserSessionId } = input;
134
143
  const lines = new Tail<ConsoleLine>();
135
144
  const errors = new Tail<PageError>();
136
145
  const requests = new Tail<NetworkEntry>();
@@ -191,38 +200,33 @@ export function watchPage(input: {
191
200
  requests.update(id, { ...held, status });
192
201
  }),
193
202
  ),
194
- connection.on(
195
- 'Fetch.requestPaused',
196
- mine((params) => {
197
- const pausedId = text(params['requestId']) ?? '';
198
- const request = record(params['request']);
199
- const url = text(request?.['url']) ?? '';
200
- if (hostDecision(url, input.allowHosts).allowed) {
201
- void connection
202
- .send('Fetch.continueRequest', { requestId: pausedId }, sessionId)
203
- .catch(() => undefined);
204
- return;
205
- }
206
- const id = text(params['networkId']) ?? pausedId;
207
- const held = requests.get(id);
208
- const refused: NetworkEntry = {
209
- method: text(request?.['method']) ?? held?.method ?? 'GET',
210
- url,
211
- resourceType: resourceType(params['resourceType']),
212
- at: held?.at ?? now(),
213
- refused: 'host',
214
- };
215
- if (held === undefined) requests.put(refused, id);
216
- else requests.update(id, refused);
203
+ connection.on('Fetch.requestPaused', (params, on) => {
204
+ // The page's session or the browser's: anything else is somebody else's interception.
205
+ if (on === undefined || (on !== sessionId && on !== browserSessionId)) return;
206
+ const pausedId = text(params['requestId']) ?? '';
207
+ const request = record(params['request']);
208
+ const url = text(request?.['url']) ?? '';
209
+ if (hostDecision(url, input.allowHosts).allowed) {
217
210
  void connection
218
- .send(
219
- 'Fetch.failRequest',
220
- { requestId: pausedId, errorReason: 'BlockedByClient' },
221
- sessionId,
222
- )
211
+ .send('Fetch.continueRequest', { requestId: pausedId }, on)
223
212
  .catch(() => undefined);
224
- }),
225
- ),
213
+ return;
214
+ }
215
+ const id = text(params['networkId']) ?? pausedId;
216
+ const held = requests.get(id);
217
+ const refused: NetworkEntry = {
218
+ method: text(request?.['method']) ?? held?.method ?? 'GET',
219
+ url,
220
+ resourceType: resourceType(params['resourceType']),
221
+ at: held?.at ?? now(),
222
+ refused: 'host',
223
+ };
224
+ if (held === undefined) requests.put(refused, id);
225
+ else requests.update(id, refused);
226
+ void connection
227
+ .send('Fetch.failRequest', { requestId: pausedId, errorReason: 'BlockedByClient' }, on)
228
+ .catch(() => undefined);
229
+ }),
226
230
  ];
227
231
 
228
232
  let stopped = false;
package/src/ci-log.ts CHANGED
Binary file
@@ -0,0 +1,16 @@
1
+ // `x auth`'s declaration, apart from its body: the parser, `x help` and the `errors` step
2
+ // read it without loading `cmd-auth.ts`, which `registry.ts` imports only when the command runs.
3
+
4
+ import type { CommandSpec } from './parse';
5
+
6
+ export const AUTH_SUBCOMMANDS = ['seal-mfa'] as const;
7
+
8
+ export const authSpec: CommandSpec = {
9
+ name: 'auth',
10
+ summary: 'one-shot maintenance of the auth tables: seal every plaintext second-factor secret',
11
+ usage: 'x auth seal-mfa [--json]',
12
+ requiresApp: true,
13
+ // No default: the one subcommand WRITES, and a bare `x auth` must not.
14
+ subcommands: [...AUTH_SUBCOMMANDS],
15
+ flags: [],
16
+ };
@@ -0,0 +1,65 @@
1
+ // `x auth seal-mfa` — seal every `x_users.mfa_secret` still stored in the clear, in place, under
2
+ // the app's master key. The one-shot an upgrade onto sealed second-factor secrets owes: readers
3
+ // refuse a plaintext value (`X_MFA_SECRET_UNSEALED`), and this is the command that refusal names.
4
+ // Idempotent — a sealed value is counted and left alone — so an interrupted run is finished by
5
+ // running it again.
6
+
7
+ import type { MfaSecretStore } from '@ultimat3/auth';
8
+ import { postgresAuthAdapter, sealMfaSecrets } from '@ultimat3/auth';
9
+ import { requireAppRoot } from './app-root';
10
+ import { authSpec } from './cmd-auth-spec';
11
+ import type { CliCommand, CommandContext } from './command';
12
+ import { ok } from './command';
13
+ import { msg } from './messages';
14
+ import type { CommandResult } from './output';
15
+ import { resolveServices } from './runtime-bindings';
16
+ import { startQueue } from './runtime-queue';
17
+
18
+ export interface OpenAuthStore extends MfaSecretStore {
19
+ close(): Promise<void>;
20
+ }
21
+
22
+ /**
23
+ * The app's own database, opened the way every other command opens it (`startQueue`: the
24
+ * framework's tables are applied first, so `x_users` exists) and ALWAYS released — a CLI that
25
+ * exits holding the PGlite lock breaks the next command run against this app.
26
+ */
27
+ async function openAppAuthStore(root: string, ctx: CommandContext): Promise<OpenAuthStore> {
28
+ const queue = await startQueue(resolveServices(root, ctx.env), undefined, ctx.env);
29
+ return { adapter: postgresAuthAdapter(queue.db), close: () => queue.stop() };
30
+ }
31
+
32
+ /** `open` is the seam a test supplies a store through; the shipped command opens the app's. */
33
+ export const authCommandOver = (
34
+ open: (root: string, ctx: CommandContext) => Promise<OpenAuthStore>,
35
+ ): CliCommand => ({
36
+ spec: authSpec,
37
+ async run(ctx: CommandContext): Promise<CommandResult> {
38
+ const root = requireAppRoot('auth', ctx.cwd).dir;
39
+ const store = await open(root, ctx);
40
+ try {
41
+ // The key is read from the command's own environment and the app's `.secrets.key`: with
42
+ // neither, the first seal refuses (`X_SEAL_KEY_MISSING`) before any row is written.
43
+ const report = await sealMfaSecrets(store, { root, env: ctx.env });
44
+ return ok(
45
+ 'auth',
46
+ msg('cli.auth.sealed', {
47
+ sealed: report.sealed,
48
+ already: report.alreadySealed,
49
+ skipped: report.skipped,
50
+ }),
51
+ {
52
+ data: {
53
+ sealed: report.sealed,
54
+ alreadySealed: report.alreadySealed,
55
+ skipped: report.skipped,
56
+ },
57
+ },
58
+ );
59
+ } finally {
60
+ await store.close();
61
+ }
62
+ },
63
+ });
64
+
65
+ export const authCommand: CliCommand = authCommandOver(openAppAuthStore);
@@ -8,7 +8,7 @@ export const buildSpec: CommandSpec = {
8
8
  summary:
9
9
  'build a container image, a single binary, a prerendered static site, or the store an image boots from',
10
10
  usage:
11
- 'x build --target docker|binary|static|prebuilt [--tag name] [--out path] [--no-preflight] [--json]',
11
+ 'x build --target docker|binary|static|prebuilt [--tag name] [--out path] [--platform bun-target] [--no-preflight] [--json]',
12
12
  requiresApp: true,
13
13
  flags: [
14
14
  {
@@ -20,6 +20,12 @@ export const buildSpec: CommandSpec = {
20
20
  },
21
21
  { name: 'tag', type: 'string', summary: 'image tag (docker target)' },
22
22
  { name: 'out', type: 'string', summary: 'output path (binary and static targets)' },
23
+ {
24
+ name: 'platform',
25
+ type: 'string',
26
+ summary:
27
+ "binary target only: bun-linux-x64 | bun-linux-arm64 | bun-windows-x64 | bun-darwin-arm64 — Bun's --target; a Windows build is written as <out>.exe",
28
+ },
23
29
  {
24
30
  name: 'preflight',
25
31
  type: 'boolean',