@ultimat3/cli 19.4.0 → 20.1.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
@@ -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 capture loop, the assertions before each shutter, the missing-shot gate |
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 four guards, `As of 2026-08-22`.** The scaffolded `AGENTS.md` states nine
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": "19.4.0",
3
+ "version": "20.1.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": "19.4.0",
41
- "@ultimat3/admin": "19.4.0",
42
- "@ultimat3/ai": "19.4.0",
43
- "@ultimat3/auth": "19.4.0",
44
- "@ultimat3/cache": "19.4.0",
45
- "@ultimat3/core": "19.4.0",
46
- "@ultimat3/db": "19.4.0",
47
- "@ultimat3/entity": "19.4.0",
48
- "@ultimat3/flags": "19.4.0",
49
- "@ultimat3/http": "19.4.0",
50
- "@ultimat3/i18n": "19.4.0",
51
- "@ultimat3/jobs": "19.4.0",
52
- "@ultimat3/mail": "19.4.0",
53
- "@ultimat3/manifest": "19.4.0",
54
- "@ultimat3/mcp": "19.4.0",
55
- "@ultimat3/money": "19.4.0",
56
- "@ultimat3/notify": "19.4.0",
57
- "@ultimat3/policy": "19.4.0",
58
- "@ultimat3/pwa": "19.4.0",
59
- "@ultimat3/query": "19.4.0",
60
- "@ultimat3/realtime": "19.4.0",
61
- "@ultimat3/render": "19.4.0",
62
- "@ultimat3/schema": "19.4.0",
63
- "@ultimat3/scraping": "19.4.0",
64
- "@ultimat3/seo": "19.4.0",
65
- "@ultimat3/storage": "19.4.0",
66
- "@ultimat3/testing": "19.4.0",
67
- "@ultimat3/time": "19.4.0",
40
+ "@ultimat3/action": "20.1.0",
41
+ "@ultimat3/admin": "20.1.0",
42
+ "@ultimat3/ai": "20.1.0",
43
+ "@ultimat3/auth": "20.1.0",
44
+ "@ultimat3/cache": "20.1.0",
45
+ "@ultimat3/core": "20.1.0",
46
+ "@ultimat3/db": "20.1.0",
47
+ "@ultimat3/entity": "20.1.0",
48
+ "@ultimat3/flags": "20.1.0",
49
+ "@ultimat3/http": "20.1.0",
50
+ "@ultimat3/i18n": "20.1.0",
51
+ "@ultimat3/jobs": "20.1.0",
52
+ "@ultimat3/mail": "20.1.0",
53
+ "@ultimat3/manifest": "20.1.0",
54
+ "@ultimat3/mcp": "20.1.0",
55
+ "@ultimat3/money": "20.1.0",
56
+ "@ultimat3/notify": "20.1.0",
57
+ "@ultimat3/policy": "20.1.0",
58
+ "@ultimat3/pwa": "20.1.0",
59
+ "@ultimat3/query": "20.1.0",
60
+ "@ultimat3/realtime": "20.1.0",
61
+ "@ultimat3/render": "20.1.0",
62
+ "@ultimat3/schema": "20.1.0",
63
+ "@ultimat3/scraping": "20.1.0",
64
+ "@ultimat3/seo": "20.1.0",
65
+ "@ultimat3/storage": "20.1.0",
66
+ "@ultimat3/testing": "20.1.0",
67
+ "@ultimat3/time": "20.1.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/budgets.ts CHANGED
@@ -13,6 +13,7 @@ import type { Manifest, RouteFact } from '@ultimat3/manifest';
13
13
  import { formatBytes, parseByteBudget } from '@ultimat3/render';
14
14
  import type { Finding } from './output';
15
15
  import type { UnmeasuredRoute } from './static-report';
16
+ import { SW_REGISTER_PATH } from './sw-artifacts';
16
17
 
17
18
  export const BUILD_STATS_FILE = join('.x', 'build-stats.json');
18
19
 
@@ -24,7 +25,14 @@ export interface RouteStats {
24
25
  * prerenders many pages contributes ONE row, holding its heaviest.
25
26
  */
26
27
  readonly path: string;
28
+ /** The APP's JavaScript, and only the app's — see `FRAMEWORK_SCRIPTS`. */
27
29
  readonly jsBytes: number;
30
+ /**
31
+ * The framework's own injected runtime, in bytes: reported, never budgeted. Optional because a
32
+ * stats file written before 2026-09-11 has no such key, and absent is not zero — a row from an
33
+ * older build simply did not count it, and `checkBudgets` reads `jsBytes` either way.
34
+ */
35
+ readonly frameworkJsBytes?: number;
28
36
  /**
29
37
  * **Written by nothing, `As of 2026-08`.** `apps/web/prerender.ts` is the only producer of this
30
38
  * file and it emits static HTML — there is no browser in the build to observe a paint. So the
@@ -212,11 +220,39 @@ export interface MeasuredEntry {
212
220
  }
213
221
 
214
222
  export interface MeasuredJs {
223
+ /** The app's own executable bytes — what `budget.js` is a promise about. */
215
224
  readonly jsBytes: number;
216
- /** Every `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
225
+ /** The framework's injected runtime, counted separately so it is reported and never charged. */
226
+ readonly frameworkBytes: number;
227
+ /** Every APP `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
217
228
  readonly entries: readonly MeasuredEntry[];
218
229
  }
219
230
 
231
+ /**
232
+ * Scripts the FRAMEWORK injects into a document, which a route's `budget.js` does not answer for.
233
+ * `budget.js` is a promise about the APP's JavaScript: an author can delete an import, move one
234
+ * behind `hydrate: 'interaction'` or drop an island, and can do NOTHING about a file the build
235
+ * writes into every document it renders. Charging it made `js: '0kb'` — the budget `x new`
236
+ * scaffolds on `site/` — unreachable for any installable app, and the `fix:` it printed named an
237
+ * import chain of one entry the author never wrote.
238
+ *
239
+ * It was also not a stable number. `/x-sw-register.js` is written AFTER the documents that name
240
+ * it are weighed (`prerender.ts` emits the worker last, because its precache manifest is built
241
+ * from their content hashes), so a clean `.x/` measured a file that did not exist and recorded 0,
242
+ * and the next build measured the one before it and recorded 250. Same commit, green then red,
243
+ * decided by whether anything had cleaned the output directory.
244
+ *
245
+ * ENUMERATED, and it is one entry: `serviceWorkerHead` is the only `<script src>` the framework
246
+ * puts in a prerendered document — measured on a fresh scaffold plus `x g island` and
247
+ * `x g route`, where `<script src="/x-sw-register.js" defer>` is the only script tag across every
248
+ * emitted page. Two framework scripts are deliberately NOT here. `render/src/hydrate.ts`'s inline
249
+ * module runtime is charged, because it exists only when the page ships an island — it is the
250
+ * cost of the app's own interactivity, and a page with a `0kb` budget has none. `island-props.ts`'
251
+ * `<script type="application/json">` is already excluded as data, by `carriesJson`. A third one
252
+ * joins this set by a decision, here, with the same argument.
253
+ */
254
+ export const FRAMEWORK_SCRIPTS: ReadonlySet<string> = new Set([SW_REGISTER_PATH]);
255
+
220
256
  /**
221
257
  * What a rendered document actually makes the browser execute: the bytes of every inline script
222
258
  * the parser will run, the size of every file a `src` points at, and the size of every island
@@ -226,6 +262,7 @@ export interface MeasuredJs {
226
262
  */
227
263
  export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
228
264
  let jsBytes = 0;
265
+ let frameworkBytes = 0;
229
266
  const entries: MeasuredEntry[] = [];
230
267
  // Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
231
268
  // once however many times the document names it, so `budget.js` — a byte budget — counts it
@@ -247,6 +284,14 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
247
284
  fetched.add(url);
248
285
  const file = Bun.file(join(out, url.slice(1)));
249
286
  const bytes = (await file.exists()) ? file.size : 0;
287
+ // Counted and set aside, not skipped: the bytes are real and a reader is owed the number.
288
+ // Kept out of `entries` as well as out of `jsBytes`, because `entries` is what a finding reads
289
+ // to name the heaviest import — and on a fresh scaffold every route's `heaviestChain` was
290
+ // `/x-sw-register.js`, a file the author cannot edit, delete or move.
291
+ if (FRAMEWORK_SCRIPTS.has(url)) {
292
+ frameworkBytes += bytes;
293
+ return;
294
+ }
250
295
  entries.push({ url, bytes });
251
296
  jsBytes += bytes;
252
297
  };
@@ -266,7 +311,7 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
266
311
  if (url === undefined) continue;
267
312
  await weigh(url);
268
313
  }
269
- return { jsBytes, entries };
314
+ return { jsBytes, frameworkBytes, entries };
270
315
  }
271
316
 
272
317
  /**
package/src/cmd-doctor.ts CHANGED
@@ -3,14 +3,20 @@
3
3
  // work back to the reader.
4
4
 
5
5
  import { existsSync } from 'node:fs';
6
- import { join } from 'node:path';
6
+ import { dirname, join, resolve, sep } from 'node:path';
7
7
  import {
8
8
  ENV_EXAMPLE_PATH,
9
9
  ERROR_DOCS_URL,
10
10
  tryResolveEnvironment,
11
11
  usesDevCursorSecret,
12
12
  } from '@ultimat3/core';
13
- import { checkDb, createPostgresClient } from '@ultimat3/db';
13
+ import {
14
+ checkDb,
15
+ createPostgresClient,
16
+ PGLITE_FIX,
17
+ PGLITE_MISSING,
18
+ PGLITE_PACKAGE,
19
+ } from '@ultimat3/db';
14
20
  import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
15
21
  import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
16
22
  import type { CliCommand, CommandContext } from './command';
@@ -61,6 +67,16 @@ export interface DoctorProbe {
61
67
  * correctly answered `X_DB_UNAVAILABLE` (#F5).
62
68
  */
63
69
  database(): Promise<Finding | null>;
70
+ /**
71
+ * Is the EMBEDDED database usable, and is it the one this environment would open? A FACT and not
72
+ * a finding, the split `offlineFallback` makes below: reaching a module resolver is IO, and what
73
+ * an unresolvable optional peer MEANS is a pure rule with a pure test.
74
+ *
75
+ * `database()` answers `null` the moment `DATABASE_URL` is unset — which is exactly a bare VM,
76
+ * the configuration `x dev` invents a database FOR — so until this existed `x doctor` was silent
77
+ * about the only database a fresh box has. `bin/setup` found out instead, at `x db migrate`.
78
+ */
79
+ embeddedDatabase(): Promise<EmbeddedDatabase>;
64
80
  drift(): Promise<readonly Finding[]>;
65
81
  /**
66
82
  * The other half of the migrations directory: a newest migration with no `.snapshot.json`, which
@@ -76,11 +92,40 @@ export interface DoctorProbe {
76
92
  offlineFallback(): Promise<OfflineFallbackFact>;
77
93
  }
78
94
 
95
+ /** What `x doctor` reads about the embedded database, without opening it. */
96
+ export interface EmbeddedDatabase {
97
+ /**
98
+ * True while `DATABASE_URL` is unset or blank — the condition that makes PGlite the app's
99
+ * database (`resolveServices`), and the same reading `probeDatabase` returns `null` on.
100
+ */
101
+ readonly selected: boolean;
102
+ /** Does `@electric-sql/pglite` resolve from the app root? */
103
+ readonly resolved: boolean;
104
+ }
105
+
79
106
  const finding = (code: string, cause: string, fix: string, at?: string): Finding =>
80
107
  at === undefined
81
108
  ? { code, cause, fix, docs: ERROR_DOCS_URL }
82
109
  : { code, cause, fix, docs: ERROR_DOCS_URL, at };
83
110
 
111
+ /**
112
+ * The rule, pure. Red only where the embedded database is the one that would be opened: an app
113
+ * pointed at a real Postgres never loads PGlite, and a finding about an absent optional peer there
114
+ * is noise the reader learns to skim.
115
+ *
116
+ * `@ultimat3/db`'s own refusal, not a CLI twin of it — same `X_DB_UNAVAILABLE`, same sentence,
117
+ * same runnable fix. The package already says this the moment a query arrives; `x doctor` is what
118
+ * says it before `bin/setup` gets that far.
119
+ */
120
+ export const embeddedDatabaseFinding = (fact: EmbeddedDatabase): Finding | undefined =>
121
+ fact.selected && !fact.resolved
122
+ ? finding(
123
+ 'X_DB_UNAVAILABLE',
124
+ `${PGLITE_MISSING} — and DATABASE_URL is unset or blank, so the embedded one is the database this app would open`,
125
+ PGLITE_FIX,
126
+ )
127
+ : undefined;
128
+
84
129
  /** The file `x doctor` reports missing, and the one the reader creates. */
85
130
  export const ENV_DEVELOPMENT = '.env.development';
86
131
 
@@ -229,6 +274,10 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
229
274
  if (offline !== undefined) findings.push(offline);
230
275
  const database = await probe.database();
231
276
  if (database !== null) findings.push(database);
277
+ // The other half of the same question, and the half a bare VM lands on: `database()` is silent
278
+ // where there is no `DATABASE_URL`, and that silence IS the bare-VM configuration.
279
+ const embedded = embeddedDatabaseFinding(await probe.embeddedDatabase());
280
+ if (embedded !== undefined) findings.push(embedded);
232
281
  findings.push(...(await probe.drift()));
233
282
  // Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
234
283
  // author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
@@ -249,6 +298,45 @@ export const doctorPort = (args: ParsedArgs): number =>
249
298
  DEFAULT_DOCTOR_PORT,
250
299
  );
251
300
 
301
+ /**
302
+ * `DATABASE_URL` as BOTH halves of the database question read it: unset and blank are one case.
303
+ * One seam, because the two halves are complementary — a second reading of the same variable is
304
+ * how a configuration ends up reported by neither probe, or by both.
305
+ */
306
+ const externalUrl = (raw: string | undefined): string | undefined =>
307
+ raw === undefined || raw.trim() === '' ? undefined : raw;
308
+
309
+ /**
310
+ * Is the optional peer INSTALLED for this app — a `node_modules` walk up from the app root.
311
+ *
312
+ * Never an IMPORT: loading PGlite boots 26 MB of WASM and takes the single-writer lock the next
313
+ * command needs, and a diagnostic must not be the reason `x dev` cannot open the database it just
314
+ * reported on.
315
+ *
316
+ * Never `Bun.resolveSync` either, which was this check's first draft. It falls back to Bun's
317
+ * machine-global install cache: measured 2026-09-11 against a freshly scaffolded app with no
318
+ * `node_modules` anywhere above it, `Bun.resolveSync('@electric-sql/pglite', <app>)` answered
319
+ * `~/.bun/install/cache/@electric-sql/pglite@0.5.8@@@1/dist/index.js` — a version the app does not
320
+ * depend on, in a directory `bun install` never wrote for it. A box that had downloaded the package
321
+ * ONCE, for anything, would have read as ready on every app after it, which is the exact opposite
322
+ * of the question. The walk asks what `bun add` answers, and nothing else.
323
+ *
324
+ * Up from the root and not at it, because an app checked out inside a larger workspace is installed
325
+ * by the hoisting one: `examples/dummy` in this repository has no `node_modules` of its own.
326
+ */
327
+ const installedAbove = (dir: string, specifier: string): boolean => {
328
+ let current = resolve(dir);
329
+ // Bounded by the path itself — one segment per step — rather than by `for (;;)` and the promise
330
+ // that `dirname('/')` is `/`. The loop still stops on that, one step earlier.
331
+ for (let remaining = current.split(sep).length; remaining > 0; remaining -= 1) {
332
+ if (existsSync(join(current, 'node_modules', specifier, 'package.json'))) return true;
333
+ const parent = dirname(current);
334
+ if (parent === current) return false;
335
+ current = parent;
336
+ }
337
+ return false;
338
+ };
339
+
252
340
  /**
253
341
  * A real `select 1` through the app's own driver, not a TCP connect: a running Postgres with the
254
342
  * wrong credentials or a database that does not exist accepts the socket and refuses the session,
@@ -258,7 +346,7 @@ export const doctorPort = (args: ParsedArgs): number =>
258
346
  * next `x db migrate` cannot have.
259
347
  */
260
348
  async function probeDatabase(url: string | undefined): Promise<Finding | null> {
261
- if (url === undefined || url.trim() === '') return null;
349
+ if (externalUrl(url) === undefined) return null;
262
350
  const client = createPostgresClient({ url, applicationName: 'x-doctor' });
263
351
  try {
264
352
  const report = await checkDb(client);
@@ -296,6 +384,12 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
296
384
  exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
297
385
  portFree,
298
386
  database: () => probeDatabase(process.env['DATABASE_URL']),
387
+ // `root ?? cwd` because the walk needs a directory that exists; outside an app `runDoctor`
388
+ // returns on `X_NOT_IN_APP` before this is ever asked, so the value only has to be honest.
389
+ embeddedDatabase: async () => ({
390
+ selected: externalUrl(process.env['DATABASE_URL']) === undefined,
391
+ resolved: installedAbove(root ?? cwd, PGLITE_PACKAGE),
392
+ }),
299
393
  drift: async () => (root === undefined ? [] : checkMigrationDrift(root)),
300
394
  snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
301
395
  // `routes: undefined` outside an app is "not judged", which is what the caller already is:
@@ -1,7 +1,11 @@
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.
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 { islandShotLines, islandShotSummary, islandVerdictJson } from './island-verdict';
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
  });