@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 +56 -3
- package/package.json +29 -29
- package/src/budgets.ts +47 -2
- package/src/cmd-doctor.ts +97 -3
- package/src/cmd-shot-island.ts +120 -15
- package/src/cmd-shot.ts +45 -20
- package/src/index.ts +8 -2
- 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/prerender.ts +16 -1
- package/src/sw-artifacts.ts +9 -2
- package/src/templates/github/ci.yml.ts +74 -0
- 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-claude-commands.ts +20 -9
- package/src/templates/scaffold-docs.ts +40 -5
- package/src/templates/scaffold-guards.ts +15 -0
- package/src/templates/scaffold-repo.ts +22 -2
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.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": "
|
|
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.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
|
-
/**
|
|
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 {
|
|
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
|
|
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:
|
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
|
});
|