@ultimat3/cli 11.3.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 +50 -1
- package/README.md +4 -4
- package/package.json +28 -28
- package/src/app-permissions.ts +0 -0
- 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/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/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/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/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
|
|
@@ -655,6 +703,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
655
703
|
| `dev-assets.ts` | the image pipeline's only HTTP surface: `/icons/*` and `/media/*` |
|
|
656
704
|
| `favicon.ts` | `/favicon.ico`: the app's own file, and the bytes the framework answers with when there is none |
|
|
657
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 |
|
|
658
707
|
| `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` |
|
|
659
708
|
| `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels |
|
|
660
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/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 : [],
|
package/src/dev-queue.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type PostgresIdempotencyStore,
|
|
9
9
|
postgresIdempotencyStore,
|
|
10
10
|
resetIdempotency,
|
|
11
|
+
SQL_AUDIT_TABLE,
|
|
11
12
|
SQL_IDEMPOTENCY_TABLE,
|
|
12
13
|
setIdempotencyStore,
|
|
13
14
|
} from '@ultimat3/action';
|
|
@@ -36,12 +37,19 @@ import {
|
|
|
36
37
|
setJobDriver,
|
|
37
38
|
setJobsFacade,
|
|
38
39
|
} from '@ultimat3/jobs';
|
|
40
|
+
import { attachReplica, type ReplicaEnv, replicaUrlFor } from './dev-replica';
|
|
39
41
|
import type { DevServices } from './dev-services';
|
|
40
42
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
41
43
|
|
|
42
44
|
/** Both embedded and external clients boot lazily and close explicitly. */
|
|
43
45
|
export type DevDbClient = PgliteClient | PostgresClient;
|
|
44
46
|
|
|
47
|
+
/** The primary this boot owns, plus the standby pool it opened — `stop()` closes both. */
|
|
48
|
+
interface StartedDb {
|
|
49
|
+
readonly client: DevDbClient;
|
|
50
|
+
readonly replica: PostgresClient | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
export interface RunningQueue {
|
|
46
54
|
readonly db: DevDbClient;
|
|
47
55
|
readonly jobs: JobDriver;
|
|
@@ -62,7 +70,20 @@ export interface RunningQueue {
|
|
|
62
70
|
stop(): Promise<void>;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The boot's own client, and the AMBIENT one, which are deliberately not always the same object.
|
|
75
|
+
*
|
|
76
|
+
* `setDbClient` receives the replicated pair when `DATABASE_REPLICA_URL` names a standby, so an
|
|
77
|
+
* app repository reading through `db()` inside an open `withReplicaReads` scope can be served by
|
|
78
|
+
* it. Everything this file does itself — `applySchema`, the `PgExecutor` behind the queue, the
|
|
79
|
+
* outbox, `ping()`, `close()` — keeps the PRIMARY: DDL, a claim and a migration are writes by
|
|
80
|
+
* definition, and routing one would be `25006` at best.
|
|
81
|
+
*
|
|
82
|
+
* Before this, `defaultClient()` was the only composer of a replicated pair in the framework and
|
|
83
|
+
* it runs only from `baseClient()` — the client an app installed NONE for. This line installs one,
|
|
84
|
+
* so `DATABASE_REPLICA_URL` was read by no booted process at all.
|
|
85
|
+
*/
|
|
86
|
+
function startDb(services: DevServices, env: ReplicaEnv = process.env): StartedDb {
|
|
66
87
|
const binding = services.db;
|
|
67
88
|
const client =
|
|
68
89
|
binding.mode === 'embedded'
|
|
@@ -70,8 +91,9 @@ function startDb(services: DevServices): DevDbClient {
|
|
|
70
91
|
// here is a second thing to keep right when the form changes.
|
|
71
92
|
createPgliteClient({ dataDir: pgliteDataDir(binding.url) })
|
|
72
93
|
: createPostgresClient({ url: binding.url });
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
const attached = attachReplica(client, replicaUrlFor(binding, env));
|
|
95
|
+
setDbClient(attached.client);
|
|
96
|
+
return { client, replica: attached.replica };
|
|
75
97
|
}
|
|
76
98
|
|
|
77
99
|
/**
|
|
@@ -111,6 +133,13 @@ async function applySchema(client: DevDbClient): Promise<void> {
|
|
|
111
133
|
for (const ddl of [
|
|
112
134
|
SQL_JOBS_TABLE,
|
|
113
135
|
SQL_IDEMPOTENCY_TABLE,
|
|
136
|
+
// The DDL only, and deliberately NO `setAuditSink` beside `setIdempotencyStore` below: there
|
|
137
|
+
// is no default audit sink on purpose, so `X_AUDIT_SINK_MISSING` keeps firing at boot for an
|
|
138
|
+
// app that declares `audit: true` and installs none. Applying the table without installing a
|
|
139
|
+
// sink is the same call `SQL_RATE_LIMIT_TABLE` already makes — one round trip at boot on a
|
|
140
|
+
// possibly-unused table, against `postgresAuditSink` failing its first write with
|
|
141
|
+
// `relation "x_audit" does not exist`.
|
|
142
|
+
SQL_AUDIT_TABLE,
|
|
114
143
|
SQL_RATE_LIMIT_TABLE,
|
|
115
144
|
SQL_AUTH_LIMIT_TABLES,
|
|
116
145
|
]) {
|
|
@@ -148,7 +177,11 @@ async function applySchema(client: DevDbClient): Promise<void> {
|
|
|
148
177
|
* `startServices` runs before `loadApp`, so the store is in place before `registerAction`
|
|
149
178
|
* evaluates a `scope: 'shared'` declaration against it.
|
|
150
179
|
*/
|
|
151
|
-
async function startJobs(
|
|
180
|
+
async function startJobs(
|
|
181
|
+
client: DevDbClient,
|
|
182
|
+
replica: PostgresClient | undefined,
|
|
183
|
+
overrides?: RuntimeOverrides,
|
|
184
|
+
): Promise<RunningQueue> {
|
|
152
185
|
await applySchema(client);
|
|
153
186
|
const executor = pgExecutorFor(client);
|
|
154
187
|
const driver = overrides?.jobs ?? createPgDriver({ executor });
|
|
@@ -171,7 +204,7 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
|
|
|
171
204
|
outbox,
|
|
172
205
|
events,
|
|
173
206
|
idempotency,
|
|
174
|
-
stop: () => releaseQueue(client, driver),
|
|
207
|
+
stop: () => releaseQueue(client, driver, replica),
|
|
175
208
|
};
|
|
176
209
|
}
|
|
177
210
|
|
|
@@ -184,7 +217,11 @@ async function startJobs(client: DevDbClient, overrides?: RuntimeOverrides): Pro
|
|
|
184
217
|
* The facade goes with the driver for the same reason: an enqueue routed through a store bound to
|
|
185
218
|
* a closed client is a staged row nothing will ever publish.
|
|
186
219
|
*/
|
|
187
|
-
async function releaseQueue(
|
|
220
|
+
async function releaseQueue(
|
|
221
|
+
db: DevDbClient,
|
|
222
|
+
jobs: JobDriver | undefined,
|
|
223
|
+
replica?: PostgresClient,
|
|
224
|
+
): Promise<void> {
|
|
188
225
|
// The idempotency store goes back to the memory default for the same reason the facade does: it
|
|
189
226
|
// holds this client, and the next command in this process would reserve keys over a closed one.
|
|
190
227
|
resetIdempotency();
|
|
@@ -193,6 +230,9 @@ async function releaseQueue(db: DevDbClient, jobs: JobDriver | undefined): Promi
|
|
|
193
230
|
setDbClient(undefined);
|
|
194
231
|
await jobs?.close?.();
|
|
195
232
|
await db.close();
|
|
233
|
+
// After the primary, and never instead of it: a standby pool left open is a connection slot on
|
|
234
|
+
// the other server that nothing in this process can reach again.
|
|
235
|
+
await replica?.close();
|
|
196
236
|
}
|
|
197
237
|
|
|
198
238
|
/**
|
|
@@ -205,18 +245,18 @@ export async function startQueue(
|
|
|
205
245
|
services: DevServices,
|
|
206
246
|
overrides?: RuntimeOverrides,
|
|
207
247
|
): Promise<RunningQueue> {
|
|
208
|
-
const db = startDb(services);
|
|
248
|
+
const { client: db, replica } = startDb(services);
|
|
209
249
|
try {
|
|
210
250
|
// Pay the Postgres boot here, so the first request is not the slow one and a broken database
|
|
211
251
|
// fails at boot rather than on some later query.
|
|
212
252
|
await db.ping();
|
|
213
|
-
return await startJobs(db, overrides);
|
|
253
|
+
return await startJobs(db, replica, overrides);
|
|
214
254
|
} catch (error) {
|
|
215
255
|
// `db.ping()` or `startJobs` is where a broken database is supposed to fail. Without this,
|
|
216
256
|
// the caller exits holding the PGlite lock and the ambient accessors, and nothing is left to
|
|
217
257
|
// release them. The rejection that started the unwind is the one worth reporting.
|
|
218
258
|
try {
|
|
219
|
-
await releaseQueue(db, undefined);
|
|
259
|
+
await releaseQueue(db, undefined, replica);
|
|
220
260
|
} catch {
|
|
221
261
|
// Cleanup noise never replaces the boot failure.
|
|
222
262
|
}
|