@ultimat3/cli 20.1.6 → 20.2.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.
- package/CLAUDE.md +1 -0
- package/package.json +29 -29
- package/src/budgets.ts +17 -2
- package/src/cmd-dev.ts +14 -15
- package/src/cmd-mcp.ts +2 -2
- package/src/cmd-shot.ts +49 -9
- package/src/dev-render.ts +9 -1
- package/src/dev-roles.ts +6 -6
- package/src/error-codes.ts +20 -2
- package/src/error-page-csp.ts +50 -0
- package/src/island-bundle.ts +6 -1
- package/src/island-solid-dedupe.ts +108 -0
- package/src/mcp-errors.ts +20 -1
- package/src/mcp-ui-diff.ts +141 -0
- package/src/mcp-ui-inspect.ts +183 -0
- package/src/mcp-ui-interact.ts +304 -0
- package/src/mcp-ui.ts +53 -15
- package/src/output.ts +1 -1
- package/src/prerender.ts +3 -0
- package/src/scaffold-typecheck.ts +5 -0
- package/src/script-csp.ts +5 -2
- package/src/serve.ts +8 -0
- package/src/shot-theme.ts +52 -0
- package/src/templates/index.ts +5 -1
- package/src/templates/scaffold-app.ts +14 -155
- package/src/templates/scaffold-dashboard-bare.ts +182 -0
- package/src/templates/scaffold-dashboard-example.ts +326 -0
- package/src/templates/scaffold-dashboard-shared.ts +77 -0
- package/src/templates/scaffold-dashboard.ts +27 -0
- package/src/templates/scaffold-errors.ts +128 -0
- package/src/templates/scaffold-i18n.ts +46 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/templates/scaffold-shell.ts +461 -0
- package/src/templates/scaffold-site.ts +287 -0
- package/src/theme-boot.ts +59 -0
- package/src/ui-diff.ts +90 -0
- package/src/ui-inspect-probe.ts +131 -0
package/CLAUDE.md
CHANGED
|
@@ -1261,6 +1261,7 @@ missing.
|
|
|
1261
1261
|
| File | Job |
|
|
1262
1262
|
|---|---|
|
|
1263
1263
|
| `island-bundle.ts` | discover `*.island.tsx`, build each as its own entry point, hash it, resolve a page's specifier to its URL |
|
|
1264
|
+
| `island-solid-dedupe.ts` | the plugin installed FIRST: every `solid-js` specifier in an island's graph resolves to the APP's copy. `Bun.build` resolves from a module's real path, so a package reached through a symlink (`file:` overrides, `bun link`) brought its own `solid-js` — the scaffold's theme toggle shipped two runtimes under CI's own links, 62,463 B against 50,042 B with one (issue #490), and two runtimes are two reactive graphs |
|
|
1264
1265
|
| `island-routes.ts` | serve those chunks, at `ISLAND_BASE_PATH`, immutable |
|
|
1265
1266
|
| `dev-render.ts` | one collector **per render**, and `hydrateRuntime` after the body |
|
|
1266
1267
|
| `prerender.ts` | build first, write the chunks into the export, then measure |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "20.1
|
|
3
|
+
"version": "20.2.1",
|
|
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
|
|
41
|
-
"@ultimat3/admin": "20.1
|
|
42
|
-
"@ultimat3/ai": "20.1
|
|
43
|
-
"@ultimat3/auth": "20.1
|
|
44
|
-
"@ultimat3/cache": "20.1
|
|
45
|
-
"@ultimat3/core": "20.1
|
|
46
|
-
"@ultimat3/db": "20.1
|
|
47
|
-
"@ultimat3/entity": "20.1
|
|
48
|
-
"@ultimat3/flags": "20.1
|
|
49
|
-
"@ultimat3/http": "20.1
|
|
50
|
-
"@ultimat3/i18n": "20.1
|
|
51
|
-
"@ultimat3/jobs": "20.1
|
|
52
|
-
"@ultimat3/mail": "20.1
|
|
53
|
-
"@ultimat3/manifest": "20.1
|
|
54
|
-
"@ultimat3/mcp": "20.1
|
|
55
|
-
"@ultimat3/money": "20.1
|
|
56
|
-
"@ultimat3/notify": "20.1
|
|
57
|
-
"@ultimat3/policy": "20.1
|
|
58
|
-
"@ultimat3/pwa": "20.1
|
|
59
|
-
"@ultimat3/query": "20.1
|
|
60
|
-
"@ultimat3/realtime": "20.1
|
|
61
|
-
"@ultimat3/render": "20.1
|
|
62
|
-
"@ultimat3/schema": "20.1
|
|
63
|
-
"@ultimat3/scraping": "20.1
|
|
64
|
-
"@ultimat3/seo": "20.1
|
|
65
|
-
"@ultimat3/storage": "20.1
|
|
66
|
-
"@ultimat3/testing": "20.1
|
|
67
|
-
"@ultimat3/time": "20.1
|
|
40
|
+
"@ultimat3/action": "20.2.1",
|
|
41
|
+
"@ultimat3/admin": "20.2.1",
|
|
42
|
+
"@ultimat3/ai": "20.2.1",
|
|
43
|
+
"@ultimat3/auth": "20.2.1",
|
|
44
|
+
"@ultimat3/cache": "20.2.1",
|
|
45
|
+
"@ultimat3/core": "20.2.1",
|
|
46
|
+
"@ultimat3/db": "20.2.1",
|
|
47
|
+
"@ultimat3/entity": "20.2.1",
|
|
48
|
+
"@ultimat3/flags": "20.2.1",
|
|
49
|
+
"@ultimat3/http": "20.2.1",
|
|
50
|
+
"@ultimat3/i18n": "20.2.1",
|
|
51
|
+
"@ultimat3/jobs": "20.2.1",
|
|
52
|
+
"@ultimat3/mail": "20.2.1",
|
|
53
|
+
"@ultimat3/manifest": "20.2.1",
|
|
54
|
+
"@ultimat3/mcp": "20.2.1",
|
|
55
|
+
"@ultimat3/money": "20.2.1",
|
|
56
|
+
"@ultimat3/notify": "20.2.1",
|
|
57
|
+
"@ultimat3/policy": "20.2.1",
|
|
58
|
+
"@ultimat3/pwa": "20.2.1",
|
|
59
|
+
"@ultimat3/query": "20.2.1",
|
|
60
|
+
"@ultimat3/realtime": "20.2.1",
|
|
61
|
+
"@ultimat3/render": "20.2.1",
|
|
62
|
+
"@ultimat3/schema": "20.2.1",
|
|
63
|
+
"@ultimat3/scraping": "20.2.1",
|
|
64
|
+
"@ultimat3/seo": "20.2.1",
|
|
65
|
+
"@ultimat3/storage": "20.2.1",
|
|
66
|
+
"@ultimat3/testing": "20.2.1",
|
|
67
|
+
"@ultimat3/time": "20.2.1",
|
|
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
|
-
|
|
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`:
|
|
268
|
-
//
|
|
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
|
|
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
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
|
|
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
|
|
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
|
|
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';
|
|
@@ -32,6 +32,7 @@ import { shotBrowserChoice } from './shot-browser';
|
|
|
32
32
|
import type { BootDevServer, ShotServer } from './shot-server';
|
|
33
33
|
import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
|
|
34
34
|
import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
|
|
35
|
+
import { readThemeFlag, themeChoiceExpression } from './shot-theme';
|
|
35
36
|
import type { IslandCount, ShotArtifacts } from './shot-verdict';
|
|
36
37
|
import {
|
|
37
38
|
buildVerdict,
|
|
@@ -191,12 +192,25 @@ export interface ShotRun {
|
|
|
191
192
|
*/
|
|
192
193
|
readonly extraHosts?: string | undefined;
|
|
193
194
|
/**
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
195
|
+
* The theme the picture is of. Two things happen BEFORE navigation, because the boot script runs
|
|
196
|
+
* inline and nothing after `goto` can reach it: `prefers-color-scheme` is emulated so the boot's
|
|
197
|
+
* "system" branch answers the same on every box, AND the scheme is stored as the visitor's
|
|
198
|
+
* choice under `THEME_STORAGE_KEY` (`shot-theme.ts`), because an app with `theme.defaultMode`
|
|
199
|
+
* set answers that before the OS and only a stored choice beats it (issue #489). Absent means
|
|
200
|
+
* neither: the box's own preference and the app's own default — what `x shot` has always done,
|
|
201
|
+
* and the point of `defaultMode` — and `ui.shot` names one explicitly for exactly that reason.
|
|
197
202
|
*/
|
|
198
203
|
readonly colorScheme?: ColorScheme | undefined;
|
|
199
204
|
readonly now?: (() => Date) | undefined;
|
|
205
|
+
/**
|
|
206
|
+
* Something to do with the page AFTER the islands settled and BEFORE the picture — `ui.inspect`
|
|
207
|
+
* reads the DOM here, on the one navigation the picture already paid for. `settle` re-runs the
|
|
208
|
+
* island poll (an action that mounts something changes the count the verdict reports); the
|
|
209
|
+
* caller who never calls it gets the count from the first settle.
|
|
210
|
+
*/
|
|
211
|
+
readonly act?:
|
|
212
|
+
| ((page: ScrapePage, settle: () => Promise<IslandCount | null>) => Promise<void>)
|
|
213
|
+
| undefined;
|
|
200
214
|
}
|
|
201
215
|
|
|
202
216
|
/** Nothing here may replace the failure that caused it, so a teardown throw is swallowed. */
|
|
@@ -225,7 +239,11 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
225
239
|
timeoutMs: options.timeoutMs,
|
|
226
240
|
});
|
|
227
241
|
const page = session.page;
|
|
228
|
-
if (options.colorScheme !== undefined)
|
|
242
|
+
if (options.colorScheme !== undefined) {
|
|
243
|
+
await page.colorScheme(options.colorScheme);
|
|
244
|
+
const choice = themeChoiceExpression(options.colorScheme);
|
|
245
|
+
if (choice !== undefined) await page.prepare(choice);
|
|
246
|
+
}
|
|
229
247
|
await page.goto(requestedUrl, { timeout: options.timeoutMs });
|
|
230
248
|
if (options.settleMs > 0) await Bun.sleep(options.settleMs);
|
|
231
249
|
// The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
|
|
@@ -239,10 +257,15 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
|
239
257
|
// The same budget again, and deliberately no new flag: `settleMs` is the deadline at which the
|
|
240
258
|
// runtime CALLS `import()`, so a mount gets exactly as long to settle as the runtime got to
|
|
241
259
|
// start it — and `--settle 0`, which asks for no wait, still gets none.
|
|
242
|
-
const
|
|
243
|
-
windowMs: options.settleMs,
|
|
244
|
-
|
|
245
|
-
|
|
260
|
+
const settle = (): Promise<IslandCount | null> =>
|
|
261
|
+
settleIslands(probe, { windowMs: options.settleMs, pollMs: SETTLE_POLL_MS });
|
|
262
|
+
let islands = await settle();
|
|
263
|
+
if (options.act !== undefined) {
|
|
264
|
+
await options.act(page, async () => {
|
|
265
|
+
islands = await settle();
|
|
266
|
+
return islands;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
246
269
|
const bytes = await page.screenshot({ fullPage: options.fullPage });
|
|
247
270
|
// Read AFTER the capture, so an error logged while the page settled is in the verdict that
|
|
248
271
|
// ships with the picture it explains.
|
|
@@ -308,6 +331,11 @@ export const shotCommand: CliCommand = {
|
|
|
308
331
|
summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
|
|
309
332
|
},
|
|
310
333
|
{ name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
|
|
334
|
+
{
|
|
335
|
+
name: 'theme',
|
|
336
|
+
type: 'string',
|
|
337
|
+
summary: "light or dark, stored as the visitor's choice; absent is the app's own default",
|
|
338
|
+
},
|
|
311
339
|
// A FLAG on `x shot` and never a second command: photographing a route and photographing a
|
|
312
340
|
// component are one job with two subjects, and a parallel command would be the second path
|
|
313
341
|
// axiom 1 refuses.
|
|
@@ -338,6 +366,7 @@ export const shotCommand: CliCommand = {
|
|
|
338
366
|
// report, which is the rule `x routes` and `x mcp` already follow.
|
|
339
367
|
const island = flagString(ctx.args, 'island');
|
|
340
368
|
const state = flagString(ctx.args, 'state');
|
|
369
|
+
const theme = readThemeFlag(flagString(ctx.args, 'theme'));
|
|
341
370
|
const positional = ctx.args.positionals[0];
|
|
342
371
|
const sweep = flagBool(ctx.args, 'all-islands');
|
|
343
372
|
// Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
|
|
@@ -350,6 +379,16 @@ export const shotCommand: CliCommand = {
|
|
|
350
379
|
refuseRouteWithIsland(positional, island);
|
|
351
380
|
}
|
|
352
381
|
const component = sweep || (island !== undefined && island !== '');
|
|
382
|
+
// An island is photographed in BOTH themes by the harness, which owns its `data-theme` and
|
|
383
|
+
// carries no boot script — so a theme asked for beside one is a request nothing could honour.
|
|
384
|
+
if (component && theme !== undefined) {
|
|
385
|
+
throw new BadFlagError({
|
|
386
|
+
flag: 'theme',
|
|
387
|
+
command: 'shot',
|
|
388
|
+
reason: 'photographs a route; an island is photographed in both themes',
|
|
389
|
+
fix: 'x shot / --theme light --json',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
353
392
|
const route = component ? '' : readRoute(positional);
|
|
354
393
|
const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
|
|
355
394
|
const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
|
|
@@ -401,6 +440,7 @@ export const shotCommand: CliCommand = {
|
|
|
401
440
|
timeoutMs,
|
|
402
441
|
fullPage: flagBool(ctx.args, 'full'),
|
|
403
442
|
extraHosts: flagString(ctx.args, 'allow-hosts'),
|
|
443
|
+
...(theme === undefined ? {} : { colorScheme: theme }),
|
|
404
444
|
}),
|
|
405
445
|
);
|
|
406
446
|
},
|
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
|
-
) +
|
|
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.
|
|
325
|
-
//
|
|
326
|
-
// is
|
|
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
|
},
|
package/src/error-codes.ts
CHANGED
|
@@ -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
|
|
304
|
-
X_UI_SHOT_ROUTE_UNBUDGETED: 'ui
|
|
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/island-bundle.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { frameworkVersion, renderThrowable } from '@ultimat3/core';
|
|
|
11
11
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
12
12
|
import { contentHash } from '@ultimat3/render/server';
|
|
13
13
|
import { IslandBuildFailedError } from './errors';
|
|
14
|
+
import { solidDedupePlugin } from './island-solid-dedupe';
|
|
14
15
|
import { islandStylesPlugin } from './island-styles';
|
|
15
16
|
import { hasPathSegment } from './path-segments';
|
|
16
17
|
import { solidJsxPlugin } from './solid-loader';
|
|
@@ -91,7 +92,11 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
91
92
|
// The second closes the same shape of failure — a wrong answer `Bun.build` reports as
|
|
92
93
|
// `success: true`: without it, Bun's file loader resolves a `.module.scss` to its asset
|
|
93
94
|
// PATH, so `styles['x']` is `undefined` and every element renders unclassed.
|
|
94
|
-
|
|
95
|
+
//
|
|
96
|
+
// The dedupe goes FIRST: it answers `solid-js` specifiers before either plugin loads a file,
|
|
97
|
+
// so the `solid-js/web` helpers the JSX transform writes into a symlinked package resolve
|
|
98
|
+
// to the app's one copy. See `island-solid-dedupe.ts` for the measurement.
|
|
99
|
+
plugins: [solidDedupePlugin(root), solidJsxPlugin, islandStylesPlugin],
|
|
95
100
|
// The third one, and it is a `define` rather than the plugin this used to be: Bun selects
|
|
96
101
|
// the `development`/`production` export condition from the BUILD PROCESS's own `NODE_ENV`,
|
|
97
102
|
// and a defined `process.env.NODE_ENV` overrides it. Measured on 1.4.0, `solid-js` plus
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// One Solid runtime per island chunk: the app's. Every `solid-js` specifier in the island's graph
|
|
2
|
+
// — the entry's own `render` from `solid-js/web`, and the `solid-js/web` helpers the JSX
|
|
3
|
+
// transform writes into every `@ultimat3/ui` component — resolves to the copy the APP installed.
|
|
4
|
+
//
|
|
5
|
+
// Without this, `Bun.build` resolves each import from the importing file's REAL path. A package
|
|
6
|
+
// reached through a symlink (`file:` overrides, `bun link`, a workspace with its own install)
|
|
7
|
+
// resolves `solid-js` from its own `node_modules`, and the chunk ships two runtimes: measured on
|
|
8
|
+
// the scaffold's theme-toggle island under CI's own `file:` links, 62,463 B against 50,042 B with
|
|
9
|
+
// one (issue #490). Two copies are also two reactive graphs — a context created by one is never
|
|
10
|
+
// found by the other's `useContext` — so the dedupe is a correctness rule that happens to be the
|
|
11
|
+
// biggest single cut in the chunk, not a size trick.
|
|
12
|
+
|
|
13
|
+
// why: Bun ships no path API; `dirname` recovers the package directory from the manifest path
|
|
14
|
+
// `Bun.resolveSync` answers, and `join` puts an export target under it.
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import type { BunPlugin } from 'bun';
|
|
17
|
+
|
|
18
|
+
const SOLID_PACKAGE = 'solid-js';
|
|
19
|
+
|
|
20
|
+
/** `solid-js` and every subpath of it — `solid-js/web`, `solid-js/store`, `solid-js/h`. */
|
|
21
|
+
export const SOLID_SPECIFIER = /^solid-js(?:\/.*)?$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The conditions an island is built under, in the order they are tried: `browser` because the
|
|
25
|
+
* chunk runs there, `import` because it is ESM, `default` as the map's own fallback. NOT
|
|
26
|
+
* `development` — `island-bundle.ts` defines `process.env.NODE_ENV` as `"production"` for the
|
|
27
|
+
* same reason, and solid nests `development` INSIDE `browser`, so walking without it is what
|
|
28
|
+
* selects `dist/solid.js` over `dist/dev.js`.
|
|
29
|
+
*/
|
|
30
|
+
const BROWSER_CONDITIONS: readonly string[] = ['browser', 'import', 'default'];
|
|
31
|
+
|
|
32
|
+
/** `solid-js` → `.`, `solid-js/web` → `./web`: the key the package's `exports` map uses. */
|
|
33
|
+
export function solidSubpath(specifier: string): string {
|
|
34
|
+
return specifier === SOLID_PACKAGE ? '.' : `.${specifier.slice(SOLID_PACKAGE.length)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The target one `exports` entry names under the conditions above — depth-first, in the map's
|
|
39
|
+
* own key order, which is how Node and Bun read a conditions object. A string is a target; a
|
|
40
|
+
* nested object is walked; a condition the island does not build under (`node`, `worker`,
|
|
41
|
+
* `require`, `types`, `development`) is skipped. `undefined` when nothing matched, and the
|
|
42
|
+
* caller then leaves the specifier to Bun's own resolver rather than guessing.
|
|
43
|
+
*/
|
|
44
|
+
export function conditionTarget(entry: unknown): string | undefined {
|
|
45
|
+
if (typeof entry === 'string') return entry;
|
|
46
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
|
|
47
|
+
for (const [condition, nested] of Object.entries(entry)) {
|
|
48
|
+
if (!BROWSER_CONDITIONS.includes(condition)) continue;
|
|
49
|
+
const target = conditionTarget(nested);
|
|
50
|
+
if (target !== undefined) return target;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AppSolid {
|
|
56
|
+
/** The installed package's directory — where every export target is joined onto. */
|
|
57
|
+
readonly dir: string;
|
|
58
|
+
/** Its `exports` map, verbatim. */
|
|
59
|
+
readonly exports: unknown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The `solid-js` the app installed, found the way the app's own entry would find it: from the app
|
|
64
|
+
* root upward. `null` when there is none — an app with no islands never gets here, and an island
|
|
65
|
+
* importing solid where none is installed fails the build in Bun's own words either way.
|
|
66
|
+
*/
|
|
67
|
+
export async function resolveAppSolid(root: string): Promise<AppSolid | null> {
|
|
68
|
+
let manifest: string;
|
|
69
|
+
try {
|
|
70
|
+
manifest = Bun.resolveSync(`${SOLID_PACKAGE}/package.json`, root);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const parsed = (await Bun.file(manifest).json()) as { readonly exports?: unknown };
|
|
75
|
+
return { dir: dirname(manifest), exports: parsed.exports };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where `specifier` lands in the app's own copy, or `undefined` to leave it to Bun. */
|
|
79
|
+
export function appSolidPath(solid: AppSolid, specifier: string): string | undefined {
|
|
80
|
+
const exports = solid.exports;
|
|
81
|
+
if (exports === null || typeof exports !== 'object') return undefined;
|
|
82
|
+
const entry = Object.hasOwn(exports, solidSubpath(specifier))
|
|
83
|
+
? (exports as Record<string, unknown>)[solidSubpath(specifier)]
|
|
84
|
+
: undefined;
|
|
85
|
+
const target = conditionTarget(entry);
|
|
86
|
+
return target === undefined ? undefined : join(solid.dir, target);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The plugin `island-bundle.ts` installs FIRST, so it sees every `solid-js` specifier before the
|
|
91
|
+
* JSX and style plugins do. Resolved once per build and cached: the map is read from disk at
|
|
92
|
+
* most one time however many modules import solid.
|
|
93
|
+
*/
|
|
94
|
+
export function solidDedupePlugin(root: string): BunPlugin {
|
|
95
|
+
let app: Promise<AppSolid | null> | null = null;
|
|
96
|
+
return {
|
|
97
|
+
name: 'ultimate-solid-dedupe',
|
|
98
|
+
setup(build): void {
|
|
99
|
+
build.onResolve({ filter: SOLID_SPECIFIER }, async (args) => {
|
|
100
|
+
app ??= resolveAppSolid(root);
|
|
101
|
+
const solid = await app;
|
|
102
|
+
if (solid === null) return undefined;
|
|
103
|
+
const path = appSolidPath(solid, args.path);
|
|
104
|
+
return path === undefined ? undefined : { path };
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
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:
|
|
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.
|