@ultimat3/cli 11.2.0 → 12.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 CHANGED
@@ -14,7 +14,7 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
14
14
  | Bare subcommands | `CommandSpec.defaultSubcommand`, **declared**. The parser answered `subcommands[0]` until 1.2.0, so `x db` ran `gen` — the migration GENERATOR — because it sorted first, and `x mcp` started a server. A command with no defensible default declares none and `MissingSubcommandError` refuses the bare form; `parse.test.ts` pins the set at exactly `db` and `mcp`. Its fix is `x help <command>`. Both forms answer now: `--help` is read off the flag loop and `readSubcommand` is SKIPPED when it is set, so `x db --help`, `x mcp --help` and `x pr --help` print usage instead of exiting 1 with this same refusal — which is what they did on every command taking a subcommand until 2026-08 (`parse.test.ts` pins it across the shipped registry) |
15
15
  | Closed flag values | a flag whose values are a closed set is READ through a function that refuses the rest — `cmd-build.ts`'s `readTarget`, `cmd-deploy.ts`'s `readMethod`, `cmd-routes.ts`'s `readSurfaceFilter`, `cmd-mcp.ts`'s `isTransport`. `=== 'helm' ? 'helm' : 'compose'` made `x deploy --method helmm` a COMPOSE deploy reporting `method: "compose"`, and `--surface App` reported `0 routes` and exit 0 — a typo and an empty table rendering identically. The set is the framework's own where one exists (`SURFACES` from `@ultimat3/render`), never a list restated here |
16
16
  | App root | `CommandSpec.requiresApp`, **enforced by `dispatch.ts`** before `target.run` — the field's doc said so for 17 commands and nothing read it, so the promise was kept only by each command remembering to call `requireAppRoot` itself. Those 17 calls stay (they hand the command its root, and name subcommands the dispatcher cannot see), but the DECLARATION is what decides, ahead of any check a command makes about its own arguments; `--help` is exempt, because `target` is the help command by then |
17
- | Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of 19 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
17
+ | Result helpers | `command.ts`'s `ok()` / `failed()` write `ok` **after** the `extra` spread: the function's name is the verdict and nothing a caller passes can overturn it. Spread last, `failed('verify', '1 of 20 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
18
18
  | I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
19
19
  | Staying up | a command still listening when `run` resolves returns `hold` (`hold.ts`), or `bin.ts` exits out from under it |
20
20
  | `--json` | every command, no exceptions — same data as the human render |
@@ -80,6 +80,54 @@ source only: a test file's import is not judged, because `packages/*` here decla
80
80
  read is its own finding rather than a silent skip — a skipped workspace is a hiding place for the
81
81
  very edge the rule is looking for.
82
82
 
83
+ `app-permissions.ts` is the `policy` step, and it is the twentieth. Two references in the whole
84
+ framework are bare strings nothing checks — `RoleDef.grants` and `RouteGuard.permission` — while
85
+ `can()` calls `assertPermission` and throws `X_PERMISSION_UNKNOWN` on the first request that
86
+ reaches the route. So `x new` shipped an app that granted `dashboard:read`, required it on
87
+ `/dashboard` and declared it nowhere: HTTP 500 on two of its three routes, from the first `x dev`,
88
+ under a green gate. It reads `roleDefinitions()` and `routeEntries()` after `loadApp` and reports
89
+ each reference `isKnownPermission` refuses — **that predicate and no other**, because it is the one
90
+ `assertPermission` uses, including its rule that an app which has declared NOTHING is not checked
91
+ at all. A gate that disagreed with the process it gates would be worse than none. The cause and the
92
+ `fix:` are `permissionUnknown`'s, so `@ultimat3/policy` owns both wordings; `X_PERMISSION_UNKNOWN`
93
+ is in `CLI_BORROWED_ERROR_CODES`. Its own step rather than a rider on `budgets`, by that step's own
94
+ test: reported there, an authz defect would hand the reader a byte budget (axiom 4). It costs no
95
+ second app load.
96
+
97
+ `dev-replica.ts` is where read-replica routing is WIRED, and it had to be wired in two places
98
+ because it was opt-in twice. `@ultimat3/db`'s `defaultClient()` is the one composer of
99
+ `replicatedClient(primary, replica)` from `DATABASE_REPLICA_URL`, and it runs only from
100
+ `baseClient()` — "the client an app installed none for" — while every process the framework boots
101
+ calls `setDbClient` in `dev-queue.ts`, so no booted process had ever read that variable. Routing
102
+ also needs an open `withReplicaReads` scope, and nothing opened one. `startDb` now installs the
103
+ replicated pair as the AMBIENT client while keeping the primary for everything this boot does
104
+ itself (`applySchema`, the queue's `PgExecutor`, `ping`, `close` — DDL and a claim are writes), and
105
+ `cmd-dev.ts` / `serve.ts` prepend one middleware frame that opens the scope per request. Both
106
+ halves are `undefined`/empty with no replica configured, and an EMBEDDED binding never gets one:
107
+ PGlite has no standby. Not `@ultimat3/http`'s pipeline, which would make the HTTP tier know what a
108
+ database is; the boot is the only tier that may know about a request and a pool.
109
+
110
+ `port-probe.ts` is the one `portFree`, because two commands ask it and must not disagree:
111
+ `x doctor` reports it as a finding for BOTH ports `x dev` binds — the web port and the `PORT + 1`
112
+ sync port, each labelled with the role that wants it — and `startSync` asks it after a failed
113
+ `listenSyncNode` so a taken neighbour is `X_PORT_IN_USE` rather than `X_CLI_UNEXPECTED` over
114
+ `Bun.serve`'s own English rendered into a `cause:`. It is ASKED, never read off the caught value,
115
+ which is what `scripts/catch-render.ts` refuses; anything else the listener failed on is re-thrown
116
+ untouched. `x doctor` also probes `DATABASE_URL` with a real `select 1` through
117
+ `@ultimat3/db`'s `checkDb` — a TCP connect answers "reachable" for a running server with wrong
118
+ credentials, which is the case an operator most needs told about — and reports `X_DB_UNAVAILABLE`
119
+ with that package's own two-branch fix. An EMBEDDED binding is not probed: that lock is `x dev`'s.
120
+
121
+ `i18n-index.ts` is the one writer of an app's `packages/i18n/src/index.ts`, shared by `x g` and
122
+ `x i18n add|sync`. A catalog on disk and a SELECTABLE locale were two different sets: `x i18n add
123
+ fr` wrote the file, exited 0, and left `x verify --only i18n` red with `X_CATALOG_UNREGISTERED`
124
+ whose `fix:` named an edit that had already been made — an agent following it verbatim changes
125
+ nothing and loops forever, on the command whose whole job is adding a locale. `unregisteredFix`
126
+ (`i18n-registration.ts`) is the other half: one code over two causes, so where the index EXISTS and
127
+ does not name the locale's own `catalogs/<tag>.json` import, the CLI substitutes a fix that
128
+ performs the registration. The package's own "move the `defineCatalogs()` call" line still stands
129
+ for the cause it was written for.
130
+
83
131
  `app-agents-md.ts` is why the `manifest` step declares no `applies` at all. The drift half needs
84
132
  a committed `x.manifest.json` to compare against, but `AGENTS.md` is required of every repo the
85
133
  gate runs in — so the step always has a question to answer, and gating both halves on the file
@@ -166,7 +214,7 @@ change, and CI does not install one.
166
214
 
167
215
  | | Reaches for | Never |
168
216
  |---|---|---|
169
- | `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
217
+ | `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 |
170
218
  | `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 |
171
219
  | `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 |
172
220
  | `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
@@ -187,6 +235,7 @@ ones a running app will not produce on request. `--island` takes them, one addre
187
235
  | `island-harness-script.ts` | what runs before the chunk does: the sealed network, the pinned clock, the readiness watch |
188
236
  | `island-harness-route.ts` | `GET /_x/island`, mounted by `x dev` |
189
237
  | `island-shot.ts` | the capture loop, the assertions before each shutter, the missing-shot gate |
238
+ | `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 |
190
239
  | `island-verdict.ts` | the per-state verdict — a PNG cannot say the component threw or logged |
191
240
  | `cmd-shot-island.ts` | the flags, and the one browser per declared viewport |
192
241
 
@@ -654,6 +703,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
654
703
  | `dev-assets.ts` | the image pipeline's only HTTP surface: `/icons/*` and `/media/*` |
655
704
  | `favicon.ts` | `/favicon.ico`: the app's own file, and the bytes the framework answers with when there is none |
656
705
  | `dev-hooks.ts` | the pipeline's `authorize` seam, decided from the app's own `Policy` objects |
706
+ | `dev-replica.ts` | which boot gets a standby, and the one middleware frame that opens the read scope |
657
707
  | `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` |
658
708
  | `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels |
659
709
  | `dev-traces.ts` | core's spans → the `/_x` timeline's request traces |
package/README.md CHANGED
@@ -11,7 +11,7 @@ Commands and the `x verify` step count, `As of 2026-08`:
11
11
  | `x new <name>` | scaffolds the monorepo | interactive-free; auth, seeded DB, example route |
12
12
  | `x dev` | every role in one process | embedded Postgres/events/storage, `/_x` mounted |
13
13
  | `x build --target docker\|binary\|static` | one artifact | `ROLE` selects behaviour at start |
14
- | `x verify` | **the gate** | 19 named steps, each with pass/fail + duration |
14
+ | `x verify` | **the gate** | 20 named steps, each with pass/fail + duration |
15
15
  | `x g <primitive> <name>` | scaffolds a primitive **with a passing test** | never a TODO stub |
16
16
  | `x db gen\|migrate\|reset\|branch\|backfill` | everything DB | `branch` = copy-on-write clone + preview URL; `backfill` dry-runs unless `--write`. `x db studio` is **planned** — it parses, and exits `X_NOT_IMPLEMENTED` naming `/_x`'s db panel |
17
17
  | `x mcp serve` | `@ultimat3/mcp`'s 13 dev tools, over stdio or HTTP | one catalog, one scope set, both transports |
@@ -47,15 +47,15 @@ X_DB_DRIFT: schema differs from migrations
47
47
 
48
48
  ```sh
49
49
  x verify --json
50
- # {"ok":false,"command":"verify","summary":"1 of 19 steps failed","steps":[...]}
50
+ # {"ok":false,"command":"verify","summary":"1 of 20 steps failed","steps":[...]}
51
51
  ```
52
52
 
53
53
  ## `x verify` steps
54
54
 
55
55
  `typecheck lint boundaries filesize package-shape errors unit contract live job e2e eval drift
56
- contract-diff budgets seo i18n manifest roadmap`
56
+ contract-diff budgets seo i18n policy manifest roadmap`
57
57
 
58
- Nineteen, in cost order, defined once as `VERIFY_STEP_NAMES` (`verify-step.ts`) — the summary
58
+ Twenty, in cost order, defined once as `VERIFY_STEP_NAMES` (`verify-step.ts`) — the summary
59
59
  count above is projected from that list, and the framework repo's own gate (`bun run verify`)
60
60
  runs exactly it. A step with nothing to check here reports as skipped, never as
61
61
  passed. Never bails early: an agent fixing three things needs all three findings from one run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "11.2.0",
3
+ "version": "12.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,33 +37,33 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "11.2.0",
41
- "@ultimat3/admin": "11.2.0",
42
- "@ultimat3/ai": "11.2.0",
43
- "@ultimat3/auth": "11.2.0",
44
- "@ultimat3/cache": "11.2.0",
45
- "@ultimat3/core": "11.2.0",
46
- "@ultimat3/db": "11.2.0",
47
- "@ultimat3/entity": "11.2.0",
48
- "@ultimat3/flags": "11.2.0",
49
- "@ultimat3/http": "11.2.0",
50
- "@ultimat3/i18n": "11.2.0",
51
- "@ultimat3/jobs": "11.2.0",
52
- "@ultimat3/mail": "11.2.0",
53
- "@ultimat3/manifest": "11.2.0",
54
- "@ultimat3/mcp": "11.2.0",
55
- "@ultimat3/money": "11.2.0",
56
- "@ultimat3/policy": "11.2.0",
57
- "@ultimat3/pwa": "11.2.0",
58
- "@ultimat3/query": "11.2.0",
59
- "@ultimat3/realtime": "11.2.0",
60
- "@ultimat3/render": "11.2.0",
61
- "@ultimat3/schema": "11.2.0",
62
- "@ultimat3/scraping": "11.2.0",
63
- "@ultimat3/seo": "11.2.0",
64
- "@ultimat3/storage": "11.2.0",
65
- "@ultimat3/testing": "11.2.0",
66
- "@ultimat3/time": "11.2.0",
40
+ "@ultimat3/action": "12.0.0",
41
+ "@ultimat3/admin": "12.0.0",
42
+ "@ultimat3/ai": "12.0.0",
43
+ "@ultimat3/auth": "12.0.0",
44
+ "@ultimat3/cache": "12.0.0",
45
+ "@ultimat3/core": "12.0.0",
46
+ "@ultimat3/db": "12.0.0",
47
+ "@ultimat3/entity": "12.0.0",
48
+ "@ultimat3/flags": "12.0.0",
49
+ "@ultimat3/http": "12.0.0",
50
+ "@ultimat3/i18n": "12.0.0",
51
+ "@ultimat3/jobs": "12.0.0",
52
+ "@ultimat3/mail": "12.0.0",
53
+ "@ultimat3/manifest": "12.0.0",
54
+ "@ultimat3/mcp": "12.0.0",
55
+ "@ultimat3/money": "12.0.0",
56
+ "@ultimat3/policy": "12.0.0",
57
+ "@ultimat3/pwa": "12.0.0",
58
+ "@ultimat3/query": "12.0.0",
59
+ "@ultimat3/realtime": "12.0.0",
60
+ "@ultimat3/render": "12.0.0",
61
+ "@ultimat3/schema": "12.0.0",
62
+ "@ultimat3/scraping": "12.0.0",
63
+ "@ultimat3/seo": "12.0.0",
64
+ "@ultimat3/storage": "12.0.0",
65
+ "@ultimat3/testing": "12.0.0",
66
+ "@ultimat3/time": "12.0.0",
67
67
  "babel-preset-solid": "^1.9.15"
68
68
  }
69
69
  }
Binary file
@@ -2,11 +2,17 @@
2
2
  // of this package. `@ultimat3/scraping` declares the launcher's shape structurally (`cdp-port.ts`)
3
3
  // precisely so the framework can drive a browser without shipping one, and `x shot` is a CLI
4
4
  // command holding to the same bargain: the app installs `puppeteer-core`, the CLI asks for it.
5
+ //
6
+ // Two ways to get a browser, and the second is the one production uses. `localBrowser()` starts
7
+ // Chrome in this container; `remoteBrowser({ cdpUrl })` ATTACHES to one somebody else is running,
8
+ // which is what every stealth provider sells — a session created over their API answers with a
9
+ // `wss://` CDP endpoint and a real, unfingerprintable Chromium behind it. `driver-cdp.ts` has
10
+ // called attach its primary path since it shipped, and until now no CLI command could reach it.
5
11
 
6
12
  import { existsSync } from 'node:fs';
7
13
  import { UltimateError } from '@ultimat3/core';
8
14
  import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
9
- import { localBrowser } from '@ultimat3/scraping';
15
+ import { localBrowser, remoteBrowser } from '@ultimat3/scraping';
10
16
 
11
17
  /**
12
18
  * The one library this works against. Playwright is not an alternative and is not a flag:
@@ -18,6 +24,17 @@ export const BROWSER_PACKAGE = 'puppeteer-core';
18
24
  /** Where a browser binary is named when the flag does not name one. Read in this order. */
19
25
  export const BROWSER_PATH_VARS = ['PUPPETEER_EXECUTABLE_PATH', 'CHROME_PATH'] as const;
20
26
 
27
+ /**
28
+ * Where a CDP endpoint is named when `--cdp-url` does not name one. `SCRAPE_CDP_URL` and not a
29
+ * name of this command's own: `@ultimat3/scraping`'s `remoteRequired` refusal already tells its
30
+ * reader `remoteBrowser({ cdpUrl: env.SCRAPE_CDP_URL })`, and a second spelling would make the
31
+ * package's own instruction wrong for the CLI that follows it.
32
+ */
33
+ export const BROWSER_CDP_URL_VAR = 'SCRAPE_CDP_URL';
34
+
35
+ /** The schemes a CDP endpoint can arrive as: a provider's `wss://`, a sidecar's `http://`. */
36
+ const CDP_SCHEMES = ['ws:', 'wss:', 'http:', 'https:'] as const;
37
+
21
38
  /**
22
39
  * A missing browser is an instruction, not a crash (axiom 4). The cause distinguishes the two
23
40
  * shapes — nothing resolved, or something resolved that is not a launcher — while the fix is the
@@ -27,6 +44,8 @@ export class ShotBrowserMissingError extends UltimateError {
27
44
  constructor(input: { root: string; detail: string }) {
28
45
  super({
29
46
  code: 'X_SHOT_BROWSER_MISSING',
47
+ // The same install either way: attaching over CDP needs no Chrome on this box, but it still
48
+ // needs a client that speaks the protocol, and `puppeteer-core` is that client.
30
49
  cause: `x shot drives a real browser and ${BROWSER_PACKAGE} ${input.detail} from ${input.root}`,
31
50
  // One literal, not `bun add -d ${BROWSER_PACKAGE}`: `fix-scan.ts` can only read a fix that IS
32
51
  // one literal, and a fix line the gate cannot read is a fix line nothing holds to the
@@ -40,23 +59,34 @@ export class ShotBrowserMissingError extends UltimateError {
40
59
  /**
41
60
  * Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
42
61
  * transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
43
- * question decides — is there a `launch` to call?
62
+ * question decides — is the method this run needs there to call?
63
+ *
64
+ * WHICH method is the run's, not this function's. `cdp-port.ts` declares `launch` and `connect`
65
+ * both optional precisely so an attach-only provider SDK satisfies the port, and asking for
66
+ * `launch` when the run is going to `connect` would refuse exactly the library that works.
44
67
  */
45
- const launcherIn = (module: unknown): CdpLauncherLike | undefined => {
68
+ const launcherIn = (module: unknown, method: 'launch' | 'connect'): CdpLauncherLike | undefined => {
46
69
  if (typeof module !== 'object' || module === null) return undefined;
47
- const candidate = module as { launch?: unknown; default?: unknown };
48
- if (typeof candidate.launch === 'function') return candidate as CdpLauncherLike;
70
+ const candidate = module as Record<string, unknown>;
71
+ if (typeof candidate[method] === 'function') return candidate as CdpLauncherLike;
49
72
  // `module.exports.default = module.exports` is a real CJS interop shape, so the self-reference is
50
73
  // refused rather than followed: one unbounded recursion here is a stack overflow instead of the
51
74
  // instruction this whole function exists to produce.
52
- if (candidate.default === undefined || candidate.default === candidate) return undefined;
53
- return launcherIn(candidate.default);
75
+ const inner = candidate['default'];
76
+ if (inner === undefined || inner === candidate) return undefined;
77
+ return launcherIn(inner, method);
54
78
  };
55
79
 
56
80
  export interface AppBrowserOptions {
57
81
  readonly root: string;
58
82
  /** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
59
83
  readonly executablePath?: string | undefined;
84
+ /**
85
+ * A CDP endpoint to ATTACH to. When it is set nothing is launched here and `executablePath` is
86
+ * not read: the browser is somebody else's, and closing it ends their session too — which
87
+ * `remoteBrowser()` does deliberately, so a provider stops billing for a run that ended.
88
+ */
89
+ readonly cdpUrl?: string | undefined;
60
90
  /**
61
91
  * The page size this browser lays out at. A LAUNCH option and not a per-capture one, because
62
92
  * that is the only place the shipped port has for it: `CaptureRequest` is `fullPage` alone
@@ -85,6 +115,33 @@ export const executablePathFrom = (
85
115
  /** True when a named executable is really there — a bad `--browser` is refused before a boot. */
86
116
  export const browserBinaryExists = (path: string): boolean => existsSync(path);
87
117
 
118
+ /** The endpoint a run will attach to, or `undefined` for "launch one here". */
119
+ export const cdpUrlFrom = (
120
+ flag: string | undefined,
121
+ env: Readonly<Record<string, string | undefined>>,
122
+ ): string | undefined => {
123
+ if (flag !== undefined && flag.length > 0) return flag;
124
+ const value = env[BROWSER_CDP_URL_VAR];
125
+ return value !== undefined && value.length > 0 ? value : undefined;
126
+ };
127
+
128
+ /**
129
+ * The scheme, checked here rather than at `connect()`. A `--cdp-url` naming an https page or a
130
+ * bare host is a typo, and a typo must not cost an embedded Postgres and a provider session to
131
+ * report — the same rule `--browser` follows against the filesystem one line above.
132
+ */
133
+ export const cdpUrlProblem = (url: string): string | undefined => {
134
+ let parsed: URL;
135
+ try {
136
+ parsed = new URL(url);
137
+ } catch {
138
+ return 'is not a URL';
139
+ }
140
+ return CDP_SCHEMES.includes(parsed.protocol as (typeof CDP_SCHEMES)[number])
141
+ ? undefined
142
+ : `has scheme "${parsed.protocol}", and a CDP endpoint is ${CDP_SCHEMES.join(', ')}`;
143
+ };
144
+
88
145
  /**
89
146
  * The app's `puppeteer-core`, as a `ScrapeDriver`. Resolved FROM THE APP ROOT rather than from
90
147
  * this module: `import('puppeteer-core')` here would find the CLI's own tree, which by design has
@@ -93,17 +150,30 @@ export const browserBinaryExists = (path: string): boolean => existsSync(path);
93
150
  export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriver> {
94
151
  const resolve = options.resolve ?? ((specifier, from) => Bun.resolveSync(specifier, from));
95
152
  const load = options.load ?? ((path: string) => import(path) as Promise<unknown>);
153
+ const method = options.cdpUrl === undefined ? 'launch' : 'connect';
96
154
  let entry: string;
97
155
  try {
98
156
  entry = resolve(BROWSER_PACKAGE, options.root);
99
157
  } catch {
100
158
  throw new ShotBrowserMissingError({ root: options.root, detail: 'does not resolve' });
101
159
  }
102
- const launcher = launcherIn(await load(entry));
160
+ const launcher = launcherIn(await load(entry), method);
103
161
  if (launcher === undefined) {
104
162
  throw new ShotBrowserMissingError({
105
163
  root: options.root,
106
- detail: `resolved to ${entry}, which exports no launch()`,
164
+ detail: `resolved to ${entry}, which exports no ${method}()`,
165
+ });
166
+ }
167
+ if (options.cdpUrl !== undefined) {
168
+ return remoteBrowser({
169
+ launcher,
170
+ cdpUrl: options.cdpUrl,
171
+ // The viewport reaches `connect()` the same way it reaches `launch()` — through the
172
+ // pass-through slot — so `x shot --island`'s one-browser-per-viewport loop is unchanged by
173
+ // which half of the port a run took.
174
+ ...(options.viewport === undefined
175
+ ? {}
176
+ : { options: { defaultViewport: { ...options.viewport } } }),
107
177
  });
108
178
  }
109
179
  return localBrowser({
package/src/cmd-dev.ts CHANGED
@@ -26,6 +26,7 @@ import { devDashboardRoutes, devPanels } from './dev-dashboard';
26
26
  import { clearLock, preflight, writeLock } from './dev-lock';
27
27
  import { createStatementLedger } from './dev-n-plus-one';
28
28
  import { appRoutes } from './dev-render';
29
+ import { replicaOverrides } from './dev-replica';
29
30
  import type { RunningRoles } from './dev-roles';
30
31
  import { DEV_BINDING, DEV_ROLES, selectRoles, startRoles } from './dev-roles';
31
32
  import type { RunningServices } from './dev-runtime';
@@ -195,6 +196,7 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
195
196
  ...appRoutes({ buildId, resolveIsland: (file) => state.islands.resolverFor(file) }),
196
197
  ];
197
198
 
199
+ const replicaOverride = replicaOverrides(undefined, services.db, options.env);
198
200
  const running = await startRoles({
199
201
  roles: options.roles ?? DEV_ROLES,
200
202
  port: options.port,
@@ -223,6 +225,10 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
223
225
  // diagnostic to call.
224
226
  devNotices: (ctx: RequestContext): readonly OverlayNotice[] =>
225
227
  statements.repeatsFor(asCtx(ctx)).map(loopFacts).map(loopNotice),
228
+ // The read-replica scope, opened per request. Absent for every app that names no
229
+ // `DATABASE_REPLICA_URL` — which is every embedded boot by construction, since PGlite has no
230
+ // standby — so this key does not exist on a homework app's boot at all.
231
+ ...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
226
232
  });
227
233
 
228
234
  const stopWatching = watchApp(options.root, (file) => {
package/src/cmd-doctor.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { existsSync } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  import { ERROR_DOCS_URL, tryResolveEnvironment, usesDevCursorSecret } from '@ultimat3/core';
8
+ import { checkDb, createPostgresClient } from '@ultimat3/db';
8
9
  import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
9
10
  import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
10
11
  import type { CliCommand, CommandContext } from './command';
@@ -15,6 +16,7 @@ import { intFlagOr, neighbouringPort, PORT_RANGE } from './flag-number';
15
16
  import { msg } from './messages';
16
17
  import type { CommandResult, Finding } from './output';
17
18
  import type { ParsedArgs } from './parse';
19
+ import { portFree } from './port-probe';
18
20
 
19
21
  /**
20
22
  * The injection seam `runDoctor` reads instead of the environment. Not a semver surface —
@@ -40,6 +42,16 @@ export interface DoctorProbe {
40
42
  readonly production: boolean;
41
43
  exists(relativePath: string): boolean;
42
44
  portFree(port: number): Promise<boolean>;
45
+ /**
46
+ * Is the configured database reachable, and what refused? `null` is "reachable, or there is
47
+ * nothing external to reach" — an unset `DATABASE_URL` is embedded PGlite, which `x dev` owns
48
+ * and which a probe would take the single-writer lock on.
49
+ *
50
+ * Until this existed `x doctor` answered "no findings — environment is shippable" against
51
+ * `DATABASE_URL=postgres://nope:nope@localhost:5432/nope`, while `x db migrate` on the same env
52
+ * correctly answered `X_DB_UNAVAILABLE` (#F5).
53
+ */
54
+ database(): Promise<Finding | null>;
43
55
  drift(): Promise<readonly Finding[]>;
44
56
  /**
45
57
  * The other half of the migrations directory: a newest migration with no `.snapshot.json`, which
@@ -59,6 +71,39 @@ export const OFFLINE_FALLBACK = 'apps/web/app/offline.tsx';
59
71
  /** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
60
72
  const DEFAULT_DOCTOR_PORT = 3000;
61
73
 
74
+ /**
75
+ * Both ports `x dev` binds, each labelled with the role that wants it. `x dev --port 3999` printed
76
+ * `web listening on 3999`, then died on 4000 as `X_CLI_UNEXPECTED` with a caught `Error` rendered
77
+ * into its cause — and `x doctor --port 3999` answered "no findings", because it probed the web
78
+ * port and only the web port (#F5). The neighbouring port is not an implementation detail an
79
+ * operator can ignore: `docker-compose.prod.yml` publishes `3001:3001` from it and `docker/helm`
80
+ * derives `PORT = .port - 1` from it, so it is part of the contract `x dev` runs by.
81
+ *
82
+ * The suggested port moves BOTH: `x dev --port N` occupies N and N+1, so a free N beside a taken
83
+ * N+1 is still not a runnable command.
84
+ */
85
+ async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
86
+ const wanted = [
87
+ { port: probe.port, role: 'web' },
88
+ { port: neighbouringPort(probe.port), role: 'sync' },
89
+ ] as const;
90
+ const findings: Finding[] = [];
91
+ for (const entry of wanted) {
92
+ if (await probe.portFree(entry.port)) continue;
93
+ findings.push(
94
+ finding(
95
+ 'X_PORT_IN_USE',
96
+ `port ${entry.port} is already listening, and \`x dev --port ${probe.port}\` binds it for the ${entry.role} role`,
97
+ // Unchanged, and deliberately: the neighbour below the top of the range is the one port
98
+ // `x dev` is guaranteed to accept (`X_CLI_BAD_FLAG` otherwise), which is what
99
+ // `cmd-doctor.test.ts` pins by parsing this line with `x dev`'s own flag reader.
100
+ `x dev --port ${neighbouringPort(probe.port)}`,
101
+ ),
102
+ );
103
+ }
104
+ return findings;
105
+ }
106
+
62
107
  /**
63
108
  * Ordered cheapest-first so the first failure is usually the root cause: a wrong Bun explains
64
109
  * every other symptom, and running outside an app explains the rest.
@@ -120,15 +165,7 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
120
165
  ),
121
166
  );
122
167
  }
123
- if (!(await probe.portFree(probe.port))) {
124
- findings.push(
125
- finding(
126
- 'X_PORT_IN_USE',
127
- `port ${probe.port} is already listening`,
128
- `x dev --port ${neighbouringPort(probe.port)}`,
129
- ),
130
- );
131
- }
168
+ findings.push(...(await portFindings(probe)));
132
169
  // `@ultimat3/pwa`'s own codes, not CLI twins of them. `X_PWA_NO_ICON_SOURCE` and
133
170
  // `X_PWA_NO_FALLBACK` used to be declared here for the same two conditions the package already
134
171
  // names — two codes for one condition, one of them registered by nobody, so `x errors explain`
@@ -156,6 +193,8 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
156
193
  ),
157
194
  );
158
195
  }
196
+ const database = await probe.database();
197
+ if (database !== null) findings.push(database);
159
198
  findings.push(...(await probe.drift()));
160
199
  // Last, and it is why `X_CLI_UNEXPECTED`'s `fix: x doctor --json` is not a dead end on the path an
161
200
  // author reaches it from: `x db gen` throwing `X_MIGRATION_SNAPSHOT_MISSING` used to be a
@@ -176,15 +215,33 @@ export const doctorPort = (args: ParsedArgs): number =>
176
215
  DEFAULT_DOCTOR_PORT,
177
216
  );
178
217
 
179
- const portFree = async (port: number): Promise<boolean> => {
218
+ /**
219
+ * A real `select 1` through the app's own driver, not a TCP connect: a running Postgres with the
220
+ * wrong credentials or a database that does not exist accepts the socket and refuses the session,
221
+ * which is the case an operator most needs told about before a deploy.
222
+ *
223
+ * The pool is CLOSED on every path — this command exits, and a held pool is a connection slot the
224
+ * next `x db migrate` cannot have.
225
+ */
226
+ async function probeDatabase(url: string | undefined): Promise<Finding | null> {
227
+ if (url === undefined || url.trim() === '') return null;
228
+ const client = createPostgresClient({ url, applicationName: 'x-doctor' });
180
229
  try {
181
- const server = Bun.serve({ port, fetch: () => new Response('') });
182
- await server.stop(true);
183
- return true;
184
- } catch {
185
- return false;
230
+ const report = await checkDb(client);
231
+ if (report.ok) return null;
232
+ // `DbHealthReport.error` is `checkDb`'s own rendering of what refused, never this file's — the
233
+ // caught value is `checkDb`'s to read, and it is the one function that already reads it safely.
234
+ return finding(
235
+ 'X_DB_UNAVAILABLE',
236
+ `DATABASE_URL does not answer \`select 1\`: ${report.error ?? 'no reason reported'}`,
237
+ // `dbUnavailable`'s own two branches, verbatim: a second wording for one condition is two
238
+ // answers to "what do I do", and this one is reached first, before any command opens a pool.
239
+ 'set DATABASE_URL to a reachable Postgres url, or run `x dev` to use the embedded PGlite',
240
+ );
241
+ } finally {
242
+ await client.close();
186
243
  }
187
- };
244
+ }
188
245
 
189
246
  export function probeFor(cwd: string, bunVersion: string, port: number): DoctorProbe {
190
247
  const root = findAppRoot(cwd)?.dir;
@@ -204,6 +261,7 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
204
261
  production: tryResolveEnvironment() === 'production',
205
262
  exists: (relativePath) => (root === undefined ? false : existsSync(join(root, relativePath))),
206
263
  portFree,
264
+ database: () => probeDatabase(process.env['DATABASE_URL']),
207
265
  drift: async () => (root === undefined ? [] : checkSourceDrift(root)),
208
266
  snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
209
267
  };
package/src/cmd-errors.ts CHANGED
@@ -94,6 +94,12 @@ export const errorsCommand: CliCommand = {
94
94
  // which names `<CODE>` and hands back a real invocation. `list` would silently print 200 rows
95
95
  // to a caller who meant to explain one — see `MissingPositionalError`'s own note.
96
96
  defaultSubcommand: 'explain',
97
+ // `x errors X_PERMISSION_UNKNOWN` is the form every reader tries first — `x help` prints
98
+ // `errors an X_* code, explained`, which reads as exactly that — and it answered
99
+ // `X_CLI_UNKNOWN_COMMAND … fix: x help`, which leads back to the line that suggested it.
100
+ // Safe to declare here and nowhere else so far: the only thing that is not `explain` or
101
+ // `list` in this slot is a code, and a near miss of either is still refused (#F16).
102
+ defaultSubcommandTakesPositional: true,
97
103
  },
98
104
  // `async` is load-bearing: a synchronous throw would escape every caller that awaits the
99
105
  // promise this signature promises, including the dispatcher's own error path.
@@ -11,10 +11,11 @@ import { generate } from './generate-files';
11
11
  import { GENERATORS, readKind, readName, readSurface } from './generate-kinds';
12
12
  import { containedPath, writeFiles } from './generate-write';
13
13
  import { resolveCatalogModule } from './i18n-audit';
14
+ import { syncI18nIndex } from './i18n-index';
14
15
  import { msg } from './messages';
15
16
  import type { CommandResult, Finding } from './output';
16
17
  import { flagBool, flagList, flagString } from './parse';
17
- import { CATALOG_ROOT, i18nIndex, resolveLocales } from './templates';
18
+ import { resolveLocales } from './templates';
18
19
 
19
20
  // One import path for the generator, unchanged by the split: `index.ts`, `x new` and the scaffold
20
21
  // fixture reach the kinds, the pure file list and the writer through this module, and a second path
@@ -26,32 +27,6 @@ export { GENERATORS } from './generate-kinds';
26
27
  export type { WriteReport } from './generate-write';
27
28
  export { dedupe, writeFiles } from './generate-write';
28
29
 
29
- const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
30
-
31
- /**
32
- * `packages/i18n/src/index.ts` is the one module the app imports catalogs through, and it is
33
- * written once, at `x new` time, importing whichever locales existed then. A later `x g
34
- * ... --locales=es` lands `packages/i18n/catalogs/es.json` on disk, but nothing would otherwise
35
- * teach the index about it — the catalog file would exist with real keys in it and the app could
36
- * still never select that locale. Every run that wrote at least one file re-derives the FULL
37
- * locale set from `packages/i18n/catalogs/` — not just the locales this invocation asked for —
38
- * and rewrites the index to match. Bypasses `writeFiles` on purpose: this file is a projection of
39
- * the catalog directory, never app-authored content a conflict check should protect. An app with
40
- * no i18n package (deleted, or never scaffolded) is left alone.
41
- */
42
- async function syncI18nIndex(root: string): Promise<void> {
43
- const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
44
- if (!existsSync(indexAbsolute)) return;
45
- const catalogDir = containedPath(root, CATALOG_ROOT);
46
- const locales: string[] = [];
47
- if (existsSync(catalogDir)) {
48
- for await (const entry of new Bun.Glob('*.json').scan({ cwd: catalogDir, absolute: false })) {
49
- locales.push(entry.replace(/\.json$/, ''));
50
- }
51
- }
52
- await Bun.write(indexAbsolute, i18nIndex(locales));
53
- }
54
-
55
30
  export const generateCommand: CliCommand = {
56
31
  spec: {
57
32
  name: 'g',
package/src/cmd-i18n.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  serializeCatalog,
24
24
  syncCatalog,
25
25
  } from './i18n-audit';
26
+ import { syncI18nIndex } from './i18n-index';
26
27
  import {
27
28
  checkRegistration,
28
29
  loudMiss,
@@ -179,6 +180,12 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
179
180
  const from = resolveDefaultLocale(app.defaultLocale, catalogs);
180
181
  const seeded = seedCatalog(from === undefined ? {} : (catalogs[from] ?? {}));
181
182
  await writeNewCatalog(join(root, path), locale, serializeCatalog(seeded));
183
+ // The second half of adding a locale, and without it this command SHIPPED A RED GATE: the file
184
+ // landed, `packages/i18n/src/index.ts` still said `locales: { en }`, and `x verify --only i18n`
185
+ // answered `X_CATALOG_UNREGISTERED` with a fix naming an edit that had already been made. Same
186
+ // writer `x g --locales` already uses, so a catalog on disk and a selectable locale can never be
187
+ // two different sets (#F4).
188
+ const registered = await syncI18nIndex(root);
182
189
 
183
190
  const keys = catalogKeys(seeded).length;
184
191
  return {
@@ -189,7 +196,9 @@ async function runAdd(root: string, ctx: CommandContext): Promise<CommandResult>
189
196
  command: 'i18n',
190
197
  summary: msg('cli.i18n.added', { locale, keys, from: from ?? locale }),
191
198
  findings: app.findings,
192
- data: { locale, from: from ?? locale, keys, path },
199
+ // `registered` is false only for an app with no `packages/i18n` at all — a fact a caller has
200
+ // to be able to read, because it is the one case where the locale is on disk and unselectable.
201
+ data: { locale, from: from ?? locale, keys, path, registered },
193
202
  };
194
203
  }
195
204
 
@@ -250,6 +259,11 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
250
259
  : (catalogs[from] ?? {});
251
260
  const { merged, added } = syncCatalog(target, source);
252
261
  if (added.length > 0) await Bun.write(join(root, catalogPath(locale)), serializeCatalog(merged));
262
+ // Unconditional, and not only when keys moved: a catalog that reached disk some other way — a
263
+ // hand-created file, a `git merge` — is exactly the unregistered locale the gate refuses, and
264
+ // this is the command its `fix:` names. Re-deriving an index that is already correct writes the
265
+ // same bytes.
266
+ const registered = await syncI18nIndex(root);
253
267
 
254
268
  const total = catalogKeys(merged).length;
255
269
  return {
@@ -268,6 +282,7 @@ async function runSync(root: string, ctx: CommandContext): Promise<CommandResult
268
282
  added,
269
283
  total,
270
284
  path: catalogPath(locale),
285
+ registered,
271
286
  // Which of `added` still need a human. Empty on a real merge, where every value is a real
272
287
  // string copied from the default locale — `--json` must be able to tell the two apart.
273
288
  placeholders: seeded ? added : [],