@ultimat3/cli 20.2.0 → 21.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CLAUDE.md +70 -1
  2. package/package.json +30 -30
  3. package/src/app-env.ts +2 -2
  4. package/src/budgets.ts +45 -12
  5. package/src/build-errors.ts +54 -0
  6. package/src/cdp-browser.ts +21 -27
  7. package/src/cdp-connection.ts +66 -30
  8. package/src/cdp-e2e-page.ts +84 -113
  9. package/src/cdp-e2e-session.ts +199 -0
  10. package/src/cdp-launch.ts +95 -41
  11. package/src/cdp-offline-script.ts +73 -0
  12. package/src/cdp-pipe.ts +77 -0
  13. package/src/cmd-deploy.ts +7 -0
  14. package/src/cmd-dev.ts +23 -86
  15. package/src/cmd-shot.ts +30 -4
  16. package/src/dev-live-feed.ts +2 -0
  17. package/src/dev-render.ts +119 -20
  18. package/src/dev-route-table.ts +119 -0
  19. package/src/dev-services.ts +4 -1
  20. package/src/dev-sync.ts +5 -3
  21. package/src/e2e-app.ts +103 -0
  22. package/src/e2e-browser-handle.ts +55 -0
  23. package/src/e2e-driver.ts +32 -12
  24. package/src/e2e-errors.ts +14 -0
  25. package/src/e2e-page.ts +5 -2
  26. package/src/e2e-preload.ts +64 -0
  27. package/src/e2e-probe.ts +23 -0
  28. package/src/e2e-spawn.ts +169 -0
  29. package/src/error-codes.ts +7 -0
  30. package/src/error-unthrown.ts +130 -0
  31. package/src/errors.ts +8 -29
  32. package/src/index.ts +18 -4
  33. package/src/island-bundle.ts +38 -11
  34. package/src/island-realtime.ts +91 -0
  35. package/src/island-solid-dedupe.ts +108 -0
  36. package/src/island-verdict.ts +1 -1
  37. package/src/live-routes.ts +82 -42
  38. package/src/mcp-errors.ts +3 -0
  39. package/src/page-sync.ts +54 -0
  40. package/src/realtime-browser-probe-fixture.ts +2 -2
  41. package/src/serve.ts +9 -0
  42. package/src/shot-theme.ts +52 -0
  43. package/src/sw-artifacts.ts +13 -3
  44. package/src/sync-url.ts +31 -0
  45. package/src/templates/resource-form-island.ts +30 -21
  46. package/src/templates/route.ts +3 -0
  47. package/src/templates/scaffold-container.ts +18 -3
  48. package/src/templates/scaffold-dashboard-shared.ts +8 -5
  49. package/src/templates/scaffold-env.ts +6 -0
  50. package/src/verify-e2e.ts +38 -0
  51. package/src/verify-run.ts +105 -50
  52. package/src/verify-tests.ts +21 -4
  53. package/src/worker-bundle.ts +192 -0
@@ -0,0 +1,73 @@
1
+ // One responsibility: `navigator.onLine` reads `false` from a document's FIRST script while the
2
+ // session's offline switch is thrown, and reads `true` again — with the `online` event a page
3
+ // reconnects on — when it goes back. `Network.emulateNetworkConditions` cuts the NETWORK for a
4
+ // document created under the switch but, measured on Chrome 150, never tells it so: a reload under
5
+ // the switch read `navigator.onLine === true` at its first script every time, COOP or not, still
6
+ // read `true` a second later, and got no `online` event when the switch went back.
7
+ // `Network.overrideNetworkState` changed nothing. The dummy's page boot asks at its first script
8
+ // and replayed its outbox with a real POST under the cut (`offline-like.e2e.test.ts`, two attempts).
9
+ //
10
+ // The override is an OWN property on `navigator`, so the real getter on `Navigator.prototype` is one
11
+ // `delete` away. A document Chrome DID tell (it saw `offline`) gets Chrome's own `online` event on
12
+ // restore; one it never told gets one from `RESTORE_ONLINE`, so a page reconnects exactly once.
13
+
14
+ import type { CdpResult } from './cdp-connection';
15
+
16
+ type Send = (
17
+ method: string,
18
+ params: Record<string, unknown>,
19
+ session: string,
20
+ ) => Promise<CdpResult>;
21
+
22
+ /** Runs before the page's own scripts, in every document a page session creates while cut. */
23
+ export const OFFLINE_FIRST_SCRIPT = `(() => {
24
+ let told = false;
25
+ Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false });
26
+ addEventListener('offline', () => { told = true; });
27
+ addEventListener('online', () => { delete navigator.onLine; }, { once: true });
28
+ Object.defineProperty(window, '__xRestoreOnLine', { configurable: true, value: () => {
29
+ if (!Object.getOwnPropertyDescriptor(navigator, 'onLine')) return;
30
+ delete navigator.onLine;
31
+ if (!told) dispatchEvent(new Event('online'));
32
+ } });
33
+ })();`;
34
+
35
+ /** Evaluated in every open page when the switch goes back: a no-op in a document never cut. */
36
+ export const RESTORE_ONLINE = `window.__xRestoreOnLine?.()`;
37
+
38
+ export interface OfflineScripts {
39
+ /** Register the script on one page session; answers once the browser has it. */
40
+ add(session: string): Promise<unknown>;
41
+ /** Unregister it from one page session, if it holds it, and restore the open document. */
42
+ remove(session: string): Promise<unknown>;
43
+ }
44
+
45
+ const field = (from: unknown, key: string): string | undefined => {
46
+ const value =
47
+ typeof from === 'object' && from !== null ? (from as Record<string, unknown>)[key] : undefined;
48
+ return typeof value === 'string' ? value : undefined;
49
+ };
50
+
51
+ /** Per-session registration, keyed by the identifier the browser hands back. */
52
+ export function offlineScripts(send: Send): OfflineScripts {
53
+ const held = new Map<string, string>();
54
+ return {
55
+ async add(session) {
56
+ const answer = await send(
57
+ 'Page.addScriptToEvaluateOnNewDocument',
58
+ { source: OFFLINE_FIRST_SCRIPT },
59
+ session,
60
+ );
61
+ const identifier = field(answer.result, 'identifier');
62
+ if (identifier !== undefined) held.set(session, identifier);
63
+ return answer;
64
+ },
65
+ async remove(session) {
66
+ const identifier = held.get(session);
67
+ if (identifier === undefined) return undefined;
68
+ held.delete(session);
69
+ await send('Page.removeScriptToEvaluateOnNewDocument', { identifier }, session);
70
+ return send('Runtime.evaluate', { expression: RESTORE_ONLINE }, session);
71
+ },
72
+ };
73
+ }
@@ -0,0 +1,77 @@
1
+ // One responsibility: CDP over the pipe `--remote-debugging-pipe` opens — Chrome reads commands on
2
+ // its fd 3 and writes replies and events on its fd 4, each message one JSON text ended by a NUL
3
+ // byte. This is the e2e driver's wire; the WebSocket in `cdp-connection.ts` is for a remote browser.
4
+ //
5
+ // Why a pipe rather than the WebSocket Chrome also offers: Bun 1.4.0's WebSocket client handed
6
+ // `onmessage` text spliced from several frames under the dummy's `offline-feed` load — 64
7
+ // unparseable frames in one run, one of them a `Runtime.evaluate` reply that then waited out its
8
+ // 30 s deadline. A frame nobody can parse has no `id`, so no layer above can even tell which call
9
+ // it lost. A pipe is bytes and a delimiter, read here, and nothing in between.
10
+
11
+ import type { CdpTransport } from './cdp-connection';
12
+
13
+ /** The two ends a transport needs: a sink for whole messages, and the byte stream Chrome writes. */
14
+ export interface PipeEnds {
15
+ /** Write these bytes to Chrome's fd 3, in order. */
16
+ readonly write: (bytes: Uint8Array) => void;
17
+ /** Chrome's fd 4. */
18
+ readonly read: ReadableStream<Uint8Array>;
19
+ /** Release the write end — Chrome treats its fd 3 closing as the client going away. */
20
+ readonly end: () => void;
21
+ }
22
+
23
+ const NUL = 0;
24
+
25
+ /** Concatenate two byte arrays; the reader only ever holds the unterminated tail. */
26
+ const join = (a: Uint8Array, b: Uint8Array): Uint8Array => {
27
+ if (a.length === 0) return b;
28
+ const out = new Uint8Array(a.length + b.length);
29
+ out.set(a);
30
+ out.set(b, a.length);
31
+ return out;
32
+ };
33
+
34
+ export function pipeTransport(ends: PipeEnds): CdpTransport {
35
+ const encoder = new TextEncoder();
36
+ let closed = false;
37
+ const reader = ends.read.getReader();
38
+ return {
39
+ send(text: string): void {
40
+ if (closed) return;
41
+ const body = encoder.encode(text);
42
+ const framed = new Uint8Array(body.length + 1);
43
+ framed.set(body);
44
+ framed[body.length] = NUL;
45
+ ends.write(framed);
46
+ },
47
+ close(): void {
48
+ if (closed) return;
49
+ closed = true;
50
+ ends.end();
51
+ void reader.cancel().catch(() => undefined);
52
+ },
53
+ listen(handlers): void {
54
+ void (async () => {
55
+ // Split on BYTES, decoded per message: a multi-byte character may straddle two reads, and
56
+ // decoding each read on its own would corrupt it — the failure this file exists to end.
57
+ const decoder = new TextDecoder();
58
+ let tail: Uint8Array = new Uint8Array(0);
59
+ try {
60
+ for (;;) {
61
+ const { value, done } = await reader.read();
62
+ if (done) break;
63
+ tail = join(tail, value);
64
+ for (let at = tail.indexOf(NUL); at !== -1; at = tail.indexOf(NUL)) {
65
+ handlers.message(decoder.decode(tail.subarray(0, at)));
66
+ tail = tail.subarray(at + 1);
67
+ }
68
+ }
69
+ } catch {
70
+ // A read that fails is a pipe that is gone — reported as the close it is, below.
71
+ }
72
+ closed = true;
73
+ handlers.closed('the browser closed the CDP pipe');
74
+ })();
75
+ },
76
+ };
77
+ }
package/src/cmd-deploy.ts CHANGED
@@ -11,6 +11,7 @@ import { msg } from './messages';
11
11
  import type { CommandResult, JsonValue } from './output';
12
12
  import { flagBool, flagString } from './parse';
13
13
  import { quoteArg } from './shell-quote';
14
+ import { PROD_ENV_FILE } from './templates/scaffold-container';
14
15
 
15
16
  /**
16
17
  * Ordered, and the order is the design. `migrate` GATES — it runs to completion before anything
@@ -146,6 +147,12 @@ export function planDeploy(image: string, method: DeployMethod, root: string): D
146
147
  command: [
147
148
  'docker',
148
149
  'compose',
150
+ // Compose interpolates `${SYNC_URL:?…}` and `${POSTGRES_PASSWORD:?…}` from the shell and
151
+ // `--env-file` only — never from a service's `env_file:`. Without this an operator who put
152
+ // them in `.env.production`, the one file the compose file tells them to fill, had every
153
+ // step die on a parse error. Global flag, so it precedes `-f`; the shell still wins over it.
154
+ '--env-file',
155
+ join(root, PROD_ENV_FILE),
149
156
  '-f',
150
157
  join(root, 'docker', 'docker-compose.prod.yml'),
151
158
  ONE_SHOT_ROLES.includes(role) ? 'run' : 'up',
package/src/cmd-dev.ts CHANGED
@@ -9,58 +9,43 @@ import { devShellStyle } from '@ultimat3/admin/dev';
9
9
  import type { Role } from '@ultimat3/core';
10
10
  import { configureTelemetry, METRICS_PATH, noopExporter } from '@ultimat3/core';
11
11
  import { setStatementObserver } from '@ultimat3/db';
12
- import type { OverlayNotice, RequestContext, Route } from '@ultimat3/http';
12
+ import type { OverlayNotice, RequestContext } from '@ultimat3/http';
13
13
  import { asCtx } from '@ultimat3/http';
14
14
  import type { Manifest } from '@ultimat3/manifest';
15
15
  import { MANIFEST_FILENAME } from '@ultimat3/manifest';
16
- import { describeRoutes } from '@ultimat3/render';
17
- import { apiRoutes } from './api-routes';
18
16
  import { loadSignInPath } from './app-auth';
19
17
  import { appManifest } from './app-manifest';
20
- import { mountAppMcp } from './app-mcp';
21
18
  import { requireAppRoot } from './app-root';
22
19
  import { loadAppRuntime } from './app-runtime';
23
20
  import type { CliCommand, CommandContext } from './command';
24
- import { assetRoutes } from './dev-assets';
25
21
  import type { DevDashboardInput, DevStatus } from './dev-dashboard';
26
- import { devDashboardRoutes, devPanels } from './dev-dashboard';
22
+ import { devPanels } from './dev-dashboard';
27
23
  import { declareDevEnvironment } from './dev-environment';
28
24
  import { liveFeedLabel } from './dev-live-feed';
29
25
  import { clearLock, preflight, writeLock } from './dev-lock';
30
26
  import { createStatementLedger } from './dev-n-plus-one';
31
27
  import { coalesceReloads } from './dev-reload';
32
- import { appRoutes } from './dev-render';
33
28
  import { replicaOverrides } from './dev-replica';
34
29
  import type { RunningRoles } from './dev-roles';
35
30
  import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
31
+ import { devRouteTable } from './dev-route-table';
36
32
  import type { RunningServices } from './dev-runtime';
37
33
  import { cdnLabel, describeCdn, describeMail, mailLabel, startServices } from './dev-runtime';
38
34
  import type { DevServices } from './dev-services';
39
35
  import { describeServices, reportedUrls, resolveServices } from './dev-services';
40
- import { storageRoutes } from './dev-storage';
41
36
  import { createTraceRecorder } from './dev-traces';
42
37
  import { watchTree } from './dev-watch-tree';
43
- import { errorPageStyleSources } from './error-page-csp';
44
38
  import { intFlagOr, PORT_RANGE } from './flag-number';
45
39
  import { holdUntilShutdown } from './hold';
46
40
  import type { IslandBundle } from './island-bundle';
47
41
  import { buildIslands } from './island-bundle';
48
42
  import { FRAME_STYLE } from './island-harness';
49
- import { islandHarnessRoutes } from './island-harness-route';
50
- import { islandRoutes } from './island-routes';
51
- import { loadIslandStates } from './island-states-load';
52
43
  import { msg } from './messages';
53
44
  import type { CommandResult, Finding } from './output';
54
45
  import { findingFrom } from './output';
55
46
  import { flagString } from './parse';
56
- import { loadPwaArtifacts } from './pwa-artifacts';
57
47
  import { metricsPortFor } from './serve';
58
48
  import { loopFacts, loopFinding, loopNotice } from './statement-loop';
59
- import { styleBundle } from './style-bundle';
60
- import { styleRoutes } from './style-routes';
61
- import { serviceWorkerArtifacts } from './sw-artifacts';
62
- import { serviceWorkerRoutes } from './sw-routes';
63
- import { loadThemeMode, themeBoot } from './theme-boot';
64
49
 
65
50
  const DEFAULT_PORT = 3000;
66
51
 
@@ -68,7 +53,10 @@ export interface DevServer {
68
53
  readonly url: string;
69
54
  readonly services: DevServices;
70
55
  readonly roles: readonly Role[];
71
- /** The manifest as it stands now — a reload that registers a new route moves it. */
56
+ /**
57
+ * `BUILD_ID` when stamped — the id every response carries. Otherwise the manifest as it stands
58
+ * now, so a reload that registers a new route moves it.
59
+ */
72
60
  readonly buildId: string;
73
61
  /**
74
62
  * Modules that would not import, primitives that would not register, reloads that would not
@@ -164,7 +152,12 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
164
152
  // boot on purpose: the header is handed to the HTTP config and the render modes once, and a
165
153
  // reload cannot re-pin it — `state.manifest.buildId` is what `/_x` and `--json` report, so a
166
154
  // divergence between the two is visible rather than silent, and a restart closes it.
167
- const buildId = state.manifest.buildId;
155
+ // `BUILD_ID` wins when set — `serve.ts`'s rule, so an e2e `deploy.newBuild()` can restart `x dev`
156
+ // as a new build with the same sources.
157
+ // Stamped, it is ALSO what `server.buildId` answers below: the process serves no other build.
158
+ const rawStamp = options.env['BUILD_ID'];
159
+ const stamped = rawStamp !== undefined && rawStamp !== '' ? rawStamp : undefined;
160
+ const buildId = stamped ?? state.manifest.buildId;
168
161
 
169
162
  let server: DevServer;
170
163
  // Read at request time, never captured at boot: `/_x/services` must report the reload counter
@@ -185,70 +178,14 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
185
178
  };
186
179
  const panels = devPanels(dashboard).map((panel) => panel.key);
187
180
 
188
- // Resolved once, before the first route: the manifest's bytes and the three head elements that
189
- // name it. `undefined` for an app that is not installable, and then nothing is mounted and no
190
- // document changes — the 0kb baseline is not spent on a `<link>` to a file that does not exist.
191
- const pwa = await loadPwaArtifacts(options.root);
192
- const theme = themeBoot(await loadThemeMode(options.root));
193
- const errorStyles = await errorPageStyleSources(options.root);
194
- // Built once at boot, from this process's own route table and island bundle. `x dev` rebuilds
195
- // islands on the watcher tick and the worker is NOT rebuilt with them, deliberately: a service
196
- // worker that changes under a page it already controls is the update path, and re-emitting one
197
- // per keystroke would exercise it on every save.
198
- const serviceWorker =
199
- pwa === undefined
200
- ? undefined
201
- : serviceWorkerArtifacts({
202
- pwa,
203
- buildId,
204
- routes: describeRoutes(),
205
- islands: state.islands,
206
- styles: styleBundle(),
207
- });
208
-
209
- // The app's own MCP endpoint, discovered from `apps/<app>/mcp.ts` and mounted through the SAME
210
- // call `runRole` makes — `POST /mcp` answered 404 in every process the framework booted until
211
- // one of them asked. Warned once here when `expose` is true and nothing can be mounted.
212
- const mcpMount = await mountAppMcp(options.root);
213
- const routes: readonly Route[] = [
214
- ...devDashboardRoutes(dashboard),
215
- // The same API table the container serves: a read that answers here and 404s in production
216
- // is exactly the drift one composition exists to prevent.
217
- ...apiRoutes(),
218
- ...mcpMount.routes,
219
- // The image pipeline's only HTTP surface: the icons the web manifest declares, and the
220
- // variants every `srcset` promises. Mounted before the app's own routes so a page route can
221
- // never shadow `/icons` or `/media`.
222
- ...assetRoutes({
223
- root: options.root,
224
- storage: runtime.storage,
225
- ...(pwa === undefined ? {} : { pwa }),
226
- }),
227
- ...storageRoutes({ storage: runtime.storage }),
228
- // The chunks the documents below name. Mounted before the app's routes for the reason
229
- // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
230
- ...islandRoutes(() => state.islands),
231
- // And the stylesheet every one of those documents links. Read through the getter for the
232
- // reason the islands are: a rebuilt island registers CSS, which mints a new URL, and a table
233
- // captured at boot would answer 404 for the href the document now carries.
234
- ...styleRoutes(() => styleBundle()),
235
- // `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
236
- // lives here rather than in a second server because everything it needs is in THIS process:
237
- // the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
238
- // may have. The states are read per REQUEST — an author editing a state and re-running the
239
- // command must not need a restart to see it.
240
- ...islandHarnessRoutes({
241
- islands: () => state.islands,
242
- states: () => loadIslandStates(options.root),
243
- }),
244
- ...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
245
- ...appRoutes({
246
- buildId,
247
- resolveIsland: (file) => state.islands.resolverFor(file),
248
- themeHead: theme.head,
249
- ...(pwa === undefined ? {} : { pwaHead: pwa.head + (serviceWorker?.head ?? '') }),
250
- }),
251
- ];
181
+ const { routes, theme, errorStyles, mcpPath } = await devRouteTable({
182
+ root: options.root,
183
+ env: options.env,
184
+ buildId,
185
+ storage: runtime.storage,
186
+ dashboard,
187
+ islands: () => state.islands,
188
+ });
252
189
 
253
190
  // The app's `apps/<app>/runtime.ts`, composed exactly as `runRole` composes a caller's
254
191
  // `runtime`: the replica scope in front, the app's own middleware behind it. Before this the
@@ -320,9 +257,9 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
320
257
  url: running.url ?? `http://localhost:${options.port}`,
321
258
  services,
322
259
  roles: running.roles,
323
- mcp: mcpMount.path,
260
+ mcp: mcpPath,
324
261
  get buildId(): string {
325
- return state.manifest.buildId;
262
+ return stamped ?? state.manifest.buildId;
326
263
  },
327
264
  // A getter, not a snapshot: `/_x` and `--json` must show the reload that just failed and the
328
265
  // loop the last request tripped, not the findings as they were when the route table was built.
package/src/cmd-shot.ts CHANGED
@@ -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,9 +192,13 @@ export interface ShotRun {
191
192
  */
192
193
  readonly extraHosts?: string | undefined;
193
194
  /**
194
- * What `prefers-color-scheme` the page sees, emulated BEFORE navigation so the boot script's
195
- * "system" branch answers the same on every box. Absent means the box's own preference — what
196
- * `x shot` has always done — and `ui.shot` names one explicitly for exactly that reason.
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;
@@ -234,7 +239,11 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
234
239
  timeoutMs: options.timeoutMs,
235
240
  });
236
241
  const page = session.page;
237
- if (options.colorScheme !== undefined) await page.colorScheme(options.colorScheme);
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
+ }
238
247
  await page.goto(requestedUrl, { timeout: options.timeoutMs });
239
248
  if (options.settleMs > 0) await Bun.sleep(options.settleMs);
240
249
  // The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
@@ -322,6 +331,11 @@ export const shotCommand: CliCommand = {
322
331
  summary: 'attach to a browser somebody else is running (a provider session, a sidecar)',
323
332
  },
324
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
+ },
325
339
  // A FLAG on `x shot` and never a second command: photographing a route and photographing a
326
340
  // component are one job with two subjects, and a parallel command would be the second path
327
341
  // axiom 1 refuses.
@@ -352,6 +366,7 @@ export const shotCommand: CliCommand = {
352
366
  // report, which is the rule `x routes` and `x mcp` already follow.
353
367
  const island = flagString(ctx.args, 'island');
354
368
  const state = flagString(ctx.args, 'state');
369
+ const theme = readThemeFlag(flagString(ctx.args, 'theme'));
355
370
  const positional = ctx.args.positionals[0];
356
371
  const sweep = flagBool(ctx.args, 'all-islands');
357
372
  // Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
@@ -364,6 +379,16 @@ export const shotCommand: CliCommand = {
364
379
  refuseRouteWithIsland(positional, island);
365
380
  }
366
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
+ }
367
392
  const route = component ? '' : readRoute(positional);
368
393
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
369
394
  const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
@@ -415,6 +440,7 @@ export const shotCommand: CliCommand = {
415
440
  timeoutMs,
416
441
  fullPage: flagBool(ctx.args, 'full'),
417
442
  extraHosts: flagString(ctx.args, 'allow-hosts'),
443
+ ...(theme === undefined ? {} : { colorScheme: theme }),
418
444
  }),
419
445
  );
420
446
  },
@@ -49,6 +49,8 @@ export async function startLiveFeed(input: LiveFeedInput): Promise<RunningLiveFe
49
49
  }
50
50
  const bridge = await startLiveReplicator({
51
51
  registry: input.sync.registry,
52
+ // The same changes the channels read — a real node's change subscription feeds both.
53
+ channels: input.sync.hub,
52
54
  // Logged, never thrown: one change nobody could fan out must not take the dev server down.
53
55
  onError: (error) =>
54
56
  logger.warn('live.bridge_delivery_failed', { error: renderThrowable(error) }),