@ultimat3/cli 24.0.0 → 25.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CLAUDE.md +15 -8
  2. package/README.md +30 -5
  3. package/package.json +29 -29
  4. package/src/admin-catalog-keys.ts +2 -2
  5. package/src/affected.ts +3 -1
  6. package/src/app-boundaries.ts +17 -4
  7. package/src/app-config-load.ts +3 -3
  8. package/src/app-env.ts +21 -14
  9. package/src/app-load.ts +38 -14
  10. package/src/app-manifest.ts +2 -2
  11. package/src/app-mcp.ts +25 -27
  12. package/src/app-name.ts +52 -0
  13. package/src/app-permissions-borrowed.ts +2 -8
  14. package/src/app-permissions-site.ts +31 -7
  15. package/src/app-root.ts +9 -5
  16. package/src/bin.ts +3 -7
  17. package/src/browser-launcher.ts +4 -2
  18. package/src/budgets-charged.ts +60 -0
  19. package/src/budgets-scan.ts +76 -0
  20. package/src/budgets-unmeasured.ts +91 -0
  21. package/src/budgets.ts +115 -121
  22. package/src/cdp-shot-driver.ts +17 -0
  23. package/src/cdp-shot-watch.ts +35 -31
  24. package/src/ci-log.ts +0 -0
  25. package/src/cmd-auth.ts +2 -2
  26. package/src/cmd-build-spec.ts +7 -1
  27. package/src/cmd-build.ts +89 -9
  28. package/src/cmd-db-backfill.ts +1 -1
  29. package/src/cmd-db-branch.ts +2 -2
  30. package/src/cmd-db.ts +5 -2
  31. package/src/cmd-deploy-compose.ts +94 -0
  32. package/src/cmd-deploy-drain.ts +48 -0
  33. package/src/cmd-deploy-start-first.ts +178 -0
  34. package/src/cmd-deploy.ts +158 -36
  35. package/src/cmd-dev.ts +18 -357
  36. package/src/cmd-doctor.ts +16 -66
  37. package/src/cmd-env.ts +29 -14
  38. package/src/cmd-generate.ts +12 -2
  39. package/src/cmd-i18n.ts +11 -8
  40. package/src/cmd-jobs-spec.ts +7 -22
  41. package/src/cmd-jobs.ts +18 -137
  42. package/src/cmd-mcp.ts +25 -7
  43. package/src/cmd-new.ts +36 -8
  44. package/src/cmd-planned.ts +9 -0
  45. package/src/cmd-routes.ts +3 -3
  46. package/src/cmd-shot-matrix.ts +21 -4
  47. package/src/cmd-shot-spec.ts +10 -2
  48. package/src/cmd-shot.ts +35 -39
  49. package/src/cmd-test.ts +1 -1
  50. package/src/coverage-lcov.ts +4 -2
  51. package/src/db-backfill.ts +2 -2
  52. package/src/db-replay-engine.ts +4 -10
  53. package/src/db-subscribes.ts +19 -16
  54. package/src/dev-boot.ts +290 -0
  55. package/src/dev-child-watch.ts +63 -12
  56. package/src/dev-dashboard-guard.ts +1 -1
  57. package/src/dev-dashboard.ts +5 -1
  58. package/src/dev-lock.ts +96 -24
  59. package/src/dev-rebuild.ts +80 -0
  60. package/src/dev-restart-retire.ts +46 -0
  61. package/src/dev-route-table.ts +30 -8
  62. package/src/dev-supervisor.ts +114 -22
  63. package/src/dev-traces.ts +53 -20
  64. package/src/dispatch.ts +14 -7
  65. package/src/doctor-auth.ts +4 -4
  66. package/src/doctor-offline.ts +6 -5
  67. package/src/document-options.ts +7 -0
  68. package/src/drift-replica-identity.ts +2 -2
  69. package/src/drift.ts +29 -6
  70. package/src/error-codes.ts +6 -0
  71. package/src/error-page-csp.ts +14 -15
  72. package/src/exec.ts +8 -0
  73. package/src/favicon.ts +2 -2
  74. package/src/fix-command.ts +9 -1
  75. package/src/framework-env.ts +148 -0
  76. package/src/framework-schema.ts +13 -2
  77. package/src/generate-catalog-locales.ts +79 -0
  78. package/src/generate-kinds.ts +3 -17
  79. package/src/generate-plural.ts +50 -0
  80. package/src/generate-shadow.ts +50 -1
  81. package/src/hold.ts +56 -1
  82. package/src/i18n-audit.ts +21 -8
  83. package/src/i18n-index.ts +5 -6
  84. package/src/i18n-registration.ts +7 -9
  85. package/src/import-scan.ts +28 -3
  86. package/src/index.ts +1 -3
  87. package/src/island-bundle.ts +54 -64
  88. package/src/island-capture.ts +1 -1
  89. package/src/island-duplicate-refusal.ts +57 -0
  90. package/src/island-duplicates.ts +221 -0
  91. package/src/island-entry-missing.ts +64 -0
  92. package/src/island-harness-route.ts +20 -4
  93. package/src/island-harness-script.ts +23 -2
  94. package/src/island-identity.ts +17 -14
  95. package/src/island-package-dedupe.ts +150 -0
  96. package/src/island-realtime.ts +36 -16
  97. package/src/island-routes.ts +2 -2
  98. package/src/island-runtime.ts +241 -0
  99. package/src/island-sources.ts +105 -0
  100. package/src/island-states-load.ts +6 -0
  101. package/src/island-store.ts +90 -6
  102. package/src/jobs-json.ts +0 -26
  103. package/src/jobs-report.ts +32 -16
  104. package/src/local-cli-handoff.ts +90 -0
  105. package/src/mcp-errors.ts +8 -1
  106. package/src/mcp-host.ts +6 -5
  107. package/src/mcp-ui-inspect.ts +3 -2
  108. package/src/mcp-ui-interact.ts +3 -2
  109. package/src/mcp-ui.ts +8 -7
  110. package/src/measure-scope.ts +15 -10
  111. package/src/messages.ts +27 -16
  112. package/src/metrics-endpoint.ts +3 -2
  113. package/src/migration-extensions.ts +1 -1
  114. package/src/otlp-export.ts +2 -2
  115. package/src/output.ts +18 -0
  116. package/src/page-speculation.ts +14 -11
  117. package/src/parse.ts +0 -8
  118. package/src/permission-grants.ts +3 -3
  119. package/src/posix-path.ts +30 -0
  120. package/src/prerender-locales.ts +6 -4
  121. package/src/prerender-report.ts +58 -0
  122. package/src/prerender-served.ts +52 -0
  123. package/src/prerender.ts +63 -98
  124. package/src/pwa-artifacts.ts +8 -1
  125. package/src/pwa-manifest-locales.ts +9 -9
  126. package/src/retry-memo.ts +1 -1
  127. package/src/role-realtime.ts +7 -1
  128. package/src/role-replicator.ts +110 -31
  129. package/src/role-start.ts +66 -87
  130. package/src/role-sync.ts +65 -6
  131. package/src/role-wake.ts +2 -3
  132. package/src/root-env.ts +157 -28
  133. package/src/route-islands.ts +69 -0
  134. package/src/runtime-admin.ts +4 -1
  135. package/src/runtime-assets.ts +15 -2
  136. package/src/runtime-bindings.ts +23 -3
  137. package/src/runtime-cache.ts +8 -8
  138. package/src/runtime-hooks.ts +2 -1
  139. package/src/runtime-isr-outcome.ts +100 -0
  140. package/src/runtime-isr.ts +2 -2
  141. package/src/runtime-jobs.ts +78 -18
  142. package/src/runtime-live-feed.ts +1 -1
  143. package/src/runtime-mail.ts +26 -0
  144. package/src/runtime-mfa-warning.ts +2 -2
  145. package/src/runtime-overrides.ts +3 -3
  146. package/src/runtime-purge.ts +1 -1
  147. package/src/runtime-queue.ts +19 -30
  148. package/src/runtime-realtime.ts +5 -3
  149. package/src/runtime-render.ts +77 -82
  150. package/src/runtime-replica.ts +3 -8
  151. package/src/runtime-services.ts +36 -15
  152. package/src/runtime-storage-upload.ts +2 -2
  153. package/src/runtime-storage.ts +3 -3
  154. package/src/schema-diff.ts +1 -1
  155. package/src/secrets-rotation.ts +8 -9
  156. package/src/seo-routes.ts +2 -2
  157. package/src/serve-boot.ts +22 -11
  158. package/src/serve-drain.ts +3 -1
  159. package/src/serve-retire.ts +95 -0
  160. package/src/serve-web.ts +19 -8
  161. package/src/serve.ts +27 -10
  162. package/src/server-barrels.ts +6 -3
  163. package/src/shot-browser.ts +1 -1
  164. package/src/shot-locale.ts +6 -15
  165. package/src/shot-settle.ts +3 -3
  166. package/src/shot-theme.ts +1 -1
  167. package/src/shot-viewport.ts +152 -0
  168. package/src/signal-shred.ts +26 -12
  169. package/src/site-asset-routes.ts +2 -2
  170. package/src/site-seo.ts +6 -4
  171. package/src/source-files.ts +13 -3
  172. package/src/static-report.ts +5 -0
  173. package/src/style-routes.ts +2 -2
  174. package/src/sw-artifacts.ts +8 -8
  175. package/src/sw-precache-plan.ts +34 -4
  176. package/src/sync-authenticator.ts +3 -3
  177. package/src/sync-url.ts +1 -1
  178. package/src/templates/action.ts +4 -4
  179. package/src/templates/backfill.ts +4 -4
  180. package/src/templates/entity.ts +2 -2
  181. package/src/templates/github/ci.yml.ts +28 -18
  182. package/src/templates/github/image.yml.ts +92 -0
  183. package/src/templates/github/runner.ts +18 -0
  184. package/src/templates/guard-bare-error.ts +16 -2
  185. package/src/templates/guard-unzoned-date.ts +56 -6
  186. package/src/templates/index.ts +1 -1
  187. package/src/templates/job-body-test.ts +2 -2
  188. package/src/templates/job.ts +6 -6
  189. package/src/templates/query.ts +9 -8
  190. package/src/templates/resource-page.ts +9 -7
  191. package/src/templates/resource-service.ts +2 -2
  192. package/src/templates/route.ts +23 -8
  193. package/src/templates/scaffold-api.ts +2 -3
  194. package/src/templates/scaffold-app.ts +2 -1
  195. package/src/templates/scaffold-auth.ts +205 -70
  196. package/src/templates/scaffold-bin.ts +122 -0
  197. package/src/templates/scaffold-claude-commands.ts +11 -11
  198. package/src/templates/scaffold-container.ts +12 -6
  199. package/src/templates/scaffold-dashboard-bare.ts +8 -4
  200. package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
  201. package/src/templates/scaffold-dashboard-example.ts +112 -172
  202. package/src/templates/scaffold-dashboard-shared.ts +62 -1
  203. package/src/templates/scaffold-dashboard.ts +9 -5
  204. package/src/templates/scaffold-db-package.ts +2 -2
  205. package/src/templates/scaffold-docs.ts +20 -77
  206. package/src/templates/scaffold-domain-package.ts +0 -4
  207. package/src/templates/scaffold-entries.ts +4 -3
  208. package/src/templates/scaffold-env.ts +6 -3
  209. package/src/templates/scaffold-gitattributes.ts +59 -0
  210. package/src/templates/scaffold-helm-templates.ts +74 -18
  211. package/src/templates/scaffold-helm.ts +31 -4
  212. package/src/templates/scaffold-http.ts +1 -1
  213. package/src/templates/scaffold-i18n.ts +15 -2
  214. package/src/templates/scaffold-icon.ts +2 -2
  215. package/src/templates/scaffold-repo.ts +18 -15
  216. package/src/templates/scaffold-shell.ts +17 -2
  217. package/src/test-only-shipped.ts +193 -0
  218. package/src/test-select.ts +2 -1
  219. package/src/test-shards.ts +1 -4
  220. package/src/theme-boot.ts +2 -2
  221. package/src/theme-brand.ts +66 -0
  222. package/src/trusted-hops.ts +49 -0
  223. package/src/unscanned-admin.ts +2 -2
  224. package/src/verify-checks.ts +12 -2
  225. package/src/verify-coverage-run.ts +3 -2
  226. package/src/verify-deadline.ts +87 -10
  227. package/src/verify-merge.ts +48 -16
  228. package/src/verify-part-step.ts +10 -2
  229. package/src/verify-role-load-probe.ts +12 -2
  230. package/src/verify-role-load.ts +9 -4
  231. package/src/verify-run.ts +3 -0
  232. package/src/verify-step.ts +2 -0
  233. package/src/verify-test-run.ts +3 -2
  234. package/src/verify-tests.ts +0 -1
  235. package/src/worker-bundle.ts +2 -2
  236. package/src/workspace-checks.ts +53 -7
  237. package/src/workspace-graph.ts +3 -2
  238. package/src/write-line.ts +82 -7
  239. package/src/browser-launcher-fake-html.ts +0 -80
  240. package/src/browser-launcher-fake.ts +0 -168
  241. package/src/jobs-drain.ts +0 -230
  242. package/src/scaffold-typecheck.ts +0 -227
  243. package/src/thrown-by.ts +0 -24
package/src/app-mcp.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  // The app's own MCP endpoint, mounted by the web role. `defineAppMcp` built `mcp.route` — a
2
2
  // `POST` handler with token auth and per-class rate limits — and `app.config.ts` declared
3
- // `ai: { mcp: { expose: true, path: '/mcp' } }` by DEFAULT, and nothing between the two served it:
3
+ // `ai: { mcp: { expose: true } }` by DEFAULT, and nothing between the two served it:
4
4
  // neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
5
5
  // every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
6
6
  // exports `mcp` — one `AppMcp`, or an array of them, one per population (customers, staff,
7
- // affiliates) — this module finds it, and both boots mount what it carries: endpoint #0 at
8
- // `ai.mcp.path`, every other at its own `defineAppMcp({ path })`.
7
+ // affiliates) — this module finds it, and both boots mount what it carries: EVERY endpoint at its
8
+ // own `defineAppMcp({ path })`, the one path its RFC 9728 metadata and its 401 also name.
9
9
 
10
10
  // why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
11
11
  import { existsSync } from 'node:fs';
@@ -13,7 +13,7 @@ import { existsSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { logger } from '@ultimat3/core';
15
15
  import type { Route } from '@ultimat3/http';
16
- import { json, selfOrigin } from '@ultimat3/http';
16
+ import { jsonResponse, selfOrigin } from '@ultimat3/http';
17
17
  import {
18
18
  type AppMcp,
19
19
  McpAppUnmountedError,
@@ -36,7 +36,7 @@ export const APP_MCP_ROUTE_NAME = 'mcp';
36
36
  export interface AppMcpMount {
37
37
  /** `[]` when `expose` is false, when nothing exports `mcp`, or when no endpoint has a route. */
38
38
  readonly routes: readonly Route[];
39
- /** Endpoint #0's `POST <path>` when mounted, else `null` — the default, at `ai.mcp.path`. */
39
+ /** Endpoint #0's `POST <path>` when mounted, else `null` — the default endpoint. */
40
40
  readonly path: string | null;
41
41
  /** Every mounted endpoint's `POST <path>`, in export order — the boot line prints one each. */
42
42
  readonly paths: readonly string[];
@@ -48,17 +48,19 @@ export interface AppMcpMount {
48
48
  readonly warning: McpAppUnmountedError | undefined;
49
49
  }
50
50
 
51
- interface ExposeDeclaration {
52
- readonly expose: boolean;
53
- readonly path: string;
54
- }
55
-
56
51
  const isRecord = (value: unknown): value is Record<string, unknown> =>
57
52
  typeof value === 'object' && value !== null;
58
53
 
59
- /** `config.ai.mcp` off the one loader (`app-config-load.ts`); nothing exposed with no config file. */
60
- async function exposeDeclaration(root: string): Promise<ExposeDeclaration | undefined> {
61
- return (await loadAppConfig(root))?.ai.mcp;
54
+ /**
55
+ * `defineAppMcp`'s own default — `mcp/src/transport-http.ts`, `input.path ?? '/mcp'` — named here
56
+ * ONLY in a warning about an endpoint that has no route and so no resolved path. It never decides
57
+ * where anything mounts: a mounted endpoint's path is `route.path`.
58
+ */
59
+ const UNROUTED_MCP_PATH = '/mcp';
60
+
61
+ /** `config.ai.mcp.expose` off the one loader (`app-config-load.ts`); false with no config file. */
62
+ async function exposed(root: string): Promise<boolean> {
63
+ return (await loadAppConfig(root))?.ai.mcp.expose ?? false;
62
64
  }
63
65
 
64
66
  const isAppMcp = (value: unknown): value is AppMcp =>
@@ -94,21 +96,20 @@ const NOTHING: AppMcpMount = { routes: [], path: null, paths: [], warning: undef
94
96
  * does not have and answer 401 before the token was ever read.
95
97
  */
96
98
  export async function appMcpMount(root: string): Promise<AppMcpMount> {
97
- const declared = await exposeDeclaration(root);
98
- if (declared === undefined || !declared.expose) return NOTHING;
99
+ if (!(await exposed(root))) return NOTHING;
99
100
  const files = await candidates(root);
100
101
  const fallbackFile = 'apps/web/mcp.ts';
101
102
  for (const file of files) {
102
103
  const module = (await import(join(root, file))) as Record<string, unknown>;
103
104
  const endpoints = endpointsOf(module[APP_MCP_EXPORT]);
104
105
  if (endpoints === undefined) continue;
105
- return mountEndpoints(endpoints, declared.path, file);
106
+ return mountEndpoints(endpoints, file);
106
107
  }
107
108
  return {
108
109
  ...NOTHING,
109
110
  warning: new McpAppUnmountedError({
110
111
  reason: 'missing',
111
- path: declared.path,
112
+ path: UNROUTED_MCP_PATH,
112
113
  file: files[0] ?? fallbackFile,
113
114
  }),
114
115
  };
@@ -119,11 +120,7 @@ export async function appMcpMount(root: string): Promise<AppMcpMount> {
119
120
  * route: it is skipped and the FIRST such is the warning, so one unfinished population never takes
120
121
  * the others down with it. Two endpoints on one route is thrown — see `McpPathDuplicateError`.
121
122
  */
122
- function mountEndpoints(
123
- endpoints: readonly AppMcp[],
124
- defaultPath: string,
125
- file: string,
126
- ): AppMcpMount {
123
+ function mountEndpoints(endpoints: readonly AppMcp[], file: string): AppMcpMount {
127
124
  const routes: Route[] = [];
128
125
  const paths: string[] = [];
129
126
  const claimed = new Map<string, number>();
@@ -133,13 +130,15 @@ function mountEndpoints(
133
130
  if (route === undefined) {
134
131
  warning ??= new McpAppUnmountedError({
135
132
  reason: 'no-route',
136
- path: defaultPath,
133
+ path: UNROUTED_MCP_PATH,
137
134
  file,
138
135
  ...(index === 0 ? {} : { endpoint: index }),
139
136
  });
140
137
  return;
141
138
  }
142
- const path = index === 0 ? defaultPath : route.path;
139
+ // The descriptor's own path — the one its metadata, its `resourceUrl` and its 401 were built
140
+ // over. A mount path from anywhere else is a resource advertised where nothing answers.
141
+ const path = route.path;
143
142
  for (const mounted of endpointRoutes(route, path, index)) {
144
143
  const key = `${mounted.method} ${mounted.path}`;
145
144
  const first = claimed.get(key);
@@ -156,8 +155,7 @@ function mountEndpoints(
156
155
  }
157
156
  paths.push(path);
158
157
  });
159
- const path = endpoints[0]?.route === undefined ? null : defaultPath;
160
- return { routes, path, paths, warning };
158
+ return { routes, path: endpoints[0]?.route?.path ?? null, paths, warning };
161
159
  }
162
160
 
163
161
  type Descriptor = NonNullable<AppMcp['route']>;
@@ -179,7 +177,7 @@ function endpointRoutes(route: Descriptor, path: string, index: number): readonl
179
177
  method: 'GET',
180
178
  path: metadataPath,
181
179
  handler: (_request, ctx) => {
182
- const response = json(resource.document(selfOrigin(ctx.url, ctx.https)));
180
+ const response = jsonResponse(resource.document(selfOrigin(ctx.url, ctx.https)));
183
181
  // A browser-hosted MCP client reads this cross-origin; it holds nothing secret.
184
182
  response.headers.set('access-control-allow-origin', '*');
185
183
  response.headers.set('cache-control', 'public, max-age=3600');
@@ -0,0 +1,52 @@
1
+ // `x new <name>`'s last refusal before a byte lands: the slug must be a name core's `defineConfig`
2
+ // accepts, because it is written verbatim as `app.config.ts`'s `name:` and spelled into every
3
+ // identifier the scaffold derives (`<app>Seed`). The rule is asked of core, never restated here.
4
+
5
+ import { defineConfig, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
6
+
7
+ /**
8
+ * Why core's own config validator refuses `name`, in core's words, or `undefined` when it accepts
9
+ * it — asked by building the one config that name would scaffold, every other field defaulted. A
10
+ * copy of core's pattern here would be a second answer to "what is an app name", and the day one
11
+ * of them moved `x new` would again write an app whose `app.config.ts` throws at its own import.
12
+ */
13
+ export function appNameIssue(name: string): string | undefined {
14
+ try {
15
+ defineConfig({ name });
16
+ return undefined;
17
+ } catch (error) {
18
+ return isUltimateError(error) ? error.cause : renderThrowable(error);
19
+ }
20
+ }
21
+
22
+ /** The bound the suggestion trims an over-long slug to — re-proved by `appNameIssue`, not trusted. */
23
+ const MAX_LENGTH = 64;
24
+
25
+ /**
26
+ * A slug core accepts, as close to `slug` as one edit gets: a leading digit gains an `app-` prefix
27
+ * (an identifier cannot start with one either), a one-letter name an `-app` suffix, and an
28
+ * over-long one is cut at the bound. `undefined` when none of those land on a valid name.
29
+ */
30
+ export function suggestAppName(slug: string): string | undefined {
31
+ let candidate = /^[0-9]/.test(slug) ? `app-${slug}` : slug;
32
+ if (candidate.length < 2) candidate = `${candidate}-app`;
33
+ candidate = candidate.slice(0, MAX_LENGTH).replace(/-+$/, '');
34
+ return appNameIssue(candidate) === undefined ? candidate : undefined;
35
+ }
36
+
37
+ /** `x new 9lives`, `x new a`: a slug core's `defineConfig` would refuse at the app's first boot. */
38
+ export class AppNameInvalidError extends UltimateError {
39
+ constructor(input: {
40
+ name: string;
41
+ slug: string;
42
+ issue: string;
43
+ invocation: string;
44
+ flags: readonly string[];
45
+ }) {
46
+ super({
47
+ code: 'X_CLI_BAD_FLAG',
48
+ cause: `"${input.name}" scaffolds the app name "${input.slug}", which app.config.ts's defineConfig refuses: ${input.issue}`,
49
+ fix: [input.invocation, suggestAppName(input.slug) ?? '<name>', ...input.flags].join(' '),
50
+ });
51
+ }
52
+ }
@@ -3,13 +3,11 @@
3
3
  // delete` for every mounted entity, so an app rule naming one passed in the web process and threw
4
4
  // X_PERMISSION_UNKNOWN in any process that never mounted the admin — with nothing saying so.
5
5
 
6
- // why: Bun ships no path API, and `relative` renders a frame's absolute path against the app root.
7
- import { relative } from 'node:path';
8
6
  import { listActions } from '@ultimat3/action';
9
7
  import { type Policy, permissionDeclarationSites, policyPermissions } from '@ultimat3/policy';
10
8
  import { listQueries } from '@ultimat3/query';
11
9
  import { routeEntries } from '@ultimat3/render';
12
- import { frameFile, siteFile } from './app-permissions-site';
10
+ import { frameFile, rootRelative, siteFile } from './app-permissions-site';
13
11
  import type { Finding } from './output';
14
12
 
15
13
  /** One permission one app declaration requires. */
@@ -72,11 +70,7 @@ export function borrowedPermissions(
72
70
  if (files.length === 0 || files.some((file) => file === undefined)) continue;
73
71
  if ((sites ?? []).some((site) => appFile(root, site) !== undefined)) continue;
74
72
  const from = [
75
- ...new Set(
76
- files.flatMap((file) =>
77
- file === undefined ? [] : [relative(root, file).replaceAll('\\', '/')],
78
- ),
79
- ),
73
+ ...new Set(files.flatMap((file) => (file === undefined ? [] : [rootRelative(root, file)]))),
80
74
  ].sort();
81
75
  found.set(`${rule.permission}\u0000${rule.by}`, { ...rule, from });
82
76
  }
@@ -2,15 +2,39 @@
2
2
  // `defineRoles()` or `definePermissions()` call — reduced to the file it names, absolute or
3
3
  // app-root-relative. Both `policy` step questions key their findings by it.
4
4
 
5
- // why: Bun ships no path API, and `relative` is what turns a stack frame's absolute path into
6
- // the app-root-relative one every finding is keyed by.
7
- import { relative } from 'node:path';
5
+ // why: Bun ships no path API, and `posix.relative` is what turns a frame's absolute path into the
6
+ // app-root-relative one every finding is keyed by — on POSIX-normalised input, so a Windows frame
7
+ // and root compare the same way on every host.
8
+ import { posix } from 'node:path';
8
9
 
9
- /** The absolute source path a frame (`at foo (/abs/path.ts:3:1)`) names, or `undefined`. */
10
+ /**
11
+ * The location at the END of a frame — `at fn (<path>:<line>:<col>)` or `at <path>:<line>:<col>` —
12
+ * read lazily from the first place a path can start, so a `(` INSIDE the path (`/srv/shop (copy)/`)
13
+ * is part of it, and a drive letter (`C:\…`) is a path start too. Anchored at the end because a
14
+ * frame's location is always its last token, and the function name before it is not a path.
15
+ */
16
+ const FRAME_LOCATION = /(?:^|[\s(])((?:[A-Za-z]:)?[\\/].*?\.[cm]?tsx?)(?::\d+){0,2}\)?\s*$/;
17
+
18
+ /** The absolute source path a frame names, exactly as written, or `undefined`. */
10
19
  export function frameFile(site: string): string | undefined {
11
- return /\(?(\/[^():]+\.[cm]?tsx?)(?::\d+)?(?::\d+)?\)?/.exec(site)?.[1];
20
+ return FRAME_LOCATION.exec(site)?.[1];
12
21
  }
13
22
 
23
+ /**
24
+ * `\` → `/`, and a drive letter rooted like a POSIX path with its case folded (`C:\a` → `/c:/a`):
25
+ * `@ultimat3/policy`'s `declaration-site.ts` compares frames `/`-normalised for the same reason —
26
+ * on Windows neither a frame nor a root holds a `/`, and a shell spells the drive either way.
27
+ */
28
+ const comparable = (path: string): string => {
29
+ const slashed = path.replaceAll('\\', '/');
30
+ const drive = /^([A-Za-z]):\//.exec(slashed);
31
+ return drive === null ? slashed : `/${(drive[1] ?? '').toLowerCase()}:${slashed.slice(2)}`;
32
+ };
33
+
34
+ /** `file` relative to `root`, `/`-separated, whichever OS printed either of them. */
35
+ export const rootRelative = (root: string, file: string): string =>
36
+ posix.relative(comparable(root), comparable(file));
37
+
14
38
  /**
15
39
  * The frame's file relative to the app root, or `undefined` outside it: a locator nobody can act on
16
40
  * is worse than none. The frame is the only place the declaring module's name exists by the time
@@ -19,6 +43,6 @@ export function frameFile(site: string): string | undefined {
19
43
  export function siteFile(root: string, site: string): string | undefined {
20
44
  const absolute = frameFile(site);
21
45
  if (absolute === undefined) return undefined;
22
- const rel = relative(root, absolute).replaceAll('\\', '/');
23
- return rel.startsWith('..') || rel.length === 0 ? undefined : rel;
46
+ const rel = rootRelative(root, absolute);
47
+ return rel.startsWith('..') || rel.length === 0 || rel.startsWith('/') ? undefined : rel;
24
48
  }
package/src/app-root.ts CHANGED
@@ -20,10 +20,14 @@ export const MANIFEST_FILE = 'x.manifest.json';
20
20
  * the gate's dominant step, with `x doctor` reporting the runtime as fine. `--parallel` arrived in
21
21
  * the same release and is emitted now, so the floor may never fall below that patch.
22
22
  *
23
- * `1.4.0` rather than `1.3.13` because a floor is a claim about a runtime somebody TESTED: CI pins
24
- * `1.4.x`, both images build on `oven/bun:1.4-*`, and the per-worker database rests on
25
- * `BUN_TEST_WORKER_ID`'s numbering, probed on 1.4.0 and on nothing older. `scripts/bun-pin.test.ts`
26
- * holds this to the same series as every other pin.
23
+ * A 1.4 patch rather than `1.3.13` because a floor is a claim about a runtime somebody TESTED: CI
24
+ * pins one exact patch, both images build on that patch's `oven/bun:1.4-*` digest, and the
25
+ * per-worker database rests on `BUN_TEST_WORKER_ID`'s numbering, probed on 1.4 and on nothing older.
26
+ * It moved 1.4.0 → 1.4.2 with CI's pin (plan 2026/10/04/101, sweep 3): from then on 1.4.0 was a
27
+ * runtime nothing here ran, and the bundler defects fixed in 1.4.1 (oven-sh/bun#40578, #40650,
28
+ * #40657) change what `x build` emits. `scaffold-repo.ts` and the scaffold's CI read this constant,
29
+ * so a new app's `engines.bun` and its CI runtime move with it. `scripts/bun-pin.test.ts` holds it
30
+ * to the exact patch CI runs.
27
31
  *
28
32
  * **Lowering it to 1.3.14 was tried on 2026-08-27 and refused**, and the argument for trying was
29
33
  * sound — `--isolate` and `--parallel` are 1.3.13 features, no package here calls a 1.4-only API
@@ -34,7 +38,7 @@ export const MANIFEST_FILE = 'x.manifest.json';
34
38
  * declared supported would hang on graceful shutdown the moment its database went away. The full
35
39
  * measurement is in `.github/actions/setup/action.yml`; read it before lowering this.
36
40
  */
37
- export const REQUIRED_BUN = '1.4.0';
41
+ export const REQUIRED_BUN = '1.4.2';
38
42
 
39
43
  export interface AppRoot {
40
44
  readonly dir: string;
package/src/bin.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { dispatch } from './dispatch';
6
6
  import { resolveLocalCli } from './local-cli';
7
+ import { handOff } from './local-cli-handoff';
7
8
  import { cwdFromArgv } from './root-env';
8
9
  // The writes themselves are `write-line.ts`: `create-ultimate`'s entry point needs the identical
9
10
  // one, and a second copy of a note about pipe truncation is a second copy that drifts. Two sinks,
@@ -24,13 +25,8 @@ const local = resolveLocalCli({
24
25
  });
25
26
  if (local !== undefined) {
26
27
  writeErrorLine(`x: using the app's own @ultimat3/cli at ${local}`);
27
- const child = Bun.spawn([process.execPath, local, ...Bun.argv.slice(2)], {
28
- stdin: 'inherit',
29
- stdout: 'inherit',
30
- stderr: 'inherit',
31
- env: Bun.env,
32
- });
33
- process.exit(await child.exited);
28
+ // Stop signals are forwarded and the child's code is ours: see local-cli-handoff.ts.
29
+ process.exit(await handOff([process.execPath, local, ...Bun.argv.slice(2)], Bun.env));
34
30
  }
35
31
 
36
32
  // `x dev` supervises a child that serves (`dev-supervisor.ts`): a save that reaches a module which
@@ -39,7 +39,8 @@ export class ShotChromeMissingError extends UltimateError {
39
39
  // One literal, so `fix-scan.ts` can read it. A path is the one repair that works whatever the
40
40
  // browser is and wherever the distribution put it; `--cdp-url` is the answer for a box that
41
41
  // will never have one, and it is a flag `x shot` already ships.
42
- fix: 'export CHROME_PATH=/usr/bin/google-chrome # any Chrome or Chromium binary; a box that will never have one attaches instead: x shot / --cdp-url wss://cdp.example.com/session/abc',
42
+ // The `#` names the macOS and Windows spellings: one literal for every platform.
43
+ fix: 'export CHROME_PATH=/usr/bin/google-chrome # any Chrome, Chromium or Edge binary — macOS: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; Windows PowerShell: $env:CHROME_PATH = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" (Edge: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"); a box that will never have one attaches instead: x shot / --cdp-url wss://cdp.example.com/session/abc',
43
44
  meta: { tried: [...CHROME_CANDIDATES], vars: [...BROWSER_PATH_VARS] },
44
45
  });
45
46
  }
@@ -67,7 +68,8 @@ export const browserBinaryExists = (path: string): boolean => existsSync(path);
67
68
  /**
68
69
  * The path a run will launch, or `undefined` when this machine has no browser to launch.
69
70
  *
70
- * The last step is a PROBE — the four paths `cdp-launch.ts` tries for the e2e driver, imported
71
+ * The last step is a PROBE — the paths `cdp-launch.ts` tries for the e2e driver on THIS platform
72
+ * (`/usr/bin` on Linux, the app bundles on macOS, Chrome and Edge on Windows), imported
71
73
  * rather than restated, because two lists of Chrome locations that must agree is the drift axiom 2
72
74
  * refuses — so a box with no browser is refused before a dev-server boot, not one launch later.
73
75
  *
@@ -0,0 +1,60 @@
1
+ // What a route's measured JavaScript was made of, file by file: the clause and the `--json` facts
2
+ // `X_BUDGET_EXCEEDED` carries, so a reader can tell a heavier chunk from a byte counted twice
3
+ // without re-running the build. Split from `budgets.ts`, which measures and compares.
4
+
5
+ import type { JsonValue } from './output';
6
+
7
+ /** One app file a document made the browser fetch, and its bytes on disk. */
8
+ export interface ChargedFile {
9
+ readonly url: string;
10
+ readonly bytes: number;
11
+ }
12
+
13
+ /**
14
+ * Named in the cause; the rest are summed. A page carries one or two files in practice, and a
15
+ * sentence listing forty is one nobody reads — `meta.charged` still holds every one of them.
16
+ */
17
+ const NAMED_FILES = 5;
18
+
19
+ /** Heaviest first, then by URL, so the clause and the stats file are reproducible. */
20
+ export function heaviestFirst(files: readonly ChargedFile[]): readonly ChargedFile[] {
21
+ return [...files].sort((a, b) =>
22
+ b.bytes !== a.bytes ? b.bytes - a.bytes : a.url < b.url ? -1 : a.url > b.url ? 1 : 0,
23
+ );
24
+ }
25
+
26
+ /**
27
+ * `; charged: /islands/post-form-04f75f18.js 81306 B, inline scripts 855 B` — exact bytes, never
28
+ * `formatBytes`: a 22 kB disagreement between two platforms is the question this answers, and a
29
+ * rounded figure hides the byte a duplicate shows up as. Empty for a row an older build wrote.
30
+ */
31
+ export function chargedClause(
32
+ charged: readonly ChargedFile[] | undefined,
33
+ inlineBytes: number | undefined,
34
+ ): string {
35
+ const files = heaviestFirst(charged ?? []);
36
+ const parts = files.slice(0, NAMED_FILES).map((one) => `${one.url} ${String(one.bytes)} B`);
37
+ const rest = files.slice(NAMED_FILES);
38
+ if (rest.length > 0) {
39
+ const bytes = rest.reduce((sum, one) => sum + one.bytes, 0);
40
+ parts.push(`${String(rest.length)} more files ${String(bytes)} B`);
41
+ }
42
+ if (inlineBytes !== undefined && inlineBytes > 0) {
43
+ parts.push(`inline scripts ${String(inlineBytes)} B`);
44
+ }
45
+ return parts.length === 0 ? '' : `; charged: ${parts.join(', ')}`;
46
+ }
47
+
48
+ /** The same facts as data, or `undefined` when the row predates them — absent is not empty. */
49
+ export function chargedMeta(
50
+ jsBytes: number,
51
+ charged: readonly ChargedFile[] | undefined,
52
+ inlineBytes: number | undefined,
53
+ ): { readonly [key: string]: JsonValue } | undefined {
54
+ if (charged === undefined && inlineBytes === undefined) return undefined;
55
+ return {
56
+ jsBytes,
57
+ charged: heaviestFirst(charged ?? []).map((one) => ({ url: one.url, bytes: one.bytes })),
58
+ inlineJsBytes: inlineBytes ?? 0,
59
+ };
60
+ }
@@ -0,0 +1,76 @@
1
+ // How the budget measurer READS an emitted document: which tags are code, which attribute names an
2
+ // island entry, which path a URL is fetched at, and which files a module makes the browser load.
3
+ // Split from `budgets.ts`, which decides what of that is charged and compares it to the budget.
4
+
5
+ import { SPECULATION_RULES_TYPE } from '@ultimat3/render';
6
+
7
+ export const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
8
+ export const SRC_ATTR = /\ssrc="(?<src>[^"]*)"/;
9
+ const TYPE_ATTR = /\stype="(?<type>[^"]*)"/;
10
+
11
+ /**
12
+ * `application/ld+json`, `application/json`, any `…+json`: the body is data, not code — the rule
13
+ * `@ultimat3/render`'s `head.ts` already states, restated because its `carriesJson` reads a
14
+ * `HeadTag` and is not exported, and this side has an attribute string off the emitted document.
15
+ * Without it a page shipping only `meta.ld` structured data and island props measured 8kb of JS
16
+ * and failed a 2kb budget with a `fix:` naming an import chain that does not exist.
17
+ */
18
+ export const carriesJson = (attrs: string): boolean => {
19
+ // Everything from the first `;` is a MIME PARAMETER and not the type: a real document writes
20
+ // `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
21
+ // test alone charged an SEO structured-data block as executable JavaScript all over again.
22
+ const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
23
+ const declared = type.trim().toLowerCase();
24
+ // Speculation rules are JSON under a type that does not say so: read by the browser's
25
+ // prefetcher, never executed — the one script a 0kb page may carry for free.
26
+ return declared.endsWith('json') || declared === SPECULATION_RULES_TYPE;
27
+ };
28
+
29
+ /**
30
+ * An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
31
+ * as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
32
+ * never for the code that runtime boots. The entry attribute is that module URL, so it is read as
33
+ * exactly what it is: a file the browser will execute.
34
+ */
35
+ export const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
36
+
37
+ /**
38
+ * Static imports and `import()` both. A chunk reached by `import()` was INLINED into its island
39
+ * before islands were split, so charging it keeps a route's number what it was; and it is fetched
40
+ * the moment the island needs it, which is before the page does what it was loaded for.
41
+ */
42
+ const LOADS: ReadonlySet<string> = new Set(['import-statement', 'dynamic-import']);
43
+
44
+ /** The origin every artifact path is resolved against — a name that can never be a real host. */
45
+ const ARTIFACT_ORIGIN = 'https://artifact.invalid';
46
+
47
+ /**
48
+ * The path a browser fetches for a root-relative `url`, or `undefined` for one that is not this
49
+ * artifact's. Resolved with the WHATWG parser, the browser's own: it reads `\` as `/`, folds `.`
50
+ * and `..`, drops a query, and makes `//host/a.js` another origin. The dedupe below is keyed by
51
+ * this, because it is the FETCH that is counted once, and a raw string kept two spellings of one
52
+ * file apart — which `join` on Windows then turned back into one real path, charged twice.
53
+ */
54
+ export function artifactPath(url: string): string | undefined {
55
+ if (!url.startsWith('/')) return undefined;
56
+ const resolved = URL.parse(url, ARTIFACT_ORIGIN);
57
+ return resolved?.origin === ARTIFACT_ORIGIN ? resolved.pathname : undefined;
58
+ }
59
+
60
+ /** Same-origin paths `code`, served at `url`, makes the browser load. Cross-origin and bare are not this build's. */
61
+ export function loadedBy(url: string, code: string): readonly string[] {
62
+ let imports: readonly { readonly path: string; readonly kind: string }[];
63
+ try {
64
+ imports = new Bun.Transpiler({ loader: 'js' }).scanImports(code);
65
+ } catch {
66
+ // A file that does not parse as JavaScript imports nothing this gate can name; its own bytes
67
+ // are already charged.
68
+ return [];
69
+ }
70
+ const base = new URL(url, ARTIFACT_ORIGIN);
71
+ return imports
72
+ .filter((one) => LOADS.has(one.kind) && /^\.{0,2}\//.test(one.path))
73
+ .map((one) => new URL(one.path, base))
74
+ .filter((target) => target.origin === base.origin)
75
+ .map((target) => target.pathname);
76
+ }
@@ -0,0 +1,91 @@
1
+ // Single responsibility: the finding for a budgeted route a build RAN and could not weigh, read off
2
+ // the static report's `unmeasured` entry — under the failure's own code when that code is the
3
+ // instruction, as an actor edit when the measurement actor was refused, the report otherwise.
4
+
5
+ import { ERROR_DOCS_URL } from '@ultimat3/core';
6
+ import type { Finding } from './output';
7
+ import type { UnmeasuredRoute } from './static-report';
8
+
9
+ /**
10
+ * The ONE code a failed measurement is reported under by its own name rather than as
11
+ * `X_BUDGET_UNMEASURED`. Its cause is complete — the island, the prop, its bytes, the cap — and
12
+ * its fix is an edit to the page, so the step's own "run x build and read the list" would put a
13
+ * second command between the author and a sentence the build had already composed. Every other
14
+ * render failure keeps the generic finding: a `TypeError` from a `load` that wanted a request is
15
+ * a reason to read the report, not an instruction.
16
+ *
17
+ * ONE code and not "any coded error", deliberately. `X_NO_CONTEXT` and `X_DB_UNAVAILABLE` from a
18
+ * measurement render are facts about the BUILD's environment, and reporting them under their own
19
+ * codes would tell the author to fix a database the gate never had. The list grows by a decision,
20
+ * per code, here.
21
+ */
22
+ const REPORTED_BY_OWN_CODE: ReadonlySet<string> = new Set([
23
+ 'X_ISLAND_PROPS_INVALID',
24
+ // A dynamic route that lists no `prerender()` path: the edit is in the route, not the build.
25
+ 'X_BUDGET_PARAMS_UNDECLARED',
26
+ ]);
27
+
28
+ /**
29
+ * Refusals of WHO the render ran as. The actor is the app's own declaration (core's
30
+ * `defineMeasurementActor`, in `app.config.ts`), so these ARE an instruction — but the refusal's
31
+ * own `fix:` was written for a request ("sign in", "mint the actor with its tenant at the request
32
+ * boundary") and names no build-time edit. The default actor is a service actor holding `*` with
33
+ * no org and no roles: a page whose policy names a role, or whose `load` reads a tenant-scoped
34
+ * entity, is refused under it (#675).
35
+ */
36
+ const ACTOR_REFUSALS: ReadonlySet<string> = new Set([
37
+ 'X_FORBIDDEN',
38
+ 'X_UNAUTHENTICATED',
39
+ 'X_TENANCY_ACTOR_ORG_REQUIRED',
40
+ ]);
41
+
42
+ function ownCodeFinding(url: string, entry: UnmeasuredRoute): Finding {
43
+ return {
44
+ code: entry.code ?? 'X_BUDGET_UNMEASURED',
45
+ cause: entry.cause ?? entry.reason,
46
+ fix: entry.fix ?? `x build --target static --json # its "unmeasured" list has ${url}`,
47
+ docs: ERROR_DOCS_URL,
48
+ at: url,
49
+ };
50
+ }
51
+
52
+ /** `CODE: cause` when the failure had a code, its rendered reason otherwise. */
53
+ const refusalOf = (entry: UnmeasuredRoute): string =>
54
+ entry.code === undefined ? entry.reason : `${entry.code}: ${entry.cause ?? entry.reason}`;
55
+
56
+ /**
57
+ * A build ran and has no row for `url`. `entry` is the report's account of why, when it has one:
58
+ * an instruction under its own code, an actor edit, or — for a failure that is a fact about the
59
+ * build's environment — the report, with the refusal already in the cause.
60
+ */
61
+ export function builtUnmeasuredFinding(
62
+ url: string,
63
+ declared: string,
64
+ statsFile: string,
65
+ entry: UnmeasuredRoute | undefined,
66
+ ): Finding {
67
+ if (entry?.code !== undefined && REPORTED_BY_OWN_CODE.has(entry.code)) {
68
+ return ownCodeFinding(url, entry);
69
+ }
70
+ const base = `${url} declares a ${declared} budget and ${statsFile} has no row for it, so the build ran and could not weigh it`;
71
+ if (entry?.code !== undefined && ACTOR_REFUSALS.has(entry.code)) {
72
+ const actor = entry.actor ?? 'the measurement actor';
73
+ return {
74
+ code: 'X_BUDGET_UNMEASURED',
75
+ cause: `${base}: rendered as ${actor}, it was refused ${refusalOf(entry)}`,
76
+ // The paste: one declaration in the one file every build imports.
77
+ fix: "defineMeasurementActor(() => userActor({ id: 'measure', orgId: '<an org your dev seed creates>', roles: ['<the role this page requires>'] })) # in app.config.ts",
78
+ docs: ERROR_DOCS_URL,
79
+ at: url,
80
+ };
81
+ }
82
+ return {
83
+ code: 'X_BUDGET_UNMEASURED',
84
+ cause: entry === undefined ? base : `${base}: ${refusalOf(entry)}`,
85
+ // When a build already ran, the report is where the rest of the answer is — it names every
86
+ // route it could not weigh, and why.
87
+ fix: `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`,
88
+ docs: ERROR_DOCS_URL,
89
+ at: url,
90
+ };
91
+ }