@ultimat3/cli 20.1.6 → 20.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "20.1.6",
3
+ "version": "20.2.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,34 +37,34 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "20.1.6",
41
- "@ultimat3/admin": "20.1.6",
42
- "@ultimat3/ai": "20.1.6",
43
- "@ultimat3/auth": "20.1.6",
44
- "@ultimat3/cache": "20.1.6",
45
- "@ultimat3/core": "20.1.6",
46
- "@ultimat3/db": "20.1.6",
47
- "@ultimat3/entity": "20.1.6",
48
- "@ultimat3/flags": "20.1.6",
49
- "@ultimat3/http": "20.1.6",
50
- "@ultimat3/i18n": "20.1.6",
51
- "@ultimat3/jobs": "20.1.6",
52
- "@ultimat3/mail": "20.1.6",
53
- "@ultimat3/manifest": "20.1.6",
54
- "@ultimat3/mcp": "20.1.6",
55
- "@ultimat3/money": "20.1.6",
56
- "@ultimat3/notify": "20.1.6",
57
- "@ultimat3/policy": "20.1.6",
58
- "@ultimat3/pwa": "20.1.6",
59
- "@ultimat3/query": "20.1.6",
60
- "@ultimat3/realtime": "20.1.6",
61
- "@ultimat3/render": "20.1.6",
62
- "@ultimat3/schema": "20.1.6",
63
- "@ultimat3/scraping": "20.1.6",
64
- "@ultimat3/seo": "20.1.6",
65
- "@ultimat3/storage": "20.1.6",
66
- "@ultimat3/testing": "20.1.6",
67
- "@ultimat3/time": "20.1.6",
40
+ "@ultimat3/action": "20.2.0",
41
+ "@ultimat3/admin": "20.2.0",
42
+ "@ultimat3/ai": "20.2.0",
43
+ "@ultimat3/auth": "20.2.0",
44
+ "@ultimat3/cache": "20.2.0",
45
+ "@ultimat3/core": "20.2.0",
46
+ "@ultimat3/db": "20.2.0",
47
+ "@ultimat3/entity": "20.2.0",
48
+ "@ultimat3/flags": "20.2.0",
49
+ "@ultimat3/http": "20.2.0",
50
+ "@ultimat3/i18n": "20.2.0",
51
+ "@ultimat3/jobs": "20.2.0",
52
+ "@ultimat3/mail": "20.2.0",
53
+ "@ultimat3/manifest": "20.2.0",
54
+ "@ultimat3/mcp": "20.2.0",
55
+ "@ultimat3/money": "20.2.0",
56
+ "@ultimat3/notify": "20.2.0",
57
+ "@ultimat3/policy": "20.2.0",
58
+ "@ultimat3/pwa": "20.2.0",
59
+ "@ultimat3/query": "20.2.0",
60
+ "@ultimat3/realtime": "20.2.0",
61
+ "@ultimat3/render": "20.2.0",
62
+ "@ultimat3/schema": "20.2.0",
63
+ "@ultimat3/scraping": "20.2.0",
64
+ "@ultimat3/seo": "20.2.0",
65
+ "@ultimat3/storage": "20.2.0",
66
+ "@ultimat3/testing": "20.2.0",
67
+ "@ultimat3/time": "20.2.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/budgets.ts CHANGED
@@ -10,7 +10,7 @@ import { existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { ERROR_DOCS_URL } from '@ultimat3/core';
12
12
  import type { Manifest, RouteFact } from '@ultimat3/manifest';
13
- import { formatBytes, parseByteBudget } from '@ultimat3/render';
13
+ import { formatBytes, parseByteBudget, themeScriptBody } from '@ultimat3/render';
14
14
  import type { Finding } from './output';
15
15
  import type { UnmeasuredRoute } from './static-report';
16
16
  import { SW_REGISTER_PATH } from './sw-artifacts';
@@ -253,6 +253,18 @@ export interface MeasuredJs {
253
253
  */
254
254
  export const FRAMEWORK_SCRIPTS: ReadonlySet<string> = new Set([SW_REGISTER_PATH]);
255
255
 
256
+ /**
257
+ * The inline bodies the boot puts in EVERY document, keyed by the same argument as
258
+ * `FRAMEWORK_SCRIPTS`: the author cannot edit, delete or move them, so charging one against a
259
+ * `0kb` budget is a finding nobody can act on. Today that is the no-flash theme script, in each
260
+ * of its three fallbacks (`theme-boot.ts` uses `themeScript`'s defaults for everything else, so
261
+ * these are the exact strings a document carries). The hydration runtime is NOT here — it exists
262
+ * only when the page ships an island, and is the cost of the app's own interactivity.
263
+ */
264
+ export const FRAMEWORK_INLINE_SCRIPTS: ReadonlySet<string> = new Set(
265
+ (['light', 'dark', 'system'] as const).map((fallback) => themeScriptBody({ fallback })),
266
+ );
267
+
256
268
  /**
257
269
  * What a rendered document actually makes the browser execute: the bytes of every inline script
258
270
  * the parser will run, the size of every file a `src` points at, and the size of every island
@@ -301,7 +313,10 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
301
313
  if (carriesJson(attrs)) continue;
302
314
  const src = SRC_ATTR.exec(attrs)?.groups?.['src'];
303
315
  if (src === undefined) {
304
- jsBytes += Buffer.byteLength(match.groups?.['body'] ?? '', 'utf8');
316
+ const body = match.groups?.['body'] ?? '';
317
+ const bytes = Buffer.byteLength(body, 'utf8');
318
+ if (FRAMEWORK_INLINE_SCRIPTS.has(body)) frameworkBytes += bytes;
319
+ else jsBytes += bytes;
305
320
  continue;
306
321
  }
307
322
  await weigh(src);
package/src/cmd-dev.ts CHANGED
@@ -40,6 +40,7 @@ import { describeServices, reportedUrls, resolveServices } from './dev-services'
40
40
  import { storageRoutes } from './dev-storage';
41
41
  import { createTraceRecorder } from './dev-traces';
42
42
  import { watchTree } from './dev-watch-tree';
43
+ import { errorPageStyleSources } from './error-page-csp';
43
44
  import { intFlagOr, PORT_RANGE } from './flag-number';
44
45
  import { holdUntilShutdown } from './hold';
45
46
  import type { IslandBundle } from './island-bundle';
@@ -59,6 +60,7 @@ import { styleBundle } from './style-bundle';
59
60
  import { styleRoutes } from './style-routes';
60
61
  import { serviceWorkerArtifacts } from './sw-artifacts';
61
62
  import { serviceWorkerRoutes } from './sw-routes';
63
+ import { loadThemeMode, themeBoot } from './theme-boot';
62
64
 
63
65
  const DEFAULT_PORT = 3000;
64
66
 
@@ -187,6 +189,8 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
187
189
  // name it. `undefined` for an app that is not installable, and then nothing is mounted and no
188
190
  // document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
189
191
  const pwa = await loadPwaArtifacts(options.root);
192
+ const theme = themeBoot(await loadThemeMode(options.root));
193
+ const errorStyles = await errorPageStyleSources(options.root);
190
194
  // Built once at boot, from this process's own route table and island bundle. `x dev` rebuilds
191
195
  // islands on the watcher tick and the worker is NOT rebuilt with them, deliberately: a service
192
196
  // worker that changes under a page it already controls is the update path, and re-emitting one
@@ -241,6 +245,7 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
241
245
  ...appRoutes({
242
246
  buildId,
243
247
  resolveIsland: (file) => state.islands.resolverFor(file),
248
+ themeHead: theme.head,
244
249
  ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
245
250
  }),
246
251
  ];
@@ -264,23 +269,17 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
264
269
  runtime,
265
270
  routes,
266
271
  env: options.env,
267
- // Read from `app.config.ts` rather than threaded through `DevOptions`: it is the app's own
268
- // declaration, and `x dev` and `serve.ts` must not be able to disagree about where the app's
269
- // sign-in page is.
272
+ // Read from `app.config.ts` rather than threaded through `DevOptions`: `x dev` and `serve.ts`
273
+ // must not be able to disagree about where the app's sign-in page is.
270
274
  signInPath: await loadSignInPath(options.root),
271
- // The same seam `serve.ts` passes: the app's own error page is a FILE, so the root is what
272
- // `startWeb` needs to find one.
275
+ // The same seam `serve.ts` passes: the app's own error page is a FILE under this root.
273
276
  root: options.root,
274
- // The documents this process serves that the app did not write — the `/_x` shell and the
275
- // screenshot harness's frame. The app's OWN surfaces need no entry any more: their CSS is a
276
- // content-hashed file `'self'` already admits (`style-bundle.ts`). `x dev` sends the policy
277
- // report-only, so an uncovered `<style>` here is a console report rather than a blank page —
278
- // which is how this reached production.
279
- inlineStyles: [await devShellStyle(), FRAME_STYLE],
280
- // The fourth surface, and the only one an author sees without leaving the page they broke:
281
- // the overlay renders this request's own loops under the error it is already showing.
282
- // `serve.ts` boots through the same `startRoles` and passes nothing, so production has no
283
- // diagnostic to call.
277
+ // The `/_x` shell, the harness's frame, and the app's own error pages — the inline bodies this
278
+ // process serves; the app's surfaces are content-hashed files `'self'` admits.
279
+ inlineStyles: [await devShellStyle(), FRAME_STYLE, ...errorStyles],
280
+ inlineScripts: [theme.cspSource],
281
+ // The overlay renders this request's own loops under the error it is already showing.
282
+ // `serve.ts` boots through the same `startRoles` and passes nothing (axiom 6).
284
283
  devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
285
284
  statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
286
285
  // The read-replica scope, opened per request. Absent for every app that names no
package/src/cmd-mcp.ts CHANGED
@@ -1,4 +1,4 @@
1
- // `x mcp serve` — the framework's dev MCP server over stdio or HTTP. The 15 tools, the JSON-RPC
1
+ // `x mcp serve` — the framework's dev MCP server over stdio or HTTP. The 18 tools, the JSON-RPC
2
2
  // dispatch, both transports and the structural SQL refusals all come from `@ultimat3/mcp`; the CLI
3
3
  // supplies only the app, the caller and the socket. A tool answered here would be a second answer
4
4
  // to a question the framework already answers.
@@ -117,7 +117,7 @@ export function startMcpHttp(host: CliMcpServer, port: number): McpHttpServer {
117
117
  * What the session reports when it is over — on STDERR, which is the half this file's header
118
118
  * claimed and did not have. `dispatch` renders a `CommandResult` only after `run` resolves, and
119
119
  * this resolves when the peer closes stdin, so nothing lands mid-session; but fd 1 under this
120
- * transport carries JSON-RPC frames, and `✓ mcp stdio serving 15 tools` arriving on it after the
120
+ * transport carries JSON-RPC frames, and `✓ mcp stdio serving 18 tools` arriving on it after the
121
121
  * loop is a malformed frame to a peer still draining, and a second document under `--json`.
122
122
  *
123
123
  * Its own function so the addressing is testable without a live peer: `serveStdio` resolves only
package/src/cmd-shot.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { mkdirSync } from 'node:fs';
9
9
  import { join, resolve } from 'node:path';
10
10
  import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
11
- import type { ColorScheme, ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
11
+ import type { ColorScheme, ScrapeDriver, ScrapePage, ScrapeSession } from '@ultimat3/scraping';
12
12
  import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
13
13
  import { requireAppRoot } from './app-root';
14
14
  import { appBrowser } from './browser-launcher';
@@ -197,6 +197,15 @@ export interface ShotRun {
197
197
  */
198
198
  readonly colorScheme?: ColorScheme | undefined;
199
199
  readonly now?: (() => Date) | undefined;
200
+ /**
201
+ * Something to do with the page AFTER the islands settled and BEFORE the picture — `ui.inspect`
202
+ * reads the DOM here, on the one navigation the picture already paid for. `settle` re-runs the
203
+ * island poll (an action that mounts something changes the count the verdict reports); the
204
+ * caller who never calls it gets the count from the first settle.
205
+ */
206
+ readonly act?:
207
+ | ((page: ScrapePage, settle: () => Promise<IslandCount | null>) => Promise<void>)
208
+ | undefined;
200
209
  }
201
210
 
202
211
  /** Nothing here may replace the failure that caused it, so a teardown throw is swallowed. */
@@ -239,10 +248,15 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
239
248
  // The same budget again, and deliberately no new flag: `settleMs` is the deadline at which the
240
249
  // runtime CALLS `import()`, so a mount gets exactly as long to settle as the runtime got to
241
250
  // start it — and `--settle 0`, which asks for no wait, still gets none.
242
- const islands = await settleIslands(probe, {
243
- windowMs: options.settleMs,
244
- pollMs: SETTLE_POLL_MS,
245
- });
251
+ const settle = (): Promise<IslandCount | null> =>
252
+ settleIslands(probe, { windowMs: options.settleMs, pollMs: SETTLE_POLL_MS });
253
+ let islands = await settle();
254
+ if (options.act !== undefined) {
255
+ await options.act(page, async () => {
256
+ islands = await settle();
257
+ return islands;
258
+ });
259
+ }
246
260
  const bytes = await page.screenshot({ fullPage: options.fullPage });
247
261
  // Read AFTER the capture, so an error logged while the page settled is in the verdict that
248
262
  // ships with the picture it explains.
package/src/dev-render.ts CHANGED
@@ -65,6 +65,12 @@ export interface DocumentOptions {
65
65
  * boot knows it, the renderer cannot ask.
66
66
  */
67
67
  readonly pwaHead?: string;
68
+ /**
69
+ * The no-flash theme `<script>` from `theme-boot.ts`, or absent for a caller that renders no
70
+ * documents a browser paints. Document-level for `pwaHead`'s reason — the same tag on every page,
71
+ * decided by `app.config.ts`, which the boot read and the renderer cannot.
72
+ */
73
+ readonly themeHead?: string;
68
74
  }
69
75
 
70
76
  export interface DevRenderOptions extends DocumentOptions {
@@ -99,7 +105,9 @@ const headFor = async (
99
105
  await entry.config.meta(metaContextFor(ctx, data)),
100
106
  seoRenderers({ path: new URL(ctx.url).pathname }),
101
107
  ),
102
- ) + (options.pwaHead ?? '');
108
+ ) +
109
+ (options.themeHead ?? '') +
110
+ (options.pwaHead ?? '');
103
111
 
104
112
  /**
105
113
  * `<link rel="stylesheet">` for the surface's own stylesheets, or nothing at all when the surface
package/src/dev-roles.ts CHANGED
@@ -100,6 +100,8 @@ export interface StartRolesOptions {
100
100
  * serves: that policy is what rendered every deployed app completely unstyled.
101
101
  */
102
102
  readonly inlineStyles?: readonly string[];
103
+ /** `script-src` sources beyond the hydration runtime's — the theme boot's hash. */
104
+ readonly inlineScripts?: readonly string[];
103
105
  /**
104
106
  * Non-fatal findings the browser overlay shows next to an error, for the request being answered.
105
107
  * Only `x dev` supplies one — `serve.ts` boots through this same function and omits it, so a
@@ -321,16 +323,14 @@ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): S
321
323
  rateLimit: { scope: store?.scope ?? 'process' },
322
324
  // Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
323
325
  // nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
324
- // function of that body. Read after `loadApp` — importing the app IS what registered them.
325
- // BOTH directives, and the script half is the one that was missing: the hydration runtime
326
- // is emitted inline in every document that carries an island, so `script-src 'self'` meant
327
- // no island booted anywhere the policy is enforced — which is every container, and never
328
- // `x dev`, where it is report-only.
326
+ // function of that body. BOTH directives: the hydration runtime is emitted inline in every
327
+ // document that carries an island, so `script-src 'self'` meant no island booted anywhere
328
+ // the policy is enforced — every container, and never `x dev`, where it is report-only.
329
329
  security: {
330
330
  csp: {
331
331
  extend: {
332
332
  'style-src': inlineStyleSources(options.inlineStyles ?? []),
333
- 'script-src': inlineScriptSources(),
333
+ 'script-src': inlineScriptSources(options.inlineScripts ?? []),
334
334
  },
335
335
  },
336
336
  },
@@ -157,6 +157,17 @@ export const CLI_OWNED_ERROR_CODES = [
157
157
  // `ui.shot` (the dev MCP server): a route it will not photograph, and why.
158
158
  'X_UI_SHOT_ROUTE_UNKNOWN',
159
159
  'X_UI_SHOT_ROUTE_UNBUDGETED',
160
+ // `ui.interact`: the four ways a step list is refused. All four REFUSE rather than trim or skip
161
+ // — a step dropped, a keystroke swallowed or a navigation followed changes what the picture is of.
162
+ 'X_UI_INTERACT_STEPS_INVALID',
163
+ 'X_UI_INTERACT_SECRET_FIELD',
164
+ 'X_UI_INTERACT_LEFT_APP',
165
+ 'X_UI_INTERACT_STEP_FAILED',
166
+ // `ui.diff`: the three ways two captures fail to become a comparison. The path gate is what
167
+ // lets a file-reading tool sit under `dev:read` — it reads `.x/shot/` and nothing else.
168
+ 'X_UI_DIFF_PATH_OUTSIDE',
169
+ 'X_UI_DIFF_FILE_MISSING',
170
+ 'X_UI_DIFF_SIZE_MISMATCH',
160
171
  // `x shot --island` — one code per way a component's named state fails to become a picture.
161
172
  // The last of the four is the one that gates: it is checked against the expansion computed
162
173
  // before a browser existed, so a capture loop that swallowed a failure cannot exit 0.
@@ -300,8 +311,15 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
300
311
  X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
301
312
  X_PACKAGE_DUPLICATED: 'two copies of one registry-holding framework package are installed',
302
313
  X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
303
- X_UI_SHOT_ROUTE_UNKNOWN: 'ui.shot was asked for a path no route answers',
304
- X_UI_SHOT_ROUTE_UNBUDGETED: 'ui.shot refused a route that declares no budget.js',
314
+ X_UI_SHOT_ROUTE_UNKNOWN: 'a ui.* tool was asked for a path no route answers',
315
+ X_UI_SHOT_ROUTE_UNBUDGETED: 'a ui.* tool refused a route that declares no budget.js',
316
+ X_UI_INTERACT_STEPS_INVALID: 'a ui.interact step list is over its bounds or malformed',
317
+ X_UI_INTERACT_SECRET_FIELD: 'ui.interact refused to type into a password field',
318
+ X_UI_INTERACT_LEFT_APP: 'a ui.interact step navigated off the dev server origin',
319
+ X_UI_INTERACT_STEP_FAILED: 'a ui.interact step raised a scraping error',
320
+ X_UI_DIFF_PATH_OUTSIDE: 'ui.diff was handed a path that does not resolve inside .x/shot/',
321
+ X_UI_DIFF_FILE_MISSING: 'ui.diff was handed a capture that is not on disk',
322
+ X_UI_DIFF_SIZE_MISMATCH: 'ui.diff was handed two captures of different sizes',
305
323
  X_SHOT_ISLAND_STATES_EMPTY: 'an island states file declares no manifest',
306
324
  X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
307
325
  X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
@@ -0,0 +1,50 @@
1
+ // The `style-src` sources that admit an app's own error pages. `error-pages.ts` serves
2
+ // `apps/web/site/errors/<status>.html` verbatim, and such a page is a self-contained document —
3
+ // it carries its own `<style>`, because the app's stylesheet bundle is not linked from it. Under
4
+ // the enforced policy a container sends, `style-src 'self' <overlay hash>` blocked that block and
5
+ // the page rendered unstyled; `x dev` sends the policy report-only, so the same page looked fine
6
+ // on every author's machine. Hashed at boot, like the hydration runtime: a hash is a function of
7
+ // the body, and these files do not change while a container runs.
8
+
9
+ // why: Bun exposes no path-join primitive, and the directory is app-root-relative — the same
10
+ // necessity `error-pages.ts` records.
11
+ import { join } from 'node:path';
12
+ import { cspHashSource } from '@ultimat3/http';
13
+ import { ERROR_PAGE_DIR } from './error-pages';
14
+
15
+ /**
16
+ * `<style>` bodies, in document order, exactly as the browser will hash them: the text between
17
+ * the tags, untrimmed. A trimmed body hashes to a different value and admits nothing.
18
+ */
19
+ export function inlineStyleBodies(html: string): readonly string[] {
20
+ const bodies: string[] = [];
21
+ const pattern = /<style(?:\s[^>]*)?>([\s\S]*?)<\/style>/gi;
22
+ for (let match = pattern.exec(html); match !== null; match = pattern.exec(html)) {
23
+ bodies.push(match[1] ?? '');
24
+ }
25
+ return bodies;
26
+ }
27
+
28
+ /**
29
+ * One hash per distinct `<style>` body across every error page the app ships. Read once at boot:
30
+ * a page an author drops in while `x dev` runs is served (the reader is per request) but not yet
31
+ * admitted, which the report-only policy there turns into a console report, not a blank page.
32
+ */
33
+ export async function errorPageStyleSources(root: string): Promise<readonly string[]> {
34
+ const dir = join(root, ERROR_PAGE_DIR);
35
+ const sources = new Set<string>();
36
+ for (const name of await htmlFilesIn(dir)) {
37
+ const html = await Bun.file(join(dir, name)).text();
38
+ for (const body of inlineStyleBodies(html)) sources.add(cspHashSource(body));
39
+ }
40
+ return [...sources].sort();
41
+ }
42
+
43
+ /** `Bun.Glob.scan` throws `ENOENT` on a missing directory, and no error pages is the common case. */
44
+ async function htmlFilesIn(dir: string): Promise<readonly string[]> {
45
+ try {
46
+ return (await Array.fromAsync(new Bun.Glob('*.html').scan({ cwd: dir }))).sort();
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
package/src/mcp-errors.ts CHANGED
@@ -55,9 +55,28 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
55
55
  X_PACKAGE_DUPLICATED:
56
56
  'x i18n check --json # the finding names both copies and the package.json to pin',
57
57
  X_SHOT_BROWSER_MISSING: 'bun add -d puppeteer-core',
58
- X_UI_SHOT_ROUTE_UNKNOWN: 'x routes --json # then ui.shot with one of its path values',
58
+ X_UI_SHOT_ROUTE_UNKNOWN:
59
+ 'x routes --json # then call the ui.* tool with one of its path values',
59
60
  X_UI_SHOT_ROUTE_UNBUDGETED:
60
61
  "x build --target static --json && x verify --only budgets --json # after declaring budget: { js: '<n>kb' } in the route file",
62
+ // The four `ui.interact` refusals. None can be repaired by a command — each is a change to the
63
+ // step list the agent sends — so each names the command that SHOWS what a valid resend needs.
64
+ X_UI_INTERACT_STEPS_INVALID:
65
+ 'x help shot --json # then resend ui.interact with at most 12 one-key steps, type.text under 500 chars and wait under 5000 ms',
66
+ X_UI_INTERACT_SECRET_FIELD:
67
+ 'x shot --all-islands --json # or --island <name>: a declared state renders the filled form without the secret ever being typed',
68
+ X_UI_INTERACT_LEFT_APP:
69
+ 'x routes --json # then resend ui.interact with steps that stay on one of its paths',
70
+ X_UI_INTERACT_STEP_FAILED:
71
+ 'x routes --json # then run ui.inspect on the route first and copy a selector it reports with count >= 1',
72
+ // The three `ui.diff` codes. Every capture it can compare was written by `x shot` or a `ui.*`
73
+ // tool under `.x/shot/`, so the runnable half is the command that writes one there.
74
+ X_UI_DIFF_PATH_OUTSIDE:
75
+ 'x shot / --json # then pass the image path it answers, relative to the app root: ui.diff reads .x/shot/ and nothing else',
76
+ X_UI_DIFF_FILE_MISSING:
77
+ 'x shot / --json # then diff the image path it answers; a capture ui.shot wrote is listed in its own answer',
78
+ X_UI_DIFF_SIZE_MISMATCH:
79
+ 'x shot / --json # photograph both captures at one viewport with one fullPage setting, then diff those two',
61
80
  // The four island-capture codes. Each one's real repair is an edit to the app's own states file
62
81
  // or component, which no command can perform — so each names the command that REPRODUCES it with
63
82
  // the file and the reason attached, which is the runnable half.
@@ -0,0 +1,141 @@
1
+ // `ui.diff` — the one `ui.*` tool with no browser in it. Two PNGs the others wrote, decoded through
2
+ // `@ultimat3/core`'s raw-pixel seam, compared by `ui-diff.ts`, and written back as a third PNG
3
+ // beside the second. No dependency: the seam already reads and writes 8-bit RGBA, and a capture
4
+ // that arrives in another PNG shape (Chrome writes RGB when a page has no transparency) is
5
+ // normalised through `transformImageBytes`, whose encoder emits the one shape the seam reads.
6
+ //
7
+ // The path gate is what makes `dev:read` defensible for a tool that opens files: `before`, `after`
8
+ // and `out` are relative to the app root and must resolve — lexically AND through any symlink —
9
+ // inside `.x/shot/`, the directory only `x shot` and the `ui.*` tools write. A token that may read
10
+ // the route table may read the pictures of those routes; it may not read `.env` through a tool
11
+ // that says "diff".
12
+
13
+ // why: Bun has no realpath of its own, and a symlink under .x/shot/ pointing out of it is the
14
+ // one path the lexical check cannot see.
15
+ import { realpath } from 'node:fs/promises';
16
+ // why: Bun exposes no path primitives; the gate is a resolve-then-prefix check on joined paths.
17
+ import { dirname, resolve, sep } from 'node:path';
18
+ import type { Raster } from '@ultimat3/core';
19
+ import {
20
+ decodeImage,
21
+ encodeImage,
22
+ ImageUnsupportedError,
23
+ transformImageBytes,
24
+ UltimateError,
25
+ } from '@ultimat3/core';
26
+ import type { UiDiffInput, UiDiffResult } from '@ultimat3/mcp';
27
+ import { SHOT_DIR } from './shot-server';
28
+ import { changedPercent, diffPixels } from './ui-diff';
29
+
30
+ export interface DiffDeps {
31
+ readonly root: string;
32
+ }
33
+
34
+ const SHOT_FIX =
35
+ 'x shot / --json # then pass the image path it answers, relative to the app root: ui.diff reads .x/shot/ and nothing else';
36
+
37
+ /** `true` when `path` is `dir` or lies under it — a prefix check on whole segments, never on chars. */
38
+ const under = (path: string, dir: string): boolean => path === dir || path.startsWith(dir + sep);
39
+
40
+ /**
41
+ * The lexical half of the gate: `root/<relative>` resolved, then held under `root/.x/shot`. An
42
+ * absolute `relative` resolves to itself, and `..` segments resolve away, so both leave through
43
+ * the same refusal.
44
+ */
45
+ export function shotPath(root: string, relative: string, field: string): string {
46
+ const shotDir = resolve(root, SHOT_DIR);
47
+ const path = resolve(root, relative);
48
+ if (!under(path, shotDir)) {
49
+ throw new UltimateError({
50
+ code: 'X_UI_DIFF_PATH_OUTSIDE',
51
+ cause: `${field} resolves to ${path}, which is not inside ${shotDir}`,
52
+ fix: SHOT_FIX,
53
+ meta: { field, path, shotDir },
54
+ });
55
+ }
56
+ return path;
57
+ }
58
+
59
+ /** The symlink half: the file's real location is held under the shot directory's real location. */
60
+ async function readCapture(root: string, relative: string, field: string): Promise<Uint8Array> {
61
+ const path = shotPath(root, relative, field);
62
+ const file = Bun.file(path);
63
+ if (!(await file.exists())) {
64
+ throw new UltimateError({
65
+ code: 'X_UI_DIFF_FILE_MISSING',
66
+ cause: `${field} names ${path}, and there is no file there`,
67
+ fix: 'x shot / --json # then diff the image path it answers; a capture ui.shot wrote is listed in its own answer',
68
+ meta: { field, path },
69
+ });
70
+ }
71
+ const real = await realpath(path);
72
+ const shotDir = await realpath(resolve(root, SHOT_DIR));
73
+ if (!under(real, shotDir)) {
74
+ throw new UltimateError({
75
+ code: 'X_UI_DIFF_PATH_OUTSIDE',
76
+ cause: `${field} is a link to ${real}, which is not inside ${shotDir}`,
77
+ fix: SHOT_FIX,
78
+ meta: { field, path, real, shotDir },
79
+ });
80
+ }
81
+ return file.bytes();
82
+ }
83
+
84
+ /**
85
+ * The seam reads 8-bit RGBA and nothing else. A PNG in any other shape — Chrome's RGB when the page
86
+ * is opaque, a palette from an optimiser — goes once through Bun's codecs, which always write the
87
+ * shape the seam reads. Only `imageUnsupported` is retried that way: a truncated file is a
88
+ * truncated file in either decoder.
89
+ */
90
+ export async function decodeCapture(bytes: Uint8Array): Promise<Raster> {
91
+ try {
92
+ return decodeImage(bytes);
93
+ } catch (error) {
94
+ if (!(error instanceof ImageUnsupportedError)) throw error;
95
+ return decodeImage(await transformImageBytes(bytes, { format: 'png' }));
96
+ }
97
+ }
98
+
99
+ /** Eight hex digits of the path's 64-bit hash: enough to keep two diffs of one `after` apart. */
100
+ export const hash8 = (text: string): string =>
101
+ Bun.hash(text).toString(16).padStart(16, '0').slice(0, 8);
102
+
103
+ export async function diffShots(deps: DiffDeps, input: UiDiffInput): Promise<UiDiffResult> {
104
+ const { root } = deps;
105
+ // The output path is gated BEFORE any decoding: a refusal should cost nothing, and `out` is the
106
+ // one path this tool writes, so it is the one that most needs holding under `.x/shot/`.
107
+ const afterPath = shotPath(root, input.after, 'after');
108
+ const diff =
109
+ input.out === undefined
110
+ ? resolve(dirname(afterPath), `diff-${hash8(input.before)}.png`)
111
+ : shotPath(root, input.out, 'out');
112
+ const [before, after] = await Promise.all([
113
+ readCapture(root, input.before, 'before').then(decodeCapture),
114
+ readCapture(root, input.after, 'after').then(decodeCapture),
115
+ ]);
116
+ if (before.width !== after.width || before.height !== after.height) {
117
+ throw new UltimateError({
118
+ code: 'X_UI_DIFF_SIZE_MISMATCH',
119
+ cause: `before is ${before.width}x${before.height} and after is ${after.width}x${after.height}; a diff needs one size`,
120
+ fix: 'x shot / --json # photograph both captures at one viewport with one fullPage setting, then diff those two',
121
+ meta: {
122
+ before: { width: before.width, height: before.height },
123
+ after: { width: after.width, height: after.height },
124
+ },
125
+ });
126
+ }
127
+ const { width, height } = after;
128
+ const result = diffPixels(before.pixels, after.pixels, width, height, input.threshold);
129
+ await Bun.write(diff, encodeImage({ width, height, pixels: result.diffRgba }));
130
+ return {
131
+ ok: true,
132
+ before: input.before,
133
+ after: input.after,
134
+ width,
135
+ height,
136
+ changedPixels: result.changedPixels,
137
+ changedPercent: changedPercent(result.changedPixels, width, height),
138
+ changedBox: result.changedBox,
139
+ diff,
140
+ };
141
+ }