@ultimat3/cli 19.3.3 → 20.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.
- package/CLAUDE.md +56 -3
- package/package.json +29 -29
- package/src/cmd-shot-island.ts +120 -15
- package/src/cmd-shot.ts +45 -20
- package/src/dev-roles.ts +25 -7
- package/src/dev-sync.ts +74 -6
- package/src/island-capture.ts +278 -0
- package/src/island-harness-script.ts +10 -1
- package/src/island-shot-index.ts +155 -0
- package/src/island-shot.ts +106 -276
- package/src/island-verdict.ts +90 -1
- package/src/messages.ts +2 -1
- package/src/templates/guard-animated-layout-property.ts +269 -0
- package/src/templates/guard-focus-visible.ts +240 -0
- package/src/templates/guard-image-dimensions.ts +225 -0
- package/src/templates/guard-island-without-states.ts +128 -0
- package/src/templates/guard-raw-colour.ts +112 -8
- package/src/templates/guard-semantic-interactive.ts +244 -0
- package/src/templates/guard-untranslated-string.ts +54 -6
- package/src/templates/island.ts +44 -1
- package/src/templates/resource-form-island.ts +67 -0
- package/src/templates/scaffold-claude-agents.ts +10 -1
- package/src/templates/scaffold-docs.ts +11 -0
- package/src/templates/scaffold-guards.ts +15 -0
- package/src/templates/scaffold-repo.ts +12 -1
package/CLAUDE.md
CHANGED
|
@@ -259,6 +259,7 @@ change, and CI does not install one.
|
|
|
259
259
|
|---|---|---|
|
|
260
260
|
| `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` — launching Chrome here, or **attaching** to one over `--cdp-url` / `SCRAPE_CDP_URL`, which is what every stealth provider sells and what `remoteBrowser()` has called its primary path since it shipped | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
|
|
261
261
|
| `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 |
|
|
262
|
+
| `x shot --all-islands` | every `*.island.states.ts` in the app, one boot, one browser per declared VIEWPORT across all of them | a second capture loop — one island and every island are `runIslandSweep` with one argument, because two loops are two answers to what a run produced |
|
|
262
263
|
| `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 |
|
|
263
264
|
| `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
|
|
264
265
|
|
|
@@ -277,10 +278,12 @@ ones a running app will not produce on request. `--island` takes them, one addre
|
|
|
277
278
|
| `island-harness.ts` | the document that mounts ONE island over `data-x-entry` / `data-x-props` |
|
|
278
279
|
| `island-harness-script.ts` | what runs before the chunk does: the sealed network, the pinned clock, the readiness watch |
|
|
279
280
|
| `island-harness-route.ts` | `GET /_x/island`, mounted by `x dev` |
|
|
280
|
-
| `island-shot.ts` | the
|
|
281
|
+
| `island-shot.ts` | the RUN: which islands, in which order, which artifacts — and the missing-shot gate |
|
|
282
|
+
| `island-capture.ts` | ONE picture: the assertions before the shutter, the crop rectangle, the one session |
|
|
283
|
+
| `island-shot-index.ts` | `index.md` as a PURE function of (manifests, targets, verdicts) — no I/O in the rule |
|
|
281
284
|
| `shot-browser.ts` | which browser a run gets — launch one here, or attach over `--cdp-url` / `SCRAPE_CDP_URL` — as three rules over plain inputs |
|
|
282
285
|
| `island-verdict.ts` | the per-state verdict — a PNG cannot say the component threw or logged |
|
|
283
|
-
| `cmd-shot-island.ts` | the flags, and the one browser per declared viewport |
|
|
286
|
+
| `cmd-shot-island.ts` | the flags, the refusals, and the one browser per declared viewport |
|
|
284
287
|
|
|
285
288
|
The vocabulary is **`@ultimat3/testing`'s**, not this package's: `defineIslandStates`,
|
|
286
289
|
`islandShotTargets`, `islandAddress` / `parseIslandAddress`, `findIslandStates`,
|
|
@@ -312,6 +315,43 @@ mount that never finished, a page that never went quiet, a zero-sized box, a box
|
|
|
312
315
|
and no text. Then a byte floor as a backstop. Every one of them otherwise comes out as a plausible
|
|
313
316
|
image of the wrong thing.
|
|
314
317
|
|
|
318
|
+
**`--all-islands` is a spelling, never `--island` with no value.** The parser refuses a bare
|
|
319
|
+
`--island` ("expects a value") and `--island=` is an empty NAME, so "every island" had no form a
|
|
320
|
+
reader could type that could not be read as a mistyped one. A boolean cannot be confused with a
|
|
321
|
+
name. Every contradictory pair is refused BY NAME and before a value is read — beside `--island`,
|
|
322
|
+
beside a route positional, and beside `--state`, which belongs to ONE manifest's vocabulary
|
|
323
|
+
(`empty` in two manifests is two unrelated states). An app declaring no states at all is refused
|
|
324
|
+
too, rather than answered with an empty gallery: "produced nothing and exited 0" is the one outcome
|
|
325
|
+
a reader cannot tell from success. Its `fix:` names `x g island`, and deliberately not a states
|
|
326
|
+
generator — a `fix:` may only cite a command this build ships (`fix-command.ts`), and there is none.
|
|
327
|
+
|
|
328
|
+
**A sweep never aborts on a failure.** Every state the app CAN photograph is captured, every
|
|
329
|
+
per-island `verdict.json` and the index are written, and only then does the missing-picture gate
|
|
330
|
+
turn the reasons into a non-zero exit. One island that will not mount must not cost a reader the
|
|
331
|
+
other nineteen — which is the same rule the per-state loop already followed, one level up.
|
|
332
|
+
|
|
333
|
+
**`.x/shot/island/index.md` is the file an agent opens**, and it is written for a SINGLE-island run
|
|
334
|
+
too: the file that says what a picture IS cannot be a property of how many islands were asked for.
|
|
335
|
+
`island-shot-index.ts` is a pure renderer over (manifests, targets, verdicts) — no disk, no browser
|
|
336
|
+
— and the caller does the writing, which is the split every check in this repo uses. It carries the
|
|
337
|
+
counts, the re-run commands, the verdict's own `blind` list (handed in, never reworded: two
|
|
338
|
+
wordings drift), and per state the `id`, the `title`, the `note` — the *"you cannot reach this by
|
|
339
|
+
clicking, because …"* line, which is the whole reason a reviewer knows what they are looking at.
|
|
340
|
+
|
|
341
|
+
**A console WARNING and an overflowing box are recorded and gate NOTHING.** `stateShotOk` reads
|
|
342
|
+
neither, on purpose: a signal that fails a run is a signal an author switches off. The warnings were
|
|
343
|
+
already in `page.console()` and already in `--json`; what was missing is that nobody counted them.
|
|
344
|
+
The overflow is new and is the readiness probe's own answer — `scrollWidth > clientWidth` on the
|
|
345
|
+
crop target, measured in the same round trip, because a fact measured after the shutter is a fact
|
|
346
|
+
about a different page.
|
|
347
|
+
|
|
348
|
+
**The clip carries a margin, clamped to the document.** It was the readiness box EXACTLY, and a
|
|
349
|
+
pixel-tight rectangle shaves off everything a component paints outside its border box — a
|
|
350
|
+
`box-shadow`, an outline, a focus ring, a hairline border on a subpixel — so a reviewer reads a
|
|
351
|
+
component with no elevation as flat. `ISLAND_CROP_MARGIN_PX` is the one constant; the clamp reads
|
|
352
|
+
the document's own extent off the probe (`page`), never the viewport, and it may only ever make the
|
|
353
|
+
frame BIGGER than the component's box — a clamp that could shrink it would crop the subject.
|
|
354
|
+
|
|
315
355
|
**One session per picture, and that is not an optimisation to collapse.** `page.console()` and
|
|
316
356
|
`page.pageErrors()` are bounded rings over the whole SESSION, so a shared one files state A's
|
|
317
357
|
console errors under state B — and per-state attribution is the half of the artifact that gates.
|
|
@@ -1487,7 +1527,20 @@ refuses). A guard returning `[1n]` is `X_GUARD_FINDING_INVALID`, per candidate,
|
|
|
1487
1527
|
entry costs its own line and not the real findings beside it. The mechanism whose job is producing
|
|
1488
1528
|
structured failures handing back a stack trace is the one outcome it exists to prevent.
|
|
1489
1529
|
|
|
1490
|
-
**`x new` ships
|
|
1530
|
+
**`x new` ships NINE guards, `As of 2026-09-08`** — the four below plus five interface rules
|
|
1531
|
+
(`semantic-interactive`, `focus-visible`, `image-dimensions`, `animated-layout-property`,
|
|
1532
|
+
`island-without-states`), each statically decidable and each carrying a legitimate-lookalike test
|
|
1533
|
+
that must NOT be reported. **Two of the original four were broken the whole time and nobody could
|
|
1534
|
+
see it**, because no guard had ever run against a real app: `raw-colour` reported all 87 uses of
|
|
1535
|
+
`rgb(var(--color-…))` — which IS the token form `_colors.scss` emits, so its own cause line ("a
|
|
1536
|
+
value no theme can restate") was false of every one — and `untranslated-string`'s JSX mask
|
|
1537
|
+
`/\{[^{}]*\}/g` does not nest, so `{t('k', { org: x })}` lost its inner brace group first and the
|
|
1538
|
+
remnant read as prose, flagging every `t()` call with an interpolation object or a template-literal
|
|
1539
|
+
key. Both are fixed and both now report zero against `examples/dummy`, which is the first tracked
|
|
1540
|
+
app to carry a `guards/` directory at all. Read the count, never this sentence:
|
|
1541
|
+
`bun -e "import {scaffoldGuardFiles} from './packages/cli/src/templates/scaffold-guards'; console.log(scaffoldGuardFiles().length)"`.
|
|
1542
|
+
|
|
1543
|
+
The scaffolded `AGENTS.md` states nine
|
|
1491
1544
|
non-negotiables, and five of them used to be prose — each proven green on `x verify`: a hardcoded
|
|
1492
1545
|
JSX string beside a `t()` call, `color: #ff0000` in a stylesheet whose own scaffolded header called
|
|
1493
1546
|
it "a lint failure", `toLocaleDateString('en-US')` with no `timeZone`, `t.number` money, and a bare
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "20.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/auth": "
|
|
44
|
-
"@ultimat3/cache": "
|
|
45
|
-
"@ultimat3/core": "
|
|
46
|
-
"@ultimat3/db": "
|
|
47
|
-
"@ultimat3/entity": "
|
|
48
|
-
"@ultimat3/flags": "
|
|
49
|
-
"@ultimat3/http": "
|
|
50
|
-
"@ultimat3/i18n": "
|
|
51
|
-
"@ultimat3/jobs": "
|
|
52
|
-
"@ultimat3/mail": "
|
|
53
|
-
"@ultimat3/manifest": "
|
|
54
|
-
"@ultimat3/mcp": "
|
|
55
|
-
"@ultimat3/money": "
|
|
56
|
-
"@ultimat3/notify": "
|
|
57
|
-
"@ultimat3/policy": "
|
|
58
|
-
"@ultimat3/pwa": "
|
|
59
|
-
"@ultimat3/query": "
|
|
60
|
-
"@ultimat3/realtime": "
|
|
61
|
-
"@ultimat3/render": "
|
|
62
|
-
"@ultimat3/schema": "
|
|
63
|
-
"@ultimat3/scraping": "
|
|
64
|
-
"@ultimat3/seo": "
|
|
65
|
-
"@ultimat3/storage": "
|
|
66
|
-
"@ultimat3/testing": "
|
|
67
|
-
"@ultimat3/time": "
|
|
40
|
+
"@ultimat3/action": "20.0.0",
|
|
41
|
+
"@ultimat3/admin": "20.0.0",
|
|
42
|
+
"@ultimat3/ai": "20.0.0",
|
|
43
|
+
"@ultimat3/auth": "20.0.0",
|
|
44
|
+
"@ultimat3/cache": "20.0.0",
|
|
45
|
+
"@ultimat3/core": "20.0.0",
|
|
46
|
+
"@ultimat3/db": "20.0.0",
|
|
47
|
+
"@ultimat3/entity": "20.0.0",
|
|
48
|
+
"@ultimat3/flags": "20.0.0",
|
|
49
|
+
"@ultimat3/http": "20.0.0",
|
|
50
|
+
"@ultimat3/i18n": "20.0.0",
|
|
51
|
+
"@ultimat3/jobs": "20.0.0",
|
|
52
|
+
"@ultimat3/mail": "20.0.0",
|
|
53
|
+
"@ultimat3/manifest": "20.0.0",
|
|
54
|
+
"@ultimat3/mcp": "20.0.0",
|
|
55
|
+
"@ultimat3/money": "20.0.0",
|
|
56
|
+
"@ultimat3/notify": "20.0.0",
|
|
57
|
+
"@ultimat3/policy": "20.0.0",
|
|
58
|
+
"@ultimat3/pwa": "20.0.0",
|
|
59
|
+
"@ultimat3/query": "20.0.0",
|
|
60
|
+
"@ultimat3/realtime": "20.0.0",
|
|
61
|
+
"@ultimat3/render": "20.0.0",
|
|
62
|
+
"@ultimat3/schema": "20.0.0",
|
|
63
|
+
"@ultimat3/scraping": "20.0.0",
|
|
64
|
+
"@ultimat3/seo": "20.0.0",
|
|
65
|
+
"@ultimat3/storage": "20.0.0",
|
|
66
|
+
"@ultimat3/testing": "20.0.0",
|
|
67
|
+
"@ultimat3/time": "20.0.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/cmd-shot-island.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// `--island`
|
|
2
|
-
// that cannot mean anything, and hand
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// `--island` and `--all-islands`: read the flags, resolve the manifests, refuse the combinations
|
|
2
|
+
// that cannot mean anything, and hand the run its values. Split from `cmd-shot.ts` so neither file
|
|
3
|
+
// has to hold both a route capture and a component capture — the two share a boot, a browser and
|
|
4
|
+
// an output tree, and nothing else.
|
|
5
|
+
//
|
|
6
|
+
// One island and every island are one code path with one argument (`runIslandSweep`), never two
|
|
7
|
+
// loops: two would be two answers to what a run produced, and the sweep is the form an agent
|
|
8
|
+
// reaches for first.
|
|
5
9
|
|
|
6
10
|
// why: no Bun native joins or resolves a path; `--out` is resolved against the app root.
|
|
7
11
|
import { join, resolve } from 'node:path';
|
|
@@ -11,10 +15,16 @@ import { findIslandStates } from '@ultimat3/testing';
|
|
|
11
15
|
import { appBrowser } from './browser-launcher';
|
|
12
16
|
import { BadFlagError } from './errors';
|
|
13
17
|
import type { IslandBrowser } from './island-shot';
|
|
14
|
-
import { ISLAND_SHOT_DIR, runIslandShot } from './island-shot';
|
|
18
|
+
import { ISLAND_SHOT_DIR, runIslandShot, runIslandSweep } from './island-shot';
|
|
15
19
|
import { loadIslandStates } from './island-states-load';
|
|
16
|
-
import type { IslandArtifacts } from './island-verdict';
|
|
17
|
-
import {
|
|
20
|
+
import type { IslandArtifacts, IslandSweepArtifacts } from './island-verdict';
|
|
21
|
+
import {
|
|
22
|
+
islandShotLines,
|
|
23
|
+
islandShotSummary,
|
|
24
|
+
islandSweepLines,
|
|
25
|
+
islandSweepSummary,
|
|
26
|
+
islandVerdictJson,
|
|
27
|
+
} from './island-verdict';
|
|
18
28
|
import type { CommandResult } from './output';
|
|
19
29
|
import type { ShotServer } from './shot-server';
|
|
20
30
|
import { SHOT_DIR } from './shot-server';
|
|
@@ -79,6 +89,62 @@ export function refuseRouteWithIsland(route: string | undefined, island: string)
|
|
|
79
89
|
});
|
|
80
90
|
}
|
|
81
91
|
|
|
92
|
+
/**
|
|
93
|
+
* An app with no `*.island.states.ts` anywhere. Refused by NAME rather than answered with an empty
|
|
94
|
+
* gallery: a run that produces no picture and exits 0 is the one outcome a reader cannot tell from
|
|
95
|
+
* success, and it is what this whole command exists to make impossible.
|
|
96
|
+
*
|
|
97
|
+
* The `fix:` is the generator that exists. It deliberately does not name a states generator: a
|
|
98
|
+
* `fix:` may only cite a command this build ships (`fix-command.ts`), and there is none.
|
|
99
|
+
*/
|
|
100
|
+
export function refuseNoIslandStates(flag: 'island' | 'all-islands'): never {
|
|
101
|
+
throw new BadFlagError({
|
|
102
|
+
flag,
|
|
103
|
+
command: 'shot',
|
|
104
|
+
reason: 'this app declares no island states at all, so there is nothing to photograph',
|
|
105
|
+
fix: "x g island settings --at apps/web/app/settings # then declare its states beside it with defineIslandStates({ island: '…', states: [...] })",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `--all-islands` is the WHOLE app and `--island <name>` is one component, so naming both is two
|
|
111
|
+
* subjects and one command. Refused by name for `refuseRouteWithIsland`'s reason: a reader who
|
|
112
|
+
* typed both has a belief about which one runs, and half of them would be wrong.
|
|
113
|
+
*/
|
|
114
|
+
export function refuseSweepWithIsland(island: string): never {
|
|
115
|
+
throw new BadFlagError({
|
|
116
|
+
flag: 'all-islands',
|
|
117
|
+
command: 'shot',
|
|
118
|
+
reason: `--all-islands photographs every island and --island ${island} photographs one; x shot takes one subject`,
|
|
119
|
+
fix: 'x shot --all-islands --json',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A route and every island are two subjects too, and the same rule decides it. */
|
|
124
|
+
export function refuseSweepWithRoute(route: string): never {
|
|
125
|
+
throw new BadFlagError({
|
|
126
|
+
flag: 'all-islands',
|
|
127
|
+
command: 'shot',
|
|
128
|
+
reason: `--all-islands photographs every island and "${route}" is a route; x shot takes one subject`,
|
|
129
|
+
fix: 'x shot --all-islands --json',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A state id belongs to ONE island's vocabulary — `empty` in two manifests is two unrelated
|
|
135
|
+
* states — so a filter across every island either means nothing or silently means whichever
|
|
136
|
+
* islands happen to share the word. The fix names the one-island form, which is where a state id
|
|
137
|
+
* can be resolved at all.
|
|
138
|
+
*/
|
|
139
|
+
export function refuseSweepWithState(state: string): never {
|
|
140
|
+
throw new BadFlagError({
|
|
141
|
+
flag: 'state',
|
|
142
|
+
command: 'shot',
|
|
143
|
+
reason: `--state ${state} names one island's declared state, and --all-islands spans every island`,
|
|
144
|
+
fix: 'x shot --island <name> --state <id> --json',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
82
148
|
export interface IslandShotInput {
|
|
83
149
|
readonly root: string;
|
|
84
150
|
readonly island: string;
|
|
@@ -96,6 +162,9 @@ export interface IslandShotInput {
|
|
|
96
162
|
readonly minBytes?: number | undefined;
|
|
97
163
|
}
|
|
98
164
|
|
|
165
|
+
/** Every field the one-island form takes, minus the two that name one island and one state. */
|
|
166
|
+
export type IslandSweepInput = Omit<IslandShotInput, 'island' | 'state'>;
|
|
167
|
+
|
|
99
168
|
/**
|
|
100
169
|
* The states are loaded, expanded and the name resolved BEFORE a browser or a dev server exists —
|
|
101
170
|
* a typo must not cost an embedded Postgres to report, and the expected picture list has to be
|
|
@@ -103,14 +172,7 @@ export interface IslandShotInput {
|
|
|
103
172
|
*/
|
|
104
173
|
export async function islandShot(input: IslandShotInput): Promise<IslandArtifacts> {
|
|
105
174
|
const all = await loadIslandStates(input.root);
|
|
106
|
-
if (all.length === 0)
|
|
107
|
-
throw new BadFlagError({
|
|
108
|
-
flag: 'island',
|
|
109
|
-
command: 'shot',
|
|
110
|
-
reason: 'this app declares no island states at all, so there is nothing to photograph',
|
|
111
|
-
fix: "x g island settings --at apps/web/app/settings # then declare its states beside it with defineIslandStates({ island: '…', states: [...] })",
|
|
112
|
-
});
|
|
113
|
-
}
|
|
175
|
+
if (all.length === 0) refuseNoIslandStates('island');
|
|
114
176
|
// `findIslandStates` is loose on the way in — `Settings`, `settings`, `settings.island.tsx` and
|
|
115
177
|
// the full path are one name — and refuses an unresolved one by listing every valid name, which
|
|
116
178
|
// is what tells a typo apart from an island whose states were never declared.
|
|
@@ -138,6 +200,48 @@ export async function islandShot(input: IslandShotInput): Promise<IslandArtifact
|
|
|
138
200
|
});
|
|
139
201
|
}
|
|
140
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Every island in the app, one boot, one browser per declared viewport across ALL of them — the
|
|
205
|
+
* memo lives in the `islandBrowser` closure built here, so a sweep of forty states at one size is
|
|
206
|
+
* still one launch. The manifests are loaded before a browser or a dev server exists, for
|
|
207
|
+
* `islandShot`'s reason: the complete expected picture list has to be knowable without either.
|
|
208
|
+
*/
|
|
209
|
+
export async function islandSweep(input: IslandSweepInput): Promise<IslandSweepArtifacts> {
|
|
210
|
+
const all = await loadIslandStates(input.root);
|
|
211
|
+
if (all.length === 0) refuseNoIslandStates('all-islands');
|
|
212
|
+
return runIslandSweep({
|
|
213
|
+
manifests: all,
|
|
214
|
+
outDir:
|
|
215
|
+
input.out === undefined
|
|
216
|
+
? join(input.root, SHOT_DIR, ISLAND_SHOT_DIR)
|
|
217
|
+
: resolve(input.root, input.out),
|
|
218
|
+
driver:
|
|
219
|
+
input.driver ??
|
|
220
|
+
islandBrowser({
|
|
221
|
+
root: input.root,
|
|
222
|
+
...(input.executablePath === undefined ? {} : { executablePath: input.executablePath }),
|
|
223
|
+
...(input.cdpUrl === undefined ? {} : { cdpUrl: input.cdpUrl }),
|
|
224
|
+
}),
|
|
225
|
+
boot: input.boot,
|
|
226
|
+
settleMs: input.settleMs,
|
|
227
|
+
timeoutMs: input.timeoutMs,
|
|
228
|
+
...(input.extraHosts === undefined ? {} : { extraHosts: input.extraHosts }),
|
|
229
|
+
...(input.minBytes === undefined ? {} : { minBytes: input.minBytes }),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export const islandSweepResult = (artifacts: IslandSweepArtifacts): CommandResult => ({
|
|
234
|
+
ok: artifacts.ok,
|
|
235
|
+
command: 'shot',
|
|
236
|
+
summary: islandSweepSummary(artifacts),
|
|
237
|
+
lines: islandSweepLines(artifacts),
|
|
238
|
+
data: {
|
|
239
|
+
dir: artifacts.dir,
|
|
240
|
+
indexFile: artifacts.indexFile,
|
|
241
|
+
islands: artifacts.verdicts.map(islandVerdictJson),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
141
245
|
export const islandShotResult = (artifacts: IslandArtifacts): CommandResult => ({
|
|
142
246
|
ok: artifacts.verdict.ok,
|
|
143
247
|
command: 'shot',
|
|
@@ -146,6 +250,7 @@ export const islandShotResult = (artifacts: IslandArtifacts): CommandResult => (
|
|
|
146
250
|
data: {
|
|
147
251
|
dir: artifacts.dir,
|
|
148
252
|
verdictFile: artifacts.verdictFile,
|
|
253
|
+
indexFile: artifacts.indexFile,
|
|
149
254
|
verdict: islandVerdictJson(artifacts.verdict),
|
|
150
255
|
},
|
|
151
256
|
});
|
package/src/cmd-shot.ts
CHANGED
|
@@ -12,7 +12,16 @@ 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 } from './browser-launcher';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
islandShot,
|
|
17
|
+
islandShotResult,
|
|
18
|
+
islandSweep,
|
|
19
|
+
islandSweepResult,
|
|
20
|
+
refuseRouteWithIsland,
|
|
21
|
+
refuseSweepWithIsland,
|
|
22
|
+
refuseSweepWithRoute,
|
|
23
|
+
refuseSweepWithState,
|
|
24
|
+
} from './cmd-shot-island';
|
|
16
25
|
import type { CliCommand, CommandContext } from './command';
|
|
17
26
|
import { BadFlagError, MissingPositionalError } from './errors';
|
|
18
27
|
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
@@ -275,9 +284,9 @@ export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
|
|
|
275
284
|
export const shotCommand: CliCommand = {
|
|
276
285
|
spec: {
|
|
277
286
|
name: 'shot',
|
|
278
|
-
summary: 'photograph one route,
|
|
287
|
+
summary: 'photograph one route, one island in a state it declares, or every island in the app',
|
|
279
288
|
usage:
|
|
280
|
-
'x shot <route> | --island <name> [--state <id>] [--port 0] [--out <dir>] [--settle 2000] [--json]',
|
|
289
|
+
'x shot <route> | --island <name> [--state <id>] | --all-islands [--port 0] [--out <dir>] [--settle 2000] [--json]',
|
|
281
290
|
requiresApp: true,
|
|
282
291
|
flags: [
|
|
283
292
|
{ name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
|
|
@@ -305,6 +314,15 @@ export const shotCommand: CliCommand = {
|
|
|
305
314
|
type: 'string',
|
|
306
315
|
summary: 'one declared state of that island, not all of them',
|
|
307
316
|
},
|
|
317
|
+
// Its own SPELLING and never `--island` with no value: the parser refuses a bare `--island`
|
|
318
|
+
// ("expects a value") and `--island=` is an empty name, so "every island" had no form a
|
|
319
|
+
// reader could type that could not be read as a mistyped one. A boolean cannot be confused
|
|
320
|
+
// with a name, and `x shot --all-islands` says what it does beside `x shot --island <name>`.
|
|
321
|
+
{
|
|
322
|
+
name: 'all-islands',
|
|
323
|
+
type: 'boolean',
|
|
324
|
+
summary: 'every island in the app, in every state it declares, plus an index.md',
|
|
325
|
+
},
|
|
308
326
|
],
|
|
309
327
|
},
|
|
310
328
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
@@ -312,11 +330,20 @@ export const shotCommand: CliCommand = {
|
|
|
312
330
|
// Every value read before anything boots: a typo must not cost a browser and a dev server to
|
|
313
331
|
// report, which is the rule `x routes` and `x mcp` already follow.
|
|
314
332
|
const island = flagString(ctx.args, 'island');
|
|
333
|
+
const state = flagString(ctx.args, 'state');
|
|
315
334
|
const positional = ctx.args.positionals[0];
|
|
335
|
+
const sweep = flagBool(ctx.args, 'all-islands');
|
|
336
|
+
// Every ambiguous pair refused BY NAME, before a value is read: a reader who typed two
|
|
337
|
+
// subjects has a belief about which one runs, and half of them would be wrong.
|
|
338
|
+
if (sweep && island !== undefined && island !== '') refuseSweepWithIsland(island);
|
|
339
|
+
if (sweep && positional !== undefined) refuseSweepWithRoute(positional);
|
|
340
|
+
// A state id is one manifest's vocabulary, so it cannot mean anything across every island.
|
|
341
|
+
if (sweep && state !== undefined && state !== '') refuseSweepWithState(state);
|
|
316
342
|
if (island !== undefined && island !== '' && positional !== undefined) {
|
|
317
343
|
refuseRouteWithIsland(positional, island);
|
|
318
344
|
}
|
|
319
|
-
const
|
|
345
|
+
const component = sweep || (island !== undefined && island !== '');
|
|
346
|
+
const route = component ? '' : readRoute(positional);
|
|
320
347
|
const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
|
|
321
348
|
const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
|
|
322
349
|
const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
|
|
@@ -332,24 +359,22 @@ export const shotCommand: CliCommand = {
|
|
|
332
359
|
});
|
|
333
360
|
const out = flagString(ctx.args, 'out');
|
|
334
361
|
const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
|
|
362
|
+
const shared = {
|
|
363
|
+
root,
|
|
364
|
+
...(out === undefined ? {} : { out }),
|
|
365
|
+
settleMs,
|
|
366
|
+
timeoutMs,
|
|
367
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
368
|
+
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
369
|
+
...(flagString(ctx.args, 'allow-hosts') === undefined
|
|
370
|
+
? {}
|
|
371
|
+
: { extraHosts: flagString(ctx.args, 'allow-hosts') }),
|
|
372
|
+
boot,
|
|
373
|
+
};
|
|
374
|
+
if (sweep) return islandSweepResult(await islandSweep(shared));
|
|
335
375
|
if (island !== undefined && island !== '') {
|
|
336
376
|
return islandShotResult(
|
|
337
|
-
await islandShot({
|
|
338
|
-
root,
|
|
339
|
-
island,
|
|
340
|
-
...(flagString(ctx.args, 'state') === undefined
|
|
341
|
-
? {}
|
|
342
|
-
: { state: flagString(ctx.args, 'state') }),
|
|
343
|
-
...(out === undefined ? {} : { out }),
|
|
344
|
-
settleMs,
|
|
345
|
-
timeoutMs,
|
|
346
|
-
...(executablePath === undefined ? {} : { executablePath }),
|
|
347
|
-
...(cdpUrl === undefined ? {} : { cdpUrl }),
|
|
348
|
-
...(flagString(ctx.args, 'allow-hosts') === undefined
|
|
349
|
-
? {}
|
|
350
|
-
: { extraHosts: flagString(ctx.args, 'allow-hosts') }),
|
|
351
|
-
boot,
|
|
352
|
-
}),
|
|
377
|
+
await islandShot({ ...shared, island, ...(state === undefined ? {} : { state }) }),
|
|
353
378
|
);
|
|
354
379
|
}
|
|
355
380
|
// Resolved before the boot for the same reason: an app with no browser installed must not pay
|
package/src/dev-roles.ts
CHANGED
|
@@ -8,7 +8,13 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
10
|
import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
RateLimitStore,
|
|
13
|
+
Route,
|
|
14
|
+
ServerHandle,
|
|
15
|
+
ServerHooks,
|
|
16
|
+
WebSocketMount,
|
|
17
|
+
} from '@ultimat3/http';
|
|
12
18
|
import {
|
|
13
19
|
configuredAuthenticator,
|
|
14
20
|
configuredHttp,
|
|
@@ -26,7 +32,7 @@ import {
|
|
|
26
32
|
jobDriver,
|
|
27
33
|
pgSchedulerState,
|
|
28
34
|
} from '@ultimat3/jobs';
|
|
29
|
-
import type { LiveQueryRegistry } from '@ultimat3/realtime/server';
|
|
35
|
+
import type { LiveQueryRegistry, SyncWs } from '@ultimat3/realtime/server';
|
|
30
36
|
import type { LiveReplicator } from '@ultimat3/testing';
|
|
31
37
|
import { devHooks } from './dev-hooks';
|
|
32
38
|
import type { LiveFeed } from './dev-live-feed';
|
|
@@ -36,7 +42,7 @@ import type { RunningReplicator } from './dev-replicator';
|
|
|
36
42
|
import { startReplicator } from './dev-replicator';
|
|
37
43
|
import type { RunningServices } from './dev-runtime';
|
|
38
44
|
import type { Env } from './dev-services';
|
|
39
|
-
import {
|
|
45
|
+
import { prepareSync, type RunningSync } from './dev-sync';
|
|
40
46
|
import { errorPageHook } from './error-pages';
|
|
41
47
|
import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
|
|
42
48
|
import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
@@ -270,7 +276,9 @@ function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefin
|
|
|
270
276
|
return supplied;
|
|
271
277
|
}
|
|
272
278
|
|
|
273
|
-
|
|
279
|
+
/** `mount` is the sync node's socket, served on THIS port as well as its own — why, in `dev-sync`.
|
|
280
|
+
* Undefined without the `sync` role, and then this server opens no websocket, as it always did. */
|
|
281
|
+
function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): ServerHandle {
|
|
274
282
|
warnIfUnauthenticatable(options.routes);
|
|
275
283
|
const binding = options.http ?? DEV_BINDING;
|
|
276
284
|
const hops = trustedHopsFromEnv(options.env);
|
|
@@ -278,6 +286,7 @@ function startWeb(options: StartRolesOptions): ServerHandle {
|
|
|
278
286
|
return createServer({
|
|
279
287
|
routes: options.routes,
|
|
280
288
|
role: 'web',
|
|
289
|
+
...(mount === undefined ? {} : { websocket: mount }),
|
|
281
290
|
hooks: devHooks({
|
|
282
291
|
...(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
|
|
283
292
|
...(options.root === undefined ? {} : { errorPage: errorPageHook(options.root) }),
|
|
@@ -373,11 +382,20 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
373
382
|
});
|
|
374
383
|
started.push(async () => metrics.stop());
|
|
375
384
|
|
|
376
|
-
|
|
385
|
+
// BUILT here, BOUND below, the web role between them: `web` serves the node's socket on its
|
|
386
|
+
// own port and a listening server cannot be handed one, while the neighbouring-port refusals
|
|
387
|
+
// are only the right answer once the web port's own has been given.
|
|
388
|
+
const prepared = selected.includes('sync') ? await prepareSync(options) : null;
|
|
389
|
+
// One rollback entry, kept current — two would stop the node twice out of a failed boot.
|
|
390
|
+
let releaseSync = prepared?.stop ?? null;
|
|
391
|
+
if (prepared !== null) started.push(async () => await releaseSync?.());
|
|
392
|
+
|
|
393
|
+
const server = selected.includes('web') ? startWeb(options, prepared?.mount) : null;
|
|
377
394
|
if (server !== null) started.push(() => server.stop());
|
|
378
395
|
|
|
379
|
-
const sync
|
|
380
|
-
|
|
396
|
+
const sync: RunningSync | null =
|
|
397
|
+
prepared === null ? null : await prepared.listen(server === null ? null : server.url());
|
|
398
|
+
if (sync !== null) releaseSync = sync.stop;
|
|
381
399
|
|
|
382
400
|
const worker = selected.includes('worker')
|
|
383
401
|
? createWorker({
|
package/src/dev-sync.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// and a listener of its own — and because that file is the boot's index, not its detail.
|
|
4
4
|
|
|
5
5
|
import { createContext, logger, UltimateError } from '@ultimat3/core';
|
|
6
|
+
import type { WebSocketMount } from '@ultimat3/http';
|
|
6
7
|
import { listQueries } from '@ultimat3/query';
|
|
8
|
+
import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
|
|
7
9
|
import {
|
|
8
10
|
ChannelHub,
|
|
9
11
|
createSyncNode,
|
|
@@ -98,6 +100,32 @@ export function syncPortFor(port: number): number {
|
|
|
98
100
|
return port + 1;
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
/**
|
|
104
|
+
* A node that is built and subscribed but bound to nothing yet — the one moment the `web` role can
|
|
105
|
+
* still mount its socket, since `web` binds its port first and a mount handed over after that is a
|
|
106
|
+
* server already listening without it.
|
|
107
|
+
*/
|
|
108
|
+
export interface PreparedSync {
|
|
109
|
+
readonly node: SyncNode;
|
|
110
|
+
readonly registry: LiveQueryRegistry;
|
|
111
|
+
/**
|
|
112
|
+
* The socket as the WEB role can serve it, on the port that role already publishes.
|
|
113
|
+
*
|
|
114
|
+
* `x dev` does BOTH: this mount on `PORT`, and the node's own listener on `PORT + 1` below. They
|
|
115
|
+
* are one node behind two doors, not two nodes — the sockets share the registry, the grants and
|
|
116
|
+
* the change subscription — and which door a browser uses is whichever one it can reach.
|
|
117
|
+
* `docker/` publishes the second; a laptop reached through one forwarded port uses the first.
|
|
118
|
+
*/
|
|
119
|
+
readonly mount: WebSocketMount<SyncWs>;
|
|
120
|
+
/**
|
|
121
|
+
* Bind `PORT + 1`, and answer with the same object `startSync` always did. `appUrl` is the web
|
|
122
|
+
* role's own origin when one runs here, for the line below that names both doors.
|
|
123
|
+
*/
|
|
124
|
+
listen(appUrl: string | null): Promise<RunningSync>;
|
|
125
|
+
/** Release the node when nothing ever bound it — a `web` role that threw after it was built. */
|
|
126
|
+
stop(): Promise<void>;
|
|
127
|
+
}
|
|
128
|
+
|
|
101
129
|
/** What `startRoles` holds on to: where the node listens, and how to take it down. */
|
|
102
130
|
export interface RunningSync {
|
|
103
131
|
readonly url: string;
|
|
@@ -141,13 +169,10 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
|
|
|
141
169
|
}
|
|
142
170
|
|
|
143
171
|
/**
|
|
144
|
-
* The
|
|
145
|
-
*
|
|
146
|
-
* Port 0 is passed straight through rather than incremented — `+ 1` would ask the kernel for
|
|
147
|
-
* port 1 instead of an ephemeral one — and the reported url is the listener's own bound address,
|
|
148
|
-
* never a string built from the port that was requested.
|
|
172
|
+
* The node itself: its hub, its registry, its authenticator and its change subscription. Nothing
|
|
173
|
+
* bound — `listen()` and the `web` role's mount are the two doors, and this is what is behind both.
|
|
149
174
|
*/
|
|
150
|
-
export async function
|
|
175
|
+
export async function prepareSync(options: StartRolesOptions): Promise<PreparedSync> {
|
|
151
176
|
const sockets = new SocketRegistry();
|
|
152
177
|
const hub = new ChannelHub({ transport: options.runtime.transport, sockets });
|
|
153
178
|
// The node evaluated no credential of its own and no host ever handed it one, so every socket
|
|
@@ -180,6 +205,35 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
180
205
|
}),
|
|
181
206
|
});
|
|
182
207
|
await node.start();
|
|
208
|
+
return {
|
|
209
|
+
node,
|
|
210
|
+
registry,
|
|
211
|
+
// The node's OWN path, asked rather than restated: `SyncNodeOptions.path` is settable and a
|
|
212
|
+
// second copy of `/_x/sync` here is the copy that stays behind when it moves.
|
|
213
|
+
mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
|
|
214
|
+
stop: () => node.stop(),
|
|
215
|
+
listen: async (appUrl) => await listen(options, node, registry, appUrl),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The node's own socket, on `PORT + 1`. The sync role owns it because websockets and the request
|
|
221
|
+
* pipeline drain differently, and `docker/` publishes it as a service of its own.
|
|
222
|
+
*
|
|
223
|
+
* Kept a step of its own so `web` binds BEFORE it: the two refusals below are about a taken
|
|
224
|
+
* neighbouring port, and reversing the order would answer a second `x dev` on this checkout with
|
|
225
|
+
* "port 3001 is in use" when the fact worth printing is that 3000 is.
|
|
226
|
+
*
|
|
227
|
+
* Port 0 is passed straight through rather than incremented — `+ 1` would ask the kernel for
|
|
228
|
+
* port 1 instead of an ephemeral one — and the reported url is the listener's own bound address,
|
|
229
|
+
* never a string built from the port that was requested.
|
|
230
|
+
*/
|
|
231
|
+
async function listen(
|
|
232
|
+
options: StartRolesOptions,
|
|
233
|
+
node: SyncNode,
|
|
234
|
+
registry: LiveQueryRegistry,
|
|
235
|
+
appUrl: string | null,
|
|
236
|
+
): Promise<RunningSync> {
|
|
183
237
|
const port = syncPortFor(options.port);
|
|
184
238
|
try {
|
|
185
239
|
// The SAME interface the web role binds, resolved from the same option and the same default.
|
|
@@ -192,6 +246,14 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
192
246
|
// seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a reconnect frame
|
|
193
247
|
// whose target does not exist yet.
|
|
194
248
|
const listener = listenSyncNode(node, { port, hostname: binding.hostname, drainGraceMs: 0 });
|
|
249
|
+
// BOTH doors, named, once. `sync node ready` said only that a node existed: the first question
|
|
250
|
+
// a failing browser socket raises — "is the ws server up, and where?" — had no answer anywhere
|
|
251
|
+
// in the boot output, and the port was never printed at all. It is also what an editor's port
|
|
252
|
+
// forwarding reads: a url in the terminal is how VS Code and a Codespace learn a port exists.
|
|
253
|
+
logger.info('sync reachable', {
|
|
254
|
+
node: `${listener.url}${node.path}`,
|
|
255
|
+
app: appUrl === null ? null : `${appUrl}${node.path}`,
|
|
256
|
+
});
|
|
195
257
|
return {
|
|
196
258
|
url: listener.url,
|
|
197
259
|
registry,
|
|
@@ -207,3 +269,9 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
|
|
|
207
269
|
throw error;
|
|
208
270
|
}
|
|
209
271
|
}
|
|
272
|
+
|
|
273
|
+
/** Both steps, for a caller with no web role to mount anything on. */
|
|
274
|
+
export async function startSync(options: StartRolesOptions): Promise<RunningSync> {
|
|
275
|
+
const prepared = await prepareSync(options);
|
|
276
|
+
return await prepared.listen(null);
|
|
277
|
+
}
|