@ultimat3/cli 11.1.0 → 11.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/CLAUDE.md CHANGED
@@ -167,6 +167,7 @@ change, and CI does not install one.
167
167
  | | Reaches for | Never |
168
168
  |---|---|---|
169
169
  | `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
170
+ | `x shot --island <name>` | the same server and the same browser, plus the app's own `*.island.states.ts` | a second command — photographing a route and photographing a component are one job with two subjects, and `--island` with a route positional is refused by name |
170
171
  | `x pr review\|resolve\|reply` | `gh api graphql`, through the injected `Runner` | `gh pr view --comments`, which shows *issue* comments and not the line-anchored threads that carry the findings |
171
172
  | `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
172
173
 
@@ -174,6 +175,65 @@ change, and CI does not install one.
174
175
  not observe alongside what it did. A capture tool that silently omits what it cannot see is worse
175
176
  than one that says so, because the omission reads as a clean result.
176
177
 
178
+ ### `--island` photographs ONE component in a state nobody can click to
179
+
180
+ A failed read, an empty list, over-quota, read-only: the states a reviewer most needs to see are the
181
+ ones a running app will not produce on request. `--island` takes them, one address at a time.
182
+
183
+ | File | Job |
184
+ |---|---|
185
+ | `island-states-load.ts` | discover `*.island.states.ts`, prove each pure, import it, check the set |
186
+ | `island-harness.ts` | the document that mounts ONE island over `data-x-entry` / `data-x-props` |
187
+ | `island-harness-script.ts` | what runs before the chunk does: the sealed network, the pinned clock, the readiness watch |
188
+ | `island-harness-route.ts` | `GET /_x/island`, mounted by `x dev` |
189
+ | `island-shot.ts` | the capture loop, the assertions before each shutter, the missing-shot gate |
190
+ | `island-verdict.ts` | the per-state verdict — a PNG cannot say the component threw or logged |
191
+ | `cmd-shot-island.ts` | the flags, and the one browser per declared viewport |
192
+
193
+ The vocabulary is **`@ultimat3/testing`'s**, not this package's: `defineIslandStates`,
194
+ `islandShotTargets`, `islandAddress` / `parseIslandAddress`, `findIslandStates`,
195
+ `assertIslandStatesPure`. `cli → testing` is a declared sideways edge and `cli → scraping` is
196
+ another, which is what makes `@ultimat3/cli` the only package that can hold both the mount half and
197
+ the screenshot half.
198
+
199
+ **The expected picture list exists before a browser does.** `loadIslandStates` → `findIslandStates`
200
+ → `islandShotTargets` is a pure expansion off files on disk, and the run ends by diffing it against
201
+ what actually landed (`missingShots`, `X_SHOT_ISLAND_MISSING`). That diff is the point of the whole
202
+ design: a loop that swallowed every failure would otherwise report a clean run with no pictures in
203
+ it, and "produced nothing and exited 0" is the one outcome a reader cannot tell from success.
204
+
205
+ **An unstubbed request FAILS the run.** The page's own seal replaces `fetch`, `WebSocket`,
206
+ `EventSource` and `XMLHttpRequest` before the island's chunk is imported, answers the state's
207
+ `routes` and publishes everything else on `window.__xShot.unstubbed`; the capture refuses on a
208
+ non-empty list with `X_SHOT_ISLAND_UNSTUBBED_REQUEST`, naming each method and path. A component
209
+ whose fetch quietly hangs paints its own loading branch, and the picture then shows a fixture gap
210
+ dressed up as a real component state. `@ultimat3/testing`'s `sealNetwork()` is not reusable here:
211
+ it patches THIS process's `globalThis.fetch` and the component runs in the page's realm.
212
+
213
+ **Readiness is quiet, not idle.** Fonts ready, then N consecutive animation frames with an unchanged
214
+ network-ACTIVITY counter — never "nothing in flight", which never comes for a state whose fixture is
215
+ deliberately `pending`, and never a fixed sleep, which photographs whatever a slow machine painted.
216
+
217
+ **Eight assertions before a shutter opens** (`photographFault`), each naming a fact the picture would
218
+ have hidden rather than shown: no probe, not the harness, no host element, a mount that REJECTED, a
219
+ mount that never finished, a page that never went quiet, a zero-sized box, a box with no children
220
+ and no text. Then a byte floor as a backstop. Every one of them otherwise comes out as a plausible
221
+ image of the wrong thing.
222
+
223
+ **One session per picture, and that is not an optimisation to collapse.** `page.console()` and
224
+ `page.pageErrors()` are bounded rings over the whole SESSION, so a shared one files state A's
225
+ console errors under state B — and per-state attribution is the half of the artifact that gates.
226
+
227
+ **The picture is the VIEWPORT, not a crop.** `@ultimat3/scraping`'s `CaptureRequest` is `fullPage`
228
+ alone, so there is no clip rectangle to ask for; the framing knob is the state's own `viewport`,
229
+ passed to `launch()` as `defaultViewport` through `LocalBrowserOptions.options`. That is why there
230
+ is one browser per declared viewport, memoised. The verdict names it as a blind spot rather than
231
+ implying a crop it did not perform.
232
+
233
+ **`loadApp` does not import a states file**, for the reason it does not import an island: it
234
+ registers no primitive, and importing it would put `@ultimat3/testing` in the server module graph of
235
+ every `x dev`, every `x build` and every gate step that loads the app (axiom 6).
236
+
177
237
  **`x shot` reuses a running `x dev` rather than booting a second one.** Embedded Postgres is
178
238
  single-writer, so a second boot is `X_DEV_ALREADY_RUNNING` and no picture is ever taken. A reused
179
239
  server's `stop()` deliberately does not clear the other process's lock.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "11.1.0",
3
+ "version": "11.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,33 +37,33 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "11.1.0",
41
- "@ultimat3/admin": "11.1.0",
42
- "@ultimat3/ai": "11.1.0",
43
- "@ultimat3/auth": "11.1.0",
44
- "@ultimat3/cache": "11.1.0",
45
- "@ultimat3/core": "11.1.0",
46
- "@ultimat3/db": "11.1.0",
47
- "@ultimat3/entity": "11.1.0",
48
- "@ultimat3/flags": "11.1.0",
49
- "@ultimat3/http": "11.1.0",
50
- "@ultimat3/i18n": "11.1.0",
51
- "@ultimat3/jobs": "11.1.0",
52
- "@ultimat3/mail": "11.1.0",
53
- "@ultimat3/manifest": "11.1.0",
54
- "@ultimat3/mcp": "11.1.0",
55
- "@ultimat3/money": "11.1.0",
56
- "@ultimat3/policy": "11.1.0",
57
- "@ultimat3/pwa": "11.1.0",
58
- "@ultimat3/query": "11.1.0",
59
- "@ultimat3/realtime": "11.1.0",
60
- "@ultimat3/render": "11.1.0",
61
- "@ultimat3/schema": "11.1.0",
62
- "@ultimat3/scraping": "11.1.0",
63
- "@ultimat3/seo": "11.1.0",
64
- "@ultimat3/storage": "11.1.0",
65
- "@ultimat3/testing": "11.1.0",
66
- "@ultimat3/time": "11.1.0",
40
+ "@ultimat3/action": "11.2.0",
41
+ "@ultimat3/admin": "11.2.0",
42
+ "@ultimat3/ai": "11.2.0",
43
+ "@ultimat3/auth": "11.2.0",
44
+ "@ultimat3/cache": "11.2.0",
45
+ "@ultimat3/core": "11.2.0",
46
+ "@ultimat3/db": "11.2.0",
47
+ "@ultimat3/entity": "11.2.0",
48
+ "@ultimat3/flags": "11.2.0",
49
+ "@ultimat3/http": "11.2.0",
50
+ "@ultimat3/i18n": "11.2.0",
51
+ "@ultimat3/jobs": "11.2.0",
52
+ "@ultimat3/mail": "11.2.0",
53
+ "@ultimat3/manifest": "11.2.0",
54
+ "@ultimat3/mcp": "11.2.0",
55
+ "@ultimat3/money": "11.2.0",
56
+ "@ultimat3/policy": "11.2.0",
57
+ "@ultimat3/pwa": "11.2.0",
58
+ "@ultimat3/query": "11.2.0",
59
+ "@ultimat3/realtime": "11.2.0",
60
+ "@ultimat3/render": "11.2.0",
61
+ "@ultimat3/schema": "11.2.0",
62
+ "@ultimat3/scraping": "11.2.0",
63
+ "@ultimat3/seo": "11.2.0",
64
+ "@ultimat3/storage": "11.2.0",
65
+ "@ultimat3/testing": "11.2.0",
66
+ "@ultimat3/time": "11.2.0",
67
67
  "babel-preset-solid": "^1.9.15"
68
68
  }
69
69
  }
package/src/app-load.ts CHANGED
@@ -46,6 +46,15 @@ const ENTRY_POINT = /^apps\/[^/]+\/(?:server|prerender)\.tsx?$/;
46
46
  */
47
47
  const CLIENT_ENTRY_POINT = /\.island\.tsx$/;
48
48
 
49
+ /**
50
+ * A `*.island.states.ts` is read by a TOOL, not by the server: `x shot --island` loads it, the
51
+ * harness route loads it, and a guard test loads it. It registers no primitive and it imports
52
+ * `@ultimat3/testing`, so importing it here would put the test-support package in the module graph
53
+ * of every `x dev`, every `x build` and every gate step that loads the app — the same rule the
54
+ * client entry point above follows, for the same reason (axiom 6).
55
+ */
56
+ const STATES_FILE = /\.island\.states\.ts$/;
57
+
49
58
  export interface LoadedApp {
50
59
  readonly root: string;
51
60
  /** App-root-relative POSIX paths of every module that imported, sorted. */
@@ -88,7 +97,9 @@ export async function loadApp(root: string): Promise<LoadedApp> {
88
97
  for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
89
98
  if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
90
99
  const file = relative(root, absolute).split(sep).join('/');
91
- if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file)) continue;
100
+ if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
101
+ continue;
102
+ }
92
103
  let module: Record<string, unknown>;
93
104
  try {
94
105
  module = (await import(absolute)) as Record<string, unknown>;
@@ -57,6 +57,13 @@ export interface AppBrowserOptions {
57
57
  readonly root: string;
58
58
  /** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
59
59
  readonly executablePath?: string | undefined;
60
+ /**
61
+ * The page size this browser lays out at. A LAUNCH option and not a per-capture one, because
62
+ * that is the only place the shipped port has for it: `CaptureRequest` is `fullPage` alone
63
+ * (`packages/scraping/src/page.ts`), so the viewport IS the frame of every picture taken with
64
+ * this driver — which is why `x shot --island` builds one browser per declared viewport.
65
+ */
66
+ readonly viewport?: { readonly width: number; readonly height: number } | undefined;
60
67
  /** Test seam: the resolver and the loader, so a test proves the refusal without an install. */
61
68
  readonly resolve?: (specifier: string, from: string) => string;
62
69
  readonly load?: (path: string) => Promise<unknown>;
@@ -103,5 +110,10 @@ export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriv
103
110
  launcher,
104
111
  headless: true,
105
112
  ...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
113
+ // `LocalBrowserOptions.options` is passed through to `launch()` untouched, which is the seam
114
+ // that lets the CLI size a browser without `@ultimat3/scraping` naming a puppeteer type.
115
+ ...(options.viewport === undefined
116
+ ? {}
117
+ : { options: { defaultViewport: { ...options.viewport } } }),
106
118
  });
107
119
  }
package/src/cmd-dev.ts CHANGED
@@ -38,7 +38,9 @@ import { intFlagOr, PORT_RANGE } from './flag-number';
38
38
  import { holdUntilShutdown } from './hold';
39
39
  import type { IslandBundle } from './island-bundle';
40
40
  import { buildIslands } from './island-bundle';
41
+ import { islandHarnessRoutes } from './island-harness-route';
41
42
  import { islandRoutes } from './island-routes';
43
+ import { loadIslandStates } from './island-states-load';
42
44
  import { msg } from './messages';
43
45
  import type { CommandResult, Finding } from './output';
44
46
  import { findingFrom } from './output';
@@ -181,6 +183,15 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
181
183
  // The chunks the documents below name. Mounted before the app's routes for the reason
182
184
  // `/icons` and `/media` are: a page route must not be able to shadow an asset URL.
183
185
  ...islandRoutes(() => state.islands),
186
+ // `x shot --island`'s harness, in the `/_x` dev namespace so no app route can shadow it. It
187
+ // lives here rather than in a second server because everything it needs is in THIS process:
188
+ // the built chunks, the app's stylesheet registry, and the one embedded Postgres a checkout
189
+ // may have. The states are read per REQUEST — an author editing a state and re-running the
190
+ // command must not need a restart to see it.
191
+ ...islandHarnessRoutes({
192
+ islands: () => state.islands,
193
+ states: () => loadIslandStates(options.root),
194
+ }),
184
195
  ...appRoutes({ buildId, resolveIsland: (file) => state.islands.resolverFor(file) }),
185
196
  ];
186
197
 
@@ -0,0 +1,146 @@
1
+ // `--island`'s half of `x shot`: read the flags, resolve the manifest, refuse the combinations
2
+ // that cannot mean anything, and hand `runIslandShot` values. Split from `cmd-shot.ts` so neither
3
+ // file has to hold both a route capture and a component capture — the two share a boot, a browser
4
+ // and an output tree, and nothing else.
5
+
6
+ // why: no Bun native joins or resolves a path; `--out` is resolved against the app root.
7
+ import { join, resolve } from 'node:path';
8
+ import type { ScrapeDriver } from '@ultimat3/scraping';
9
+ import type { IslandStatesManifest, IslandViewport } from '@ultimat3/testing';
10
+ import { findIslandStates } from '@ultimat3/testing';
11
+ import { appBrowser } from './browser-launcher';
12
+ import { BadFlagError } from './errors';
13
+ import type { IslandBrowser } from './island-shot';
14
+ import { ISLAND_SHOT_DIR, runIslandShot } from './island-shot';
15
+ import { loadIslandStates } from './island-states-load';
16
+ import type { IslandArtifacts } from './island-verdict';
17
+ import { islandShotLines, islandShotSummary, islandVerdictJson } from './island-verdict';
18
+ import type { CommandResult } from './output';
19
+ import type { ShotServer } from './shot-server';
20
+ import { SHOT_DIR } from './shot-server';
21
+
22
+ /**
23
+ * One browser per declared viewport, memoised. A state declares its own size and the viewport is a
24
+ * LAUNCH option, so two states at two sizes are two browsers — but two states at one size must not
25
+ * be, or a manifest of eight states pays eight launches for nothing.
26
+ */
27
+ export function islandBrowser(input: {
28
+ readonly root: string;
29
+ readonly executablePath?: string | undefined;
30
+ }): IslandBrowser {
31
+ const byViewport = new Map<string, Promise<ScrapeDriver>>();
32
+ return (viewport: IslandViewport): Promise<ScrapeDriver> => {
33
+ const key = `${viewport.width}x${viewport.height}`;
34
+ const held = byViewport.get(key);
35
+ if (held !== undefined) return held;
36
+ const started = appBrowser({
37
+ root: input.root,
38
+ ...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
39
+ viewport: { width: viewport.width, height: viewport.height },
40
+ });
41
+ byViewport.set(key, started);
42
+ return started;
43
+ };
44
+ }
45
+
46
+ /**
47
+ * `--state <id>` against what the manifest really declares. Refused here rather than by an empty
48
+ * expansion downstream: a filter that matches nothing produces no picture, and a run that produces
49
+ * no picture and exits 0 is the outcome this whole command exists to make impossible.
50
+ */
51
+ export function readStateFlag(
52
+ manifest: IslandStatesManifest,
53
+ wanted: string | undefined,
54
+ ): string | undefined {
55
+ if (wanted === undefined || wanted === '') return undefined;
56
+ if (manifest.states.some((one) => one.id === wanted)) return wanted;
57
+ const known = manifest.states.map((one) => one.id);
58
+ throw new BadFlagError({
59
+ flag: 'state',
60
+ command: 'shot',
61
+ reason: `${manifest.island} declares no state "${wanted}" — it declares ${known.join(', ')}`,
62
+ fix: `x shot --island ${manifest.name} --state ${known[0] ?? '<id>'} --json`,
63
+ });
64
+ }
65
+
66
+ /**
67
+ * `--island` and a route positional are two different subjects and one command, so naming both is
68
+ * refused by name. Never silently preferring one: a reader who typed both has a belief about which
69
+ * one runs, and half of them would be wrong.
70
+ */
71
+ export function refuseRouteWithIsland(route: string | undefined, island: string): never {
72
+ throw new BadFlagError({
73
+ flag: 'island',
74
+ command: 'shot',
75
+ reason: `--island ${island} photographs a component and "${route ?? ''}" is a route; x shot takes one subject`,
76
+ fix: `x shot --island ${island} --json`,
77
+ });
78
+ }
79
+
80
+ export interface IslandShotInput {
81
+ readonly root: string;
82
+ readonly island: string;
83
+ readonly state?: string | undefined;
84
+ readonly out?: string | undefined;
85
+ readonly settleMs: number;
86
+ readonly timeoutMs: number;
87
+ readonly extraHosts?: string | undefined;
88
+ readonly executablePath?: string | undefined;
89
+ readonly boot: () => Promise<ShotServer>;
90
+ /** Injected by a test, so the whole path is proved on a machine with no Chrome. */
91
+ readonly driver?: IslandBrowser | undefined;
92
+ readonly minBytes?: number | undefined;
93
+ }
94
+
95
+ /**
96
+ * The states are loaded, expanded and the name resolved BEFORE a browser or a dev server exists —
97
+ * a typo must not cost an embedded Postgres to report, and the expected picture list has to be
98
+ * knowable without either.
99
+ */
100
+ export async function islandShot(input: IslandShotInput): Promise<IslandArtifacts> {
101
+ const all = await loadIslandStates(input.root);
102
+ if (all.length === 0) {
103
+ throw new BadFlagError({
104
+ flag: 'island',
105
+ command: 'shot',
106
+ reason: 'this app declares no island states at all, so there is nothing to photograph',
107
+ fix: "x g island settings --at apps/web/app/settings # then declare its states beside it with defineIslandStates({ island: '…', states: [...] })",
108
+ });
109
+ }
110
+ // `findIslandStates` is loose on the way in — `Settings`, `settings`, `settings.island.tsx` and
111
+ // the full path are one name — and refuses an unresolved one by listing every valid name, which
112
+ // is what tells a typo apart from an island whose states were never declared.
113
+ const manifest = findIslandStates(all, input.island);
114
+ const only = readStateFlag(manifest, input.state);
115
+ return runIslandShot({
116
+ manifest,
117
+ ...(only === undefined ? {} : { state: only }),
118
+ outDir:
119
+ input.out === undefined
120
+ ? join(input.root, SHOT_DIR, ISLAND_SHOT_DIR)
121
+ : resolve(input.root, input.out),
122
+ driver:
123
+ input.driver ??
124
+ islandBrowser({
125
+ root: input.root,
126
+ ...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
127
+ }),
128
+ boot: input.boot,
129
+ settleMs: input.settleMs,
130
+ timeoutMs: input.timeoutMs,
131
+ ...(input.extraHosts === undefined ? {} : { extraHosts: input.extraHosts }),
132
+ ...(input.minBytes === undefined ? {} : { minBytes: input.minBytes }),
133
+ });
134
+ }
135
+
136
+ export const islandShotResult = (artifacts: IslandArtifacts): CommandResult => ({
137
+ ok: artifacts.verdict.ok,
138
+ command: 'shot',
139
+ summary: islandShotSummary(artifacts.verdict),
140
+ lines: islandShotLines(artifacts),
141
+ data: {
142
+ dir: artifacts.dir,
143
+ verdictFile: artifacts.verdictFile,
144
+ verdict: islandVerdictJson(artifacts.verdict),
145
+ },
146
+ });
package/src/cmd-shot.ts CHANGED
@@ -12,16 +12,15 @@ import type { ScrapeDriver, 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, browserBinaryExists, executablePathFrom } from './browser-launcher';
15
- import { startDev } from './cmd-dev';
15
+ import { islandShot, islandShotResult, refuseRouteWithIsland } from './cmd-shot-island';
16
16
  import type { CliCommand, CommandContext } from './command';
17
- import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
18
- import { DEV_BINDING } from './dev-roles';
19
- import { resolveServices } from './dev-services';
20
17
  import { BadFlagError, MissingPositionalError } from './errors';
21
18
  import { intFlagOr, PORT_RANGE } from './flag-number';
22
19
  import type { CommandResult } from './output';
23
20
  import type { ParsedArgs } from './parse';
24
21
  import { flagBool, flagString } from './parse';
22
+ import type { BootDevServer, ShotServer } from './shot-server';
23
+ import { allowHostsFrom, devServerFor, SHOT_DIR } from './shot-server';
25
24
  import { SETTLE_POLL_MS, settleIslands } from './shot-settle';
26
25
  import type { IslandCount, ShotArtifacts } from './shot-verdict';
27
26
  import {
@@ -45,7 +44,11 @@ const DEFAULT_PORT = 0;
45
44
  */
46
45
  export const DEFAULT_SETTLE_MS = IDLE_HYDRATE_TIMEOUT_MS;
47
46
 
48
- export const SHOT_DIR = join('.x', 'shot');
47
+ // Re-exported, not re-declared: `cmd-shot.test.ts` and the island path both name them, and a
48
+ // second declaration of a path or an allow-list rule is a second answer.
49
+ export type { BootDevServer, ShotServer };
50
+ export { allowHostsFrom, devServerFor, SHOT_DIR };
51
+
49
52
  export const SHOT_IMAGE = 'shot.png';
50
53
  export const SHOT_VERDICT = 'verdict.json';
51
54
 
@@ -164,24 +167,6 @@ const intFlag = (
164
167
  fallback,
165
168
  );
166
169
 
167
- /**
168
- * How `devServerFor` starts a scratch server. A parameter with a default rather than a direct
169
- * call, for the reason every `Runner` in this package is one: the failure path below — a boot that
170
- * throws, and the lock it has to hand back — is otherwise only reachable by breaking a real app.
171
- */
172
- export type BootDevServer = (input: {
173
- readonly root: string;
174
- readonly port: number;
175
- readonly env: Readonly<Record<string, string | undefined>>;
176
- }) => Promise<{ readonly url: string; stop(): Promise<void> }>;
177
-
178
- export interface ShotServer {
179
- readonly url: string;
180
- /** Which server the picture is of. Reported, because the two have different failure modes. */
181
- readonly origin: 'booted' | 'reused';
182
- stop(): Promise<void>;
183
- }
184
-
185
170
  export interface ShotRun {
186
171
  readonly route: string;
187
172
  readonly outDir: string;
@@ -274,65 +259,6 @@ export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
274
259
  }
275
260
  }
276
261
 
277
- /**
278
- * One rule, two branches: photograph the `x dev` this checkout already has, or boot a scratch one.
279
- * Reusing is not a convenience — embedded Postgres is a single-writer directory, so a second boot
280
- * on one checkout is `X_DEV_ALREADY_RUNNING` and the picture would never be taken at all.
281
- */
282
- export async function devServerFor(
283
- root: string,
284
- env: Readonly<Record<string, string | undefined>>,
285
- port: number,
286
- boot: BootDevServer = (input) => startDev(input),
287
- ): Promise<ShotServer> {
288
- const services = resolveServices(root, env);
289
- const file = Bun.file(lockPath(services.stateDir));
290
- if (await file.exists()) {
291
- const lock = parseLock(await file.text());
292
- if (lock !== null && isProcessAlive(lock.pid)) {
293
- return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
294
- }
295
- }
296
- const { release } = await preflight({
297
- stateDir: services.stateDir,
298
- port,
299
- hostname: DEV_BINDING.hostname,
300
- embeddedDb: services.db.mode === 'embedded',
301
- });
302
- // The directory is CLAIMED from here down — `preflight` returns holding it, never having merely
303
- // looked — so a boot that throws has to give it back. `cmd-dev.ts` states the same rule at the
304
- // same seam. Without it one failed `x shot` refused every later `x dev` and `x shot` on this
305
- // checkout, naming a pid that had already exited. The original error is re-thrown untouched: a
306
- // teardown must never replace the failure it is cleaning up after.
307
- const dev = await boot({ root, port, env }).catch((error: unknown) => {
308
- release();
309
- throw error;
310
- });
311
- await writeLock(services.stateDir, {
312
- pid: process.pid,
313
- port,
314
- url: dev.url,
315
- startedAt: new Date().toISOString(),
316
- });
317
- return {
318
- url: dev.url,
319
- origin: 'booted',
320
- async stop() {
321
- clearLock(services.stateDir);
322
- await dev.stop();
323
- },
324
- };
325
- }
326
-
327
- /** `--allow-hosts a.com,b.com` on top of the app's own host. Empty means the app's host alone. */
328
- export const allowHostsFrom = (url: string, extra: string | undefined): readonly string[] => {
329
- const named = (extra ?? '')
330
- .split(',')
331
- .map((host) => host.trim())
332
- .filter((host) => host.length > 0);
333
- return [new URL(url).hostname, ...named];
334
- };
335
-
336
262
  export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
337
263
  ok: artifacts.verdict.ok,
338
264
  command: 'shot',
@@ -348,8 +274,9 @@ export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
348
274
  export const shotCommand: CliCommand = {
349
275
  spec: {
350
276
  name: 'shot',
351
- summary: 'photograph one route from a real browser, with a verdict a picture cannot carry',
352
- usage: 'x shot <route> [--port 0] [--out <dir>] [--no-full] [--settle 2000] [--json]',
277
+ summary: 'photograph one route, or one island in a state it declares, from a real browser',
278
+ usage:
279
+ 'x shot <route> | --island <name> [--state <id>] [--port 0] [--out <dir>] [--settle 2000] [--json]',
353
280
  requiresApp: true,
354
281
  flags: [
355
282
  { name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
@@ -359,13 +286,31 @@ export const shotCommand: CliCommand = {
359
286
  { name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
360
287
  { name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
361
288
  { name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
289
+ // A FLAG on `x shot` and never a second command: photographing a route and photographing a
290
+ // component are one job with two subjects, and a parallel command would be the second path
291
+ // axiom 1 refuses.
292
+ {
293
+ name: 'island',
294
+ type: 'string',
295
+ summary: 'photograph one island in every state it declares',
296
+ },
297
+ {
298
+ name: 'state',
299
+ type: 'string',
300
+ summary: 'one declared state of that island, not all of them',
301
+ },
362
302
  ],
363
303
  },
364
304
  async run(ctx: CommandContext): Promise<CommandResult> {
365
305
  const root = requireAppRoot('shot', ctx.cwd).dir;
366
306
  // Every value read before anything boots: a typo must not cost a browser and a dev server to
367
307
  // report, which is the rule `x routes` and `x mcp` already follow.
368
- const route = readRoute(ctx.args.positionals[0]);
308
+ const island = flagString(ctx.args, 'island');
309
+ const positional = ctx.args.positionals[0];
310
+ if (island !== undefined && island !== '' && positional !== undefined) {
311
+ refuseRouteWithIsland(positional, island);
312
+ }
313
+ const route = island === undefined || island === '' ? readRoute(positional) : '';
369
314
  const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
370
315
  const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
371
316
  const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
@@ -375,11 +320,30 @@ export const shotCommand: CliCommand = {
375
320
  flag: 'browser',
376
321
  command: 'shot',
377
322
  reason: `no executable at "${executablePath}"`,
378
- fix: `x shot ${route} --browser /usr/bin/chromium`,
323
+ fix: 'x shot / --browser /usr/bin/chromium',
379
324
  });
380
325
  }
381
326
  const out = flagString(ctx.args, 'out');
382
327
  const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
328
+ if (island !== undefined && island !== '') {
329
+ return islandShotResult(
330
+ await islandShot({
331
+ root,
332
+ island,
333
+ ...(flagString(ctx.args, 'state') === undefined
334
+ ? {}
335
+ : { state: flagString(ctx.args, 'state') }),
336
+ ...(out === undefined ? {} : { out }),
337
+ settleMs,
338
+ timeoutMs,
339
+ ...(executablePath === undefined ? {} : { executablePath }),
340
+ ...(flagString(ctx.args, 'allow-hosts') === undefined
341
+ ? {}
342
+ : { extraHosts: flagString(ctx.args, 'allow-hosts') }),
343
+ boot,
344
+ }),
345
+ );
346
+ }
383
347
  // Resolved before the boot for the same reason: an app with no browser installed must not pay
384
348
  // an embedded Postgres to be told to run `bun add -d puppeteer-core`.
385
349
  const driver = await appBrowser({
@@ -114,6 +114,13 @@ export const CLI_OWNED_ERROR_CODES = [
114
114
  'X_SECRETS_EDIT_FAILED',
115
115
  'X_WORKSPACE_DEP_UNDECLARED',
116
116
  'X_SHOT_BROWSER_MISSING',
117
+ // `x shot --island` — one code per way a component's named state fails to become a picture.
118
+ // The last of the four is the one that gates: it is checked against the expansion computed
119
+ // before a browser existed, so a capture loop that swallowed a failure cannot exit 0.
120
+ 'X_SHOT_ISLAND_STATES_EMPTY',
121
+ 'X_SHOT_ISLAND_UNPHOTOGRAPHABLE',
122
+ 'X_SHOT_ISLAND_UNSTUBBED_REQUEST',
123
+ 'X_SHOT_ISLAND_MISSING',
117
124
  'X_GH_UNAVAILABLE',
118
125
  'X_GH_NOT_AUTHENTICATED',
119
126
  'X_GH_COMMAND_FAILED',
@@ -218,6 +225,10 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
218
225
  X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
219
226
  X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
220
227
  X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
228
+ X_SHOT_ISLAND_STATES_EMPTY: 'an island states file declares no manifest',
229
+ X_SHOT_ISLAND_UNPHOTOGRAPHABLE: 'the island never reached a state worth photographing',
230
+ X_SHOT_ISLAND_UNSTUBBED_REQUEST: 'the island requested something no state stub answers',
231
+ X_SHOT_ISLAND_MISSING: 'a declared island picture is not on disk',
221
232
  X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
222
233
  X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
223
234
  X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',