@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 +60 -0
- package/package.json +28 -28
- package/src/app-load.ts +12 -1
- package/src/browser-launcher.ts +12 -0
- package/src/cmd-dev.ts +11 -0
- package/src/cmd-shot-island.ts +146 -0
- package/src/cmd-shot.ts +50 -86
- package/src/error-codes.ts +11 -0
- package/src/island-harness-route.ts +91 -0
- package/src/island-harness-script.ts +147 -0
- package/src/island-harness.ts +98 -0
- package/src/island-shot-errors.ts +94 -0
- package/src/island-shot.ts +303 -0
- package/src/island-states-load.ts +78 -0
- package/src/island-verdict.ts +194 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +14 -0
- package/src/reexport-manifest.ts +62 -0
- package/src/shot-server.ts +90 -0
- package/src/shot-settle.ts +33 -3
- package/src/ts-scan.ts +34 -5
- package/src/workspace-checks.ts +23 -7
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.
|
|
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.
|
|
41
|
-
"@ultimat3/admin": "11.
|
|
42
|
-
"@ultimat3/ai": "11.
|
|
43
|
-
"@ultimat3/auth": "11.
|
|
44
|
-
"@ultimat3/cache": "11.
|
|
45
|
-
"@ultimat3/core": "11.
|
|
46
|
-
"@ultimat3/db": "11.
|
|
47
|
-
"@ultimat3/entity": "11.
|
|
48
|
-
"@ultimat3/flags": "11.
|
|
49
|
-
"@ultimat3/http": "11.
|
|
50
|
-
"@ultimat3/i18n": "11.
|
|
51
|
-
"@ultimat3/jobs": "11.
|
|
52
|
-
"@ultimat3/mail": "11.
|
|
53
|
-
"@ultimat3/manifest": "11.
|
|
54
|
-
"@ultimat3/mcp": "11.
|
|
55
|
-
"@ultimat3/money": "11.
|
|
56
|
-
"@ultimat3/policy": "11.
|
|
57
|
-
"@ultimat3/pwa": "11.
|
|
58
|
-
"@ultimat3/query": "11.
|
|
59
|
-
"@ultimat3/realtime": "11.
|
|
60
|
-
"@ultimat3/render": "11.
|
|
61
|
-
"@ultimat3/schema": "11.
|
|
62
|
-
"@ultimat3/scraping": "11.
|
|
63
|
-
"@ultimat3/seo": "11.
|
|
64
|
-
"@ultimat3/storage": "11.
|
|
65
|
-
"@ultimat3/testing": "11.
|
|
66
|
-
"@ultimat3/time": "11.
|
|
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))
|
|
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>;
|
package/src/browser-launcher.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
352
|
-
usage:
|
|
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
|
|
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:
|
|
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({
|
package/src/error-codes.ts
CHANGED
|
@@ -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',
|