@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 +52 -2
- package/README.md +4 -4
- package/package.json +28 -28
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +79 -9
- package/src/cmd-dev.ts +6 -0
- package/src/cmd-doctor.ts +74 -16
- package/src/cmd-errors.ts +6 -0
- package/src/cmd-generate.ts +2 -27
- package/src/cmd-i18n.ts +16 -1
- package/src/cmd-shot-island.ts +5 -0
- package/src/cmd-shot.ts +17 -10
- package/src/compile-externals.ts +7 -4
- package/src/dev-queue.ts +49 -9
- package/src/dev-replica.ts +99 -0
- package/src/dev-roles-fixture.ts +7 -0
- package/src/dev-roles.ts +44 -30
- package/src/dev-sync.ts +45 -1
- package/src/error-codes.ts +16 -0
- package/src/i18n-index.ts +40 -0
- package/src/i18n-registration.ts +43 -1
- package/src/island-bundle.ts +18 -7
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +6 -1
- package/src/parse.ts +39 -6
- package/src/port-probe.ts +22 -0
- package/src/serve.ts +7 -1
- package/src/shot-browser.ts +84 -0
- package/src/templates/scaffold-app.ts +2 -0
- package/src/templates/scaffold-docs.ts +10 -4
- package/src/templates/scaffold-http.ts +84 -0
- package/src/templates/scaffold-repo.ts +20 -0
- package/src/templates/scaffold-roles.ts +31 -3
- package/src/verify-checks.ts +36 -0
- package/src/verify-step.ts +8 -0
- package/src/island-solid-production.ts +0 -129
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
|
|
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** |
|
|
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
|
|
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
|
-
|
|
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": "
|
|
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": "
|
|
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/policy": "
|
|
57
|
-
"@ultimat3/pwa": "
|
|
58
|
-
"@ultimat3/query": "
|
|
59
|
-
"@ultimat3/realtime": "
|
|
60
|
-
"@ultimat3/render": "
|
|
61
|
-
"@ultimat3/schema": "
|
|
62
|
-
"@ultimat3/scraping": "
|
|
63
|
-
"@ultimat3/seo": "
|
|
64
|
-
"@ultimat3/storage": "
|
|
65
|
-
"@ultimat3/testing": "
|
|
66
|
-
"@ultimat3/time": "
|
|
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
|
package/src/browser-launcher.ts
CHANGED
|
@@ -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
|
|
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
|
|
48
|
-
if (typeof candidate
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return
|
|
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.
|
package/src/cmd-generate.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 : [],
|