@ultimat3/cli 19.1.3 → 19.3.1

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 (65) hide show
  1. package/CLAUDE.md +125 -8
  2. package/package.json +29 -29
  3. package/src/app-boundaries.ts +11 -2
  4. package/src/app-load.ts +5 -1
  5. package/src/app-openapi.ts +13 -5
  6. package/src/app-permissions.ts +0 -0
  7. package/src/browser-launcher.ts +53 -4
  8. package/src/budgets.ts +60 -7
  9. package/src/cmd-dev.ts +49 -39
  10. package/src/cmd-doctor.ts +61 -23
  11. package/src/cmd-generate.ts +5 -2
  12. package/src/cmd-i18n.ts +10 -3
  13. package/src/cmd-jobs.ts +56 -10
  14. package/src/cmd-shot.ts +3 -1
  15. package/src/cmd-test.ts +15 -10
  16. package/src/db-seed.ts +2 -1
  17. package/src/dev-queue.ts +16 -2
  18. package/src/dev-reload.ts +46 -0
  19. package/src/dev-render.ts +28 -7
  20. package/src/dev-roles.ts +9 -8
  21. package/src/dev-runtime.ts +4 -1
  22. package/src/dev-sync.ts +17 -3
  23. package/src/dev-watch-tree.ts +226 -0
  24. package/src/dev-watch.ts +75 -0
  25. package/src/doctor-offline.ts +122 -0
  26. package/src/duplicate-packages.ts +278 -0
  27. package/src/error-catalog.ts +4 -5
  28. package/src/error-codes.ts +6 -0
  29. package/src/fix-command.ts +40 -1
  30. package/src/fix-path.ts +10 -11
  31. package/src/flag-number.ts +15 -0
  32. package/src/generate-kinds.ts +54 -4
  33. package/src/generate-write.ts +25 -2
  34. package/src/gitignore.ts +145 -0
  35. package/src/hold.ts +50 -17
  36. package/src/i18n-registration.ts +34 -5
  37. package/src/index.ts +3 -1
  38. package/src/island-bundle.ts +123 -10
  39. package/src/island-harness.ts +11 -4
  40. package/src/island-states-load.ts +2 -1
  41. package/src/jobs-driver.ts +4 -1
  42. package/src/mcp-errors.ts +2 -0
  43. package/src/mcp-host.ts +21 -9
  44. package/src/parse.ts +17 -0
  45. package/src/path-segments.ts +14 -0
  46. package/src/prerender.ts +68 -16
  47. package/src/retry-memo.ts +37 -0
  48. package/src/serve.ts +17 -2
  49. package/src/shot-browser.ts +23 -4
  50. package/src/source-files.ts +3 -1
  51. package/src/static-report.ts +21 -1
  52. package/src/style-bundle.ts +124 -0
  53. package/src/style-csp.ts +14 -12
  54. package/src/style-routes.ts +56 -0
  55. package/src/sw-artifacts.ts +84 -12
  56. package/src/templates/admin-page.ts +49 -1
  57. package/src/templates/resource-form-island.ts +13 -3
  58. package/src/templates/scaffold-container.ts +12 -0
  59. package/src/templates/scaffold-repo.ts +13 -2
  60. package/src/test-passes.ts +79 -0
  61. package/src/test-shards.ts +110 -36
  62. package/src/verify-checks.ts +13 -7
  63. package/src/verify-step.ts +4 -4
  64. package/src/verify-tests.ts +33 -8
  65. package/src/web-binding.ts +22 -0
package/src/mcp-host.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  // the gate. The description half is the framework's own `frameworkIntrospection`, so nothing here
4
4
  // is a second catalog of routes, entities, actions, queries or jobs.
5
5
 
6
+ // why: Bun exposes no path API. Every use here builds a path this host then hands to `Bun.file`
7
+ // or prints inside a `fix:` an operator runs — the dev log, a per-role log, the committed
8
+ // manifest — and string concatenation would answer a different path on a Windows checkout.
6
9
  import { join } from 'node:path';
7
10
  import { agentActor, isUltimateError, renderThrowable, UltimateError } from '@ultimat3/core';
8
11
  import type { DbClient } from '@ultimat3/db';
@@ -42,6 +45,7 @@ import { databaseTarget } from './mcp-db-target';
42
45
  import { explainErrorCode } from './mcp-errors';
43
46
  import { parseBunTest } from './mcp-test-output';
44
47
  import { readMigrations } from './migrations';
48
+ import { retryMemo } from './retry-memo';
45
49
 
46
50
  export interface DevHostInput {
47
51
  readonly root: string;
@@ -88,7 +92,11 @@ export interface LazyServices {
88
92
  */
89
93
  export function lazyServices(input: DevHostInput): LazyServices {
90
94
  const services = resolveServices(input.root, input.env);
91
- let started: Promise<RunningServices> | undefined;
95
+ // `retryMemo`, not `??=`: a boot that REJECTED is not an answer to keep. A Postgres that refused
96
+ // one connection wedged every later tool call in the session with that first error, and the only
97
+ // way out was restarting the host — `startServices` unwinds everything it started before it
98
+ // rejects, so there is nothing left over for a second attempt to collide with.
99
+ const boot = retryMemo(() => startServices(services, input.env));
92
100
  let closed = false;
93
101
  return {
94
102
  services,
@@ -101,14 +109,14 @@ export function lazyServices(input: DevHostInput): LazyServices {
101
109
  fix: 'x mcp serve --transport stdio # keep the host open for the whole session',
102
110
  });
103
111
  }
104
- started ??= startServices(services, input.env);
105
- return started;
112
+ return boot.get();
106
113
  },
107
114
  async close(): Promise<void> {
108
115
  if (closed) return;
109
116
  closed = true;
110
- // A boot that rejected has nothing to stop, and close() must not throw on the way out.
111
- await (await started?.catch(() => undefined))?.stop();
117
+ // `started()` and never `get()`: closing must not BOOT a database in order to stop one. A
118
+ // boot that rejected has nothing to stop, and close() must not throw on the way out.
119
+ await (await boot.started()?.catch(() => undefined))?.stop();
112
120
  },
113
121
  };
114
122
  }
@@ -172,8 +180,13 @@ export async function readOnlyRows(
172
180
  function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities {
173
181
  const { root, runner } = input;
174
182
  // Layer 1 is seven idempotent DDL statements, and `db.query` is a tool an agent calls in a
175
- // loop — resolve the role once per process and reuse the answer, `null` included.
176
- let readOnlyRole: Promise<string | null> | undefined;
183
+ // loop — resolve the role once per process and reuse the answer, `null` included. A FAILED
184
+ // resolution is not an answer: `??=` kept the rejection, so a statement timeout on the DDL
185
+ // meant every later `db.query` in the session refused with it instead of trying again.
186
+ //
187
+ // It asks `lazy.running()` for the client rather than closing over one: this runs before any
188
+ // boot has happened, and the boot is memoised, so it is the same connection `runQuery` uses.
189
+ const readOnlyRole = retryMemo(async () => ensureReadOnlyRole((await lazy.running()).db));
177
190
 
178
191
  return {
179
192
  database: databaseTarget(lazy.services, input.env),
@@ -182,8 +195,7 @@ function capabilities(input: DevHostInput, lazy: LazyServices): DevCapabilities
182
195
  const { db } = await lazy.running();
183
196
  // A managed Postgres may refuse CREATE ROLE; `ensureReadOnlyRole` answers null and the
184
197
  // layer is reported absent in `guards` rather than quietly assumed present.
185
- readOnlyRole ??= ensureReadOnlyRole(db);
186
- return readOnlyRows(db, sql, limits, await readOnlyRole);
198
+ return readOnlyRows(db, sql, limits, await readOnlyRole.get());
187
199
  },
188
200
 
189
201
  async runMigrations(branch: string, dryRun: boolean) {
package/src/parse.ts CHANGED
@@ -88,6 +88,15 @@ export interface CommandSpec {
88
88
  * that declared it and forgot the call ran outside an app with no refusal.
89
89
  */
90
90
  readonly requiresApp?: boolean;
91
+ /**
92
+ * The command hands everything after a bare `--` to another tool, so `ParsedArgs.passthrough`
93
+ * has a READER. Declared, because it did not until 2026-09 and nothing read it anywhere:
94
+ * `x test unit -- --coverage --bail` parsed both flags, carried them the whole way and dropped
95
+ * them, and every other command did the same in the same silence. A command that declares this
96
+ * forwards them; one that does not refuses the `--` (`X_CLI_BAD_FLAG`), which is the only way an
97
+ * argument that changes nothing becomes visible to the caller who typed it.
98
+ */
99
+ readonly passthrough?: true;
91
100
  }
92
101
 
93
102
  export interface ParsedArgs {
@@ -170,6 +179,14 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
170
179
  }
171
180
 
172
181
  const spec = resolveCommand(first, specs);
182
+ if (passthrough.length > 0 && spec.passthrough !== true) {
183
+ throw new BadFlagError({
184
+ flag: '',
185
+ command: spec.name,
186
+ reason: `hands nothing to another tool, so ${passthrough.join(' ')} would be dropped in silence`,
187
+ fix: `x ${spec.name} --help`,
188
+ });
189
+ }
173
190
  const flags = defaults(spec);
174
191
  const positionals: string[] = [];
175
192
  // What argv actually SET, as against what `defaults()` seeded: a default is nobody's request,
@@ -0,0 +1,14 @@
1
+ // One answer to "does this path pass through a directory called X". Six modules asked it with
2
+ // `path.includes('node_modules')`, which is a SUBSTRING match: an app checked out under
3
+ // `~/dev/node_modules-experiments/myapp` answered true for every file it holds, so `loadApp`
4
+ // imported none of them and the app registered nothing at all.
5
+
6
+ /** Split on either separator: a rule that reads `a/b` and not `a\b` does not exist on Windows. */
7
+ export function pathSegments(path: string): readonly string[] {
8
+ return path.replaceAll('\\', '/').split('/');
9
+ }
10
+
11
+ /** Whether any whole segment of `path` is `segment` — never a substring of one. */
12
+ export function hasPathSegment(path: string, segment: string): boolean {
13
+ return pathSegments(path).includes(segment);
14
+ }
package/src/prerender.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // only which routes qualify and where the bytes land.
5
5
 
6
6
  import { join } from 'node:path';
7
- import { createContext, renderThrowable, runWithContext } from '@ultimat3/core';
7
+ import { createContext, isUltimateError, renderThrowable, runWithContext } from '@ultimat3/core';
8
8
  import type { RouteEntry } from '@ultimat3/render';
9
9
  import { describeRoutes, routeEntries } from '@ultimat3/render';
10
10
  import { renderStatic } from '@ultimat3/render/server';
@@ -21,7 +21,14 @@ import { measurementActor } from './measurement-actor';
21
21
  import { loadPwaArtifacts, WEB_MANIFEST_PATH, writePwaIcons } from './pwa-artifacts';
22
22
  import type { SkippedRoute, UnmeasuredRoute } from './static-report';
23
23
  import { skippedRoute, skipReasonFor, writeStaticReport } from './static-report';
24
- import { SERVICE_WORKER_PATH, SW_REGISTER_PATH, serviceWorkerArtifacts } from './sw-artifacts';
24
+ import { styleBundle, writeStyles } from './style-bundle';
25
+ import type { RenderedDocument } from './sw-artifacts';
26
+ import {
27
+ SERVICE_WORKER_PATH,
28
+ SW_REGISTER_PATH,
29
+ serviceWorkerArtifacts,
30
+ serviceWorkerHead,
31
+ } from './sw-artifacts';
25
32
 
26
33
  // Re-exported, never re-declared: `static-report.ts` owns the shape because the report on disk
27
34
  // carries it, and this file already imports that module.
@@ -78,6 +85,8 @@ export interface PrerenderReport {
78
85
  readonly report: string;
79
86
  /** Client entries emitted, one chunk each. Reported so "which JS shipped?" needs no unzip. */
80
87
  readonly islands: readonly string[];
88
+ /** Surface stylesheets emitted, one file each — the CSS half of the same question. */
89
+ readonly styles: readonly string[];
81
90
  /**
82
91
  * What the service worker could not express, and what its precache manifest weighs too much of.
83
92
  *
@@ -138,12 +147,23 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
138
147
  const skipped: SkippedRoute[] = [];
139
148
  const routes: RouteStats[] = [];
140
149
  const unmeasured: UnmeasuredRoute[] = [];
150
+ // What the loop below rendered, by the path it rendered — the service worker's precache
151
+ // revisions. A `Map` and not a record, so a route path spelling a prototype member cannot answer
152
+ // with one; insertion order is never read, because `pwaRoutes` walks the route table and
153
+ // `buildPrecacheManifest` sorts its own entries by code unit.
154
+ const documents = new Map<string, RenderedDocument>();
141
155
 
142
156
  // Before the first document: a page's `data-x-entry` is a built chunk's URL, so the chunks have
143
157
  // to exist to be named. Written into `out` too — a static export is served with no process
144
158
  // behind it, so the artifact carries every byte the browser will ask for.
145
159
  const islands = await buildIslands(options.root);
146
160
  await writeIslands(islands, options.out);
161
+ // And the stylesheet every document below LINKS. Derived after the island build on purpose:
162
+ // `islandStylesPlugin` registers an island's own `.module.scss` during `Bun.build`, so a bundle
163
+ // minted before it would hash CSS the documents do not carry — and the export would publish
164
+ // pages whose `<link>` names a file the artifact does not have.
165
+ const styles = styleBundle();
166
+ await writeStyles(styles, options.out);
147
167
  // Same rule, one asset further: a browser asks for `/favicon.ico` on the first page it loads,
148
168
  // and a static export has no route to answer it — so the bytes the served surfaces would have
149
169
  // returned go into the artifact instead of leaving a 404 in every visitor's console.
@@ -165,18 +185,15 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
165
185
  // wiring exists to close. `undefined` when the app is not installable, and then no document
166
186
  // names it either.
167
187
  const pwa = await loadPwaArtifacts(options.root);
168
- // The worker and its registration script, written as FILES. A static host runs no route table,
169
- // so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the
170
- // artifact — the same promise `favicon.ico` and the icons above keep, for the asset that decides
171
- // whether the export works offline at all.
172
- const serviceWorker =
173
- pwa === undefined
174
- ? undefined
175
- : serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands });
176
- if (serviceWorker !== undefined) {
177
- await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
178
- await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
179
- }
188
+ // The registration TAG now, the worker itself after the render loop — the two halves are wanted
189
+ // at different moments and used to be taken at the same one. Every document below has to name
190
+ // `/x-sw-register.js`, and the worker's precache manifest is built from the content hash of
191
+ // those same documents, which do not exist yet: emitted here, every route's revision was the
192
+ // BUILD ID and every route's byte count was 0, so a deploy of a byte-identical site re-fetched
193
+ // everything and the precache budget could not count one byte of HTML (`precache.ts`' own
194
+ // header). `serviceWorkerHead` is the one predicate behind both, so a page can never name a
195
+ // script the export does not carry.
196
+ const swHead = pwa === undefined ? undefined : serviceWorkerHead(pwa);
180
197
  if (pwa !== undefined) {
181
198
  await Bun.write(join(options.out, WEB_MANIFEST_PATH.slice(1)), pwa.body);
182
199
  // And the icons that manifest NAMES. A static host runs no `assetRoutes()`, so every
@@ -209,7 +226,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
209
226
  runWithContext(as, () =>
210
227
  routeDocument(entry, data, {
211
228
  resolveIsland: (file: string) => islands.resolverFor(file),
212
- ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
229
+ ...(pwa === undefined ? {} : { pwaHead: pwa.head + (swHead ?? '') }),
213
230
  }),
214
231
  );
215
232
  const document = (entry: RouteEntry, data: { url: string; params: Record<string, string> }) =>
@@ -240,7 +257,17 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
240
257
  } catch (error) {
241
258
  // `renderThrowable`, never `String(error)`: this is a caught unknown, and a hostile
242
259
  // `toString` here would take the whole build down instead of one route's measurement.
243
- unmeasured.push({ path: entry.path, reason: renderThrowable(error) });
260
+ // A framework error rides along with its code, cause and fix: `checkBudgets` reports
261
+ // `X_ISLAND_PROPS_INVALID` under its own name, because that sentence — the island, the
262
+ // prop, its bytes — is the finding, and `X_BUDGET_UNMEASURED` pointing at this list was
263
+ // a second command between the author and it.
264
+ unmeasured.push({
265
+ path: entry.path,
266
+ reason: renderThrowable(error),
267
+ ...(isUltimateError(error)
268
+ ? { code: error.code, cause: error.cause, fix: error.fix }
269
+ : {}),
270
+ });
244
271
  }
245
272
  continue;
246
273
  }
@@ -274,6 +301,11 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
274
301
  hash: artifact.hash,
275
302
  bytes,
276
303
  });
304
+ // `artifact.hash` is `contentHash(html)` — the same identity that becomes this page's ETag,
305
+ // so the precache revision and the HTTP validator can never disagree about one document.
306
+ // Keyed by the FILLED path, which for a non-dynamic route is the declared one; a dynamic
307
+ // route is not precached as a single URL anyway (`buildPrecacheManifest` skips it).
308
+ documents.set(artifact.path, { revision: artifact.hash, bytes });
277
309
  // Measured from the document that was just written, so the `budgets` step compares a
278
310
  // declared budget against bytes that exist on disk rather than against a graph's estimate.
279
311
  const measured = await measureDocumentJs(artifact.html, options.out);
@@ -287,6 +319,25 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
287
319
  }
288
320
  if (heaviest !== undefined) routes.push(heaviest);
289
321
  }
322
+ // The worker, LAST: every document it precaches has now been rendered, hashed and weighed. A
323
+ // static host runs no route table, so both files go into the artifact — a
324
+ // `<script src="/x-sw-register.js">` in every document is a 404 otherwise, which is the same
325
+ // promise `favicon.ico` and the icons above keep.
326
+ const serviceWorker =
327
+ pwa === undefined
328
+ ? undefined
329
+ : serviceWorkerArtifacts({
330
+ pwa,
331
+ buildId,
332
+ routes: describeRoutes(),
333
+ islands,
334
+ styles,
335
+ documents,
336
+ });
337
+ if (serviceWorker !== undefined) {
338
+ await Bun.write(join(options.out, SERVICE_WORKER_PATH.slice(1)), serviceWorker.source);
339
+ await Bun.write(join(options.out, SW_REGISTER_PATH.slice(1)), serviceWorker.register);
340
+ }
290
341
  const stats = await writeBuildStats(options.root, { routes });
291
342
  // Written LAST and by the same call that writes the stats, so an app whose `prerender.ts` does
292
343
  // not reach `prerenderSite` produces neither — and `x verify`'s `budgets` step already reds that
@@ -313,6 +364,7 @@ export async function prerenderSite(options: PrerenderOptions): Promise<Prerende
313
364
  stats,
314
365
  report,
315
366
  islands: islands.chunks.map((chunk) => chunk.file),
367
+ styles: styles.chunks.map((chunk) => chunk.url),
316
368
  serviceWorkerWarnings: serviceWorker?.warnings ?? [],
317
369
  };
318
370
  }
@@ -0,0 +1,37 @@
1
+ // One in-flight attempt, shared — and a FAILED one not kept. `started ??= boot()` caches the
2
+ // rejection with the value, so a Postgres that refused a connection once answered every later
3
+ // call in the session with that same error and no retry was possible short of restarting the host.
4
+
5
+ /**
6
+ * A lazily made attempt that may be made again.
7
+ *
8
+ * `started()` is the half `??=` has no spelling for: it answers the attempt already in flight or
9
+ * already made, and `undefined` when nobody has asked yet — so a `close()` can stop what was
10
+ * booted without BOOTING one in order to stop it.
11
+ */
12
+ export interface RetryMemo<T> {
13
+ get(): Promise<T>;
14
+ started(): Promise<T> | undefined;
15
+ }
16
+
17
+ /**
18
+ * `start` runs at most once per SUCCESS. `@ultimat3/db`'s `createPgliteClient` states the rule this
19
+ * generalises — "a failed boot must not be cached" — and the clearing handler is attached at
20
+ * creation for the reason that matters: it therefore runs ahead of every caller's own `await`
21
+ * continuation, so by the time anyone sees the rejection the slot is already empty and the next
22
+ * call really does start a new attempt. Cleared inside the caller's `catch` instead, there is a
23
+ * window in which a second call is handed the dead promise.
24
+ */
25
+ export function retryMemo<T>(start: () => Promise<T>): RetryMemo<T> {
26
+ let attempt: Promise<T> | undefined;
27
+ return {
28
+ get(): Promise<T> {
29
+ attempt ??= start().catch((error: unknown) => {
30
+ attempt = undefined;
31
+ throw error;
32
+ });
33
+ return attempt;
34
+ },
35
+ started: (): Promise<T> | undefined => attempt,
36
+ };
37
+ }
package/src/serve.ts CHANGED
@@ -49,6 +49,8 @@ import { readMigrations } from './migrations';
49
49
  import { startOtlpExport } from './otlp-export';
50
50
  import { loadPwaArtifacts } from './pwa-artifacts';
51
51
  import type { RuntimeOverrides } from './runtime-overrides';
52
+ import { styleBundle } from './style-bundle';
53
+ import { styleRoutes } from './style-routes';
52
54
  import { serviceWorkerArtifacts } from './sw-artifacts';
53
55
  import { serviceWorkerRoutes } from './sw-routes';
54
56
 
@@ -193,7 +195,11 @@ export type StartedApp = ServedApp | MigratedApp;
193
195
  * is what fails on it.
194
196
  */
195
197
  export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
196
- const queue = await startQueue(resolveServices(options.root, options.env), options.runtime);
198
+ const queue = await startQueue(
199
+ resolveServices(options.root, options.env),
200
+ options.runtime,
201
+ options.env,
202
+ );
197
203
  try {
198
204
  const migrations = await readMigrations(options.root);
199
205
  const report = await migrate({
@@ -329,7 +335,13 @@ async function bootRoles(boot: {
329
335
  const serviceWorker =
330
336
  pwa === undefined
331
337
  ? undefined
332
- : serviceWorkerArtifacts({ pwa, buildId, routes: describeRoutes(), islands });
338
+ : serviceWorkerArtifacts({
339
+ pwa,
340
+ buildId,
341
+ routes: describeRoutes(),
342
+ islands,
343
+ styles: styleBundle(),
344
+ });
333
345
  // The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
334
346
  const mcpMount = await mountAppMcp(options.root);
335
347
  const routes: readonly Route[] = [
@@ -344,6 +356,9 @@ async function bootRoles(boot: {
344
356
  }),
345
357
  ...storageRoutes({ storage: runtime.storage }),
346
358
  ...islandRoutes(() => islands),
359
+ // The surface stylesheets the documents link. Built from the registry the `loadApp` above
360
+ // filled, so this process serves exactly the CSS it renders against.
361
+ ...styleRoutes(() => styleBundle()),
347
362
  ...appRoutes({
348
363
  buildId,
349
364
  resolveIsland: (file) => islands.resolverFor(file),
@@ -8,6 +8,7 @@ import {
8
8
  cdpUrlFrom,
9
9
  cdpUrlProblem,
10
10
  executablePathFrom,
11
+ ShotChromeMissingError,
11
12
  } from './browser-launcher';
12
13
  import { BadFlagError } from './errors';
13
14
 
@@ -21,7 +22,10 @@ const CDP_FIX = 'x shot / --cdp-url wss://cdp.example.com/session/abc';
21
22
  export interface ShotBrowserChoice {
22
23
  /** Attach here. When set, nothing about a local executable was read. */
23
24
  readonly cdpUrl?: string | undefined;
24
- /** Launch this. Already proved to exist on disk. */
25
+ /**
26
+ * Launch this. Already proved to exist on disk, and never absent alongside an absent `cdpUrl`:
27
+ * a run that reached this type has a browser, because the alternative is refused above.
28
+ */
25
29
  readonly executablePath?: string | undefined;
26
30
  }
27
31
 
@@ -31,6 +35,12 @@ export interface ShotBrowserInput {
31
35
  /** `--browser` as typed, before `PUPPETEER_EXECUTABLE_PATH` / `CHROME_PATH`. */
32
36
  readonly browserFlag?: string | undefined;
33
37
  readonly env: Readonly<Record<string, string | undefined>>;
38
+ /**
39
+ * Test seam: how a path is proved to be on disk. Injected rather than stubbed globally, so the
40
+ * probe below is asserted identically on a machine that has Chrome and on one that does not —
41
+ * an assertion whose verdict depends on the box it runs on is not an assertion.
42
+ */
43
+ readonly exists?: (path: string) => boolean;
34
44
  }
35
45
 
36
46
  /**
@@ -47,8 +57,14 @@ export interface ShotBrowserInput {
47
57
  * the flag IS read.
48
58
  * 3. **On an attach, no executable is read at all.** Checking the filesystem for a binary this run
49
59
  * will never execute is how a correct remote capture gets refused on a box with no Chrome.
60
+ * 4. **A local run with no browser anywhere is refused HERE.** `puppeteer-core` has no bundled
61
+ * browser and no default, so "nothing named" is not "the library finds its own" — it is a throw
62
+ * from inside somebody else's library, after `runShot` has already booted an embedded Postgres,
63
+ * saying ``An `executablePath` or `channel` must be specified``. The same fact is knowable from
64
+ * the environment and four filesystem probes before anything starts.
50
65
  */
51
66
  export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
67
+ const exists = input.exists ?? browserBinaryExists;
52
68
  if (input.cdpFlag !== undefined && input.browserFlag !== undefined) {
53
69
  throw new BadFlagError({
54
70
  flag: 'cdp-url',
@@ -71,8 +87,11 @@ export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
71
87
  }
72
88
  return { cdpUrl };
73
89
  }
74
- const executablePath = executablePathFrom(input.browserFlag, input.env);
75
- if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
90
+ const executablePath = executablePathFrom(input.browserFlag, input.env, exists);
91
+ // Nothing named and nothing probed: not a bad flag, because no flag was typed — a machine with no
92
+ // browser on it, which is a configuration to repair rather than a value to correct.
93
+ if (executablePath === undefined) throw new ShotChromeMissingError();
94
+ if (!exists(executablePath)) {
76
95
  throw new BadFlagError({
77
96
  flag: 'browser',
78
97
  command: 'shot',
@@ -80,5 +99,5 @@ export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
80
99
  fix: 'x shot / --browser /usr/bin/chromium',
81
100
  });
82
101
  }
83
- return executablePath === undefined ? {} : { executablePath };
102
+ return { executablePath };
84
103
  }
@@ -2,6 +2,8 @@
2
2
  // and an app. One list for every step that walks source, because two steps scanning different sets
3
3
  // means a finding one of them can never see.
4
4
 
5
+ import { hasPathSegment } from './path-segments';
6
+
5
7
  export const SOURCE_GLOBS = [
6
8
  'packages/*/src/**/*.{ts,tsx}',
7
9
  // Three packages carry an `e2e` directory beside `src`. It is shipped source by every rule that
@@ -21,7 +23,7 @@ export const SOURCE_GLOBS = [
21
23
  * root. `dist/` is build output: the sources that produced it are already in the set.
22
24
  */
23
25
  export const isVendored = (path: string): boolean =>
24
- path.includes('node_modules') || path.includes('/dist/') || path.startsWith('dist/');
26
+ hasPathSegment(path, 'node_modules') || hasPathSegment(path, 'dist');
25
27
 
26
28
  /** Emitted declarations, not authored source — a rule about authored code cannot apply to them. */
27
29
  export const isGenerated = (path: string): boolean => path.endsWith('.d.ts');
@@ -60,6 +60,17 @@ export type UnmeasuredRoute = {
60
60
  /** The DECLARED path, as `X_BUDGET_UNMEASURED`'s `at:` spells it, so the two rows join. */
61
61
  readonly path: string;
62
62
  readonly reason: string;
63
+ /**
64
+ * The throwable's own `code`, `cause` and `fix` when the render failed with an `UltimateError`
65
+ * — absent for a bare `TypeError`. Carried so the `budgets` step can report a failure that IS
66
+ * an instruction under its own code rather than under `X_BUDGET_UNMEASURED`: an island handed
67
+ * props over `ISLAND_PROPS_MAX_BYTES` throws `X_ISLAND_PROPS_INVALID` naming the prop and its
68
+ * bytes, and "run x build, its unmeasured list says why" sent the reader to a second command to
69
+ * read the sentence this build had already composed.
70
+ */
71
+ readonly code?: string;
72
+ readonly cause?: string;
73
+ readonly fix?: string;
63
74
  };
64
75
 
65
76
  /** One HTML file in the artifact, and the declared route that produced it. */
@@ -171,8 +182,17 @@ const isSkipped = (value: unknown): value is SkippedRoute =>
171
182
  typeof value['why'] === 'string' &&
172
183
  inDomain(SKIP_REASONS, value['reason']);
173
184
 
185
+ const optionalString = (value: unknown): boolean =>
186
+ value === undefined || typeof value === 'string';
187
+
188
+ /** The three coded fields are optional TOGETHER on the way in; a present one must be a string. */
174
189
  const isUnmeasured = (value: unknown): value is UnmeasuredRoute =>
175
- isRecord(value) && typeof value['path'] === 'string' && typeof value['reason'] === 'string';
190
+ isRecord(value) &&
191
+ typeof value['path'] === 'string' &&
192
+ typeof value['reason'] === 'string' &&
193
+ optionalString(value['code']) &&
194
+ optionalString(value['cause']) &&
195
+ optionalString(value['fix']);
176
196
 
177
197
  const isEmitted = (value: unknown): value is EmittedPage =>
178
198
  isRecord(value) &&
@@ -0,0 +1,124 @@
1
+ // The surface stylesheet table: the CSS a document on `site/` or `app/` carries, content-hashed
2
+ // and addressed as a FILE rather than inlined into every response. The island table's shape one
3
+ // asset over (`island-bundle.ts`), because the two answer the same question — "what does this
4
+ // document make the browser fetch, and can it keep it?" — and one mechanism is the whole point.
5
+ //
6
+ // Why it stopped being an inline `<style>`: measured against ai-maxxing on 2026-09-06, every
7
+ // `app/` document carried one 156,738-byte block — 1,336 rules from 406 module sources, byte
8
+ // identical across `/`, `/fleet`, `/fleet/[host]` and the session page — inside a response the
9
+ // pipeline sends `Cache-Control: private, no-store`. 92% of the dashboard document and 89% of the
10
+ // session document, re-sent and re-parsed on every navigation, cacheable by nothing.
11
+
12
+ // Bun ships no path API; `join` is the filesystem side of writing a chunk into a static export.
13
+ // why: Bun exposes no path API — nothing native joins a directory to a URL-shaped path.
14
+ import { join } from 'node:path';
15
+ import type { Surface } from '@ultimat3/render';
16
+ import { SURFACES } from '@ultimat3/render';
17
+ import { contentHash, stylesFor, stylesheetsRevision } from '@ultimat3/render/server';
18
+
19
+ /**
20
+ * The surfaces a stylesheet is minted for. `api/` is dropped for `documentSurfaces`' reason — it
21
+ * emits no document, so a file no `<link>` can ever name would be bytes in the export and an entry
22
+ * in the precache manifest with no reader. `shared/` stays: `x shot --island` renders an island
23
+ * that lives there, and `surfaceOf` answers `shared` for it.
24
+ */
25
+ const STYLED_SURFACES: readonly Surface[] = SURFACES.filter((surface) => surface !== 'api');
26
+
27
+ /**
28
+ * Where a surface stylesheet is served from, in `x dev`, in the container and in a static export.
29
+ * Sits beside `ISLAND_BASE_PATH`, `ICON_BASE_PATH` and `MEDIA_BASE_PATH`, and outside the dev-only
30
+ * `/_x` namespace for their reason: the URL is baked into documents a static export publishes, so
31
+ * a dev-only path would be a page that renders in `x dev` and 404s on a CDN.
32
+ */
33
+ export const STYLE_BASE_PATH = '/styles';
34
+
35
+ export interface StyleChunk {
36
+ /**
37
+ * Every surface whose documents link it, sorted. Usually one — `site/` and `app/` carry
38
+ * different modules — but an app whose only CSS is its global layer produces one byte string for
39
+ * all three, and shipping it three times would put three copies in the static export and three
40
+ * entries in the precache manifest, which has a budget.
41
+ */
42
+ readonly surfaces: readonly Surface[];
43
+ /** Immutable, content-addressed URL. What `<link rel="stylesheet">` carries. */
44
+ readonly url: string;
45
+ readonly css: string;
46
+ readonly bytes: number;
47
+ }
48
+
49
+ export interface StyleBundle {
50
+ readonly chunks: readonly StyleChunk[];
51
+ /** The `href` a document on this surface links, or `undefined` when the surface has no CSS. */
52
+ hrefFor(surface: Surface | null): string | undefined;
53
+ /** The chunk a URL names — for serving it, and for writing it into a static export. */
54
+ chunkAt(url: string): StyleChunk | undefined;
55
+ }
56
+
57
+ /**
58
+ * `stylesFor(null)` and `stylesFor('shared')` select the same sheets by construction — a `null`
59
+ * surface matches only the package sheets, which `'shared'` already carries — so the one chunk
60
+ * answers both. `island-harness.ts` is the caller that can hold a `null`.
61
+ */
62
+ const surfaceKey = (surface: Surface | null): Surface => surface ?? 'shared';
63
+
64
+ /**
65
+ * Derived, never cached across a change: `stylesheetsRevision()` moves when a sheet's rules move,
66
+ * and island CSS registers on every `buildIslands` — which `x dev` re-runs on every watcher tick.
67
+ * Memoised on that revision rather than recomputed per request, because the derivation is a filter
68
+ * over every registered sheet plus a hash of the ~150 kB it joins.
69
+ */
70
+ let memo: { readonly revision: number; readonly bundle: StyleBundle } | undefined;
71
+
72
+ export function styleBundle(): StyleBundle {
73
+ const revision = stylesheetsRevision();
74
+ if (memo !== undefined && memo.revision === revision) return memo.bundle;
75
+ const bundle = styleBundleOf(
76
+ STYLED_SURFACES.map((surface) => ({ surface, css: stylesFor(surface) })).filter(
77
+ (sheet) => sheet.css.length > 0,
78
+ ),
79
+ );
80
+ memo = { revision, bundle };
81
+ return bundle;
82
+ }
83
+
84
+ /**
85
+ * Test seam, and the shape `islandBundle` has: a table built from what the caller supplies.
86
+ *
87
+ * The URL is the content hash and nothing else — no surface in the name — because a surface is not
88
+ * a property of the BYTES. Two surfaces with identical CSS are one file, one precache entry and
89
+ * one download, which is what the name would otherwise prevent. The same `contentHash` that stamps
90
+ * an ETag, a precache revision and an island chunk: one identity for a byte string, not a fourth.
91
+ */
92
+ export function styleBundleOf(
93
+ sheets: readonly { readonly surface: Surface; readonly css: string }[],
94
+ ): StyleBundle {
95
+ const byCss = new Map<string, Surface[]>();
96
+ for (const sheet of sheets) {
97
+ const held = byCss.get(sheet.css);
98
+ if (held === undefined) byCss.set(sheet.css, [sheet.surface]);
99
+ else held.push(sheet.surface);
100
+ }
101
+ const chunks: readonly StyleChunk[] = [...byCss].map(([css, surfaces]) => ({
102
+ surfaces: [...surfaces].sort(),
103
+ url: `${STYLE_BASE_PATH}/${contentHash(css)}.css`,
104
+ css,
105
+ bytes: new TextEncoder().encode(css).byteLength,
106
+ }));
107
+ const bySurface = new Map(
108
+ chunks.flatMap((chunk) => chunk.surfaces.map((surface) => [surface, chunk] as const)),
109
+ );
110
+ const byUrl = new Map(chunks.map((chunk) => [chunk.url, chunk]));
111
+ return {
112
+ chunks,
113
+ hrefFor: (surface: Surface | null): string | undefined =>
114
+ bySurface.get(surfaceKey(surface))?.url,
115
+ chunkAt: (url: string): StyleChunk | undefined => byUrl.get(url),
116
+ };
117
+ }
118
+
119
+ /** Write every surface stylesheet under a static export, at the URL the documents already carry. */
120
+ export async function writeStyles(bundle: StyleBundle, out: string): Promise<void> {
121
+ for (const chunk of bundle.chunks) {
122
+ await Bun.write(join(out, chunk.url.slice(1)), chunk.css);
123
+ }
124
+ }
package/src/style-csp.ts CHANGED
@@ -1,19 +1,21 @@
1
- // Every inline `<style>` body a served process can put in a document, as the `style-src` sources
2
- // that admit it. Read from the stylesheet registry at boot rather than checked in as a constant:
3
- // importing the app's modules IS what fills that registry, so a committed hash would describe a
4
- // stylesheet the document no longer carries — and the CSP would block the framework's own CSS.
1
+ // Every inline `<style>` body a served process can still put in a document, as the `style-src`
2
+ // sources that admit it. The caller names them, because the caller is what knows which documents
3
+ // it mounted.
4
+ //
5
+ // The app's OWN CSS is no longer among them, `As of 2026-09-06`: a surface stylesheet is served as
6
+ // a content-hashed file (`style-bundle.ts`) and a same-origin `<link>` is admitted by the `'self'`
7
+ // already in `@ultimat3/http`'s `style-src`. This function used to hash `stylesFor(surface)` for
8
+ // all four surfaces — 157 kB of CSS hashed at boot to admit a block that is not emitted any more,
9
+ // which is a rule describing a document nobody serves. So a production boot (`serve.ts`, which
10
+ // passes no extras) now extends `style-src` with nothing at all.
5
11
 
6
12
  import { cspHashSource } from '@ultimat3/http';
7
- import { SURFACES } from '@ultimat3/render';
8
- import { stylesFor } from '@ultimat3/render/server';
9
13
 
10
14
  /**
11
- * Call AFTER `loadApp`. One hash per distinct body: `stylesFor` is what `dev-render.ts` puts in
12
- * the tag, per surface, so hashing the same call is the only way the two cannot drift. `extra`
13
- * carries the documents this package does not render — `/_x`'s shell — because the caller is what
14
- * knows which of them it mounted.
15
+ * Call with the inline bodies THIS process emits. `x dev` passes the `/_x` shell's stylesheet and
16
+ * the screenshot harness's frame style — documents this package renders itself, which no app
17
+ * surface can see.
15
18
  */
16
- export function inlineStyleSources(extra: readonly string[] = []): readonly string[] {
17
- const bodies = [...SURFACES.map((surface) => stylesFor(surface)), ...extra];
19
+ export function inlineStyleSources(bodies: readonly string[] = []): readonly string[] {
18
20
  return [...new Set(bodies.filter((body) => body.length > 0).map(cspHashSource))].sort();
19
21
  }