@ultimat3/cli 7.0.0 → 9.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 +24 -4
- package/README.md +8 -3
- package/package.json +26 -25
- package/src/app-boundaries.ts +55 -5
- package/src/app-load.ts +7 -0
- package/src/bin.ts +6 -3
- package/src/ci-log.ts +0 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +43 -6
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +55 -4
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +68 -6
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-verify.ts +47 -6
- package/src/dev-assets.ts +4 -7
- package/src/dev-cache.ts +130 -33
- package/src/dev-lock.ts +124 -12
- package/src/dev-purge.ts +120 -0
- package/src/dev-queue.ts +39 -9
- package/src/dev-render.ts +11 -14
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +137 -6
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +5 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -6
- package/src/island-styles.ts +1 -1
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +12 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/prerender.ts +2 -1
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +12 -4
- package/src/serve.ts +1 -1
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- package/src/solid-loader.ts +1 -1
- package/src/style-csp.ts +2 -1
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/island.ts +2 -1
- package/src/templates/route.ts +1 -1
- package/src/templates/scaffold-app.ts +3 -82
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +14 -6
- package/src/templates/scaffold-docs.ts +34 -16
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +40 -7
- package/src/test-select.ts +4 -3
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/write-line.ts +23 -5
package/CLAUDE.md
CHANGED
|
@@ -6,10 +6,12 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only |
|
|
8
8
|
| stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
|
|
9
|
+
| stderr | `write-line.ts`'s `writeErrorLine` — the same loop on fd 2, for a line that is not the command's answer. A `CommandResult` declaring `stream: 'stderr'` is routed there by `dispatch.ts`'s `sinkFor`, and `x mcp serve --transport stdio` is the one case: its fd 1 carries JSON-RPC frames, so the `✓ mcp stdio serving 13 tools` line rendered after the loop was a malformed frame. Neither renderer carries `stream`, exactly like `hold` |
|
|
10
|
+
| Boot logs under `--json` | `dispatch.ts` calls core's `setLogStream('stderr')` when `args.json` is set, once, for all thirty commands. `x db migrate --json` printed the boot logger's `ultimate migrate applied` and then the command's own object, so `json.load` raised on the second document. A server's stdout stays its log stream; this is the CLI process only |
|
|
9
11
|
| Numeric flags | `flag-number.ts` — one reader for `--port` / `--workers` / `--shard`. A bare `Number.parseInt` accepts `4abc` and answers `NaN`, which turned three checks into ones that cannot fail |
|
|
10
12
|
| Shell quoting | `shell-quote.ts`'s `quoteArg` — every value the CLI pastes into a `fix:` or a reproduce line, `exec.ts`'s missing-program refusal and `test-shards.ts`'s reproduce command both. A name holding a space or a `;` interpolated bare is an instruction that runs something else |
|
|
11
13
|
| Missing positionals | `MissingPositionalError`, never `BadFlagError` (names a flag that does not exist) and never `UnknownCommandError` (says a known command form is not one). Its `example` is a REAL invocation — `x g route <name>` in a shell is a redirect |
|
|
12
|
-
| 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
|
|
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) |
|
|
13
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 |
|
|
14
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 |
|
|
15
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`) |
|
|
@@ -533,6 +535,7 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
533
535
|
| `dev-queue.ts` | the db + queue pair alone, and the one place that takes every ambient accessor back |
|
|
534
536
|
| `dev-runtime.ts` | start the rest on top of it and install the remaining accessors (storage, mail, transport) |
|
|
535
537
|
| `dev-cache.ts` | which cache tiers this process reads through, and the cross-instance invalidation hop |
|
|
538
|
+
| `dev-purge.ts` | the hourly retention sweep: which framework tables this boot owns, the `purge()` job over them and the `task` that fires it |
|
|
536
539
|
| `dev-sync.ts` | the `sync` role: its live-query registry, who is dialling it, and the socket it owns |
|
|
537
540
|
| `runtime-overrides.ts` | the one field a host hands the framework a driver through |
|
|
538
541
|
| `sync-authenticator.ts` | the app's HTTP authenticator, seen as the sync node's |
|
|
@@ -600,6 +603,8 @@ relay draining it.
|
|
|
600
603
|
| the durable scheduler | `pgSchedulerState` + `createPgLeaseLeader` in `startRoles` | a watermark forgotten on restart, and every replica its own leader |
|
|
601
604
|
| the Postgres event bus | `dev-queue.ts` | `step.waitForEvent` forgot every correlation on restart |
|
|
602
605
|
| the shared idempotency store | `dev-queue.ts` | a retry on another replica charged the card twice |
|
|
606
|
+
| the shared auth limiter | `configureAuthLimiters` in `startServices` | account lockouts counted per POD, so N replicas granted `maxAttempts × N` guesses |
|
|
607
|
+
| the retention sweep | `dev-purge.ts`, declared in `startServices` | three `purgeExpired()` with no caller — every row `x_idempotency`, `x_rate_limit` and `x_auth_*` ever took was kept |
|
|
603
608
|
| the cache tiers | `dev-cache.ts` | only the CDN tier was registered; memo, LRU and Redis had zero callers |
|
|
604
609
|
| WebSocket authentication | `dev-sync.ts` | `actorId: null` on every socket — realtime was single-tenant by wiring |
|
|
605
610
|
| OTLP export | `otlp-export.ts` | the chart set the variable and no code read it |
|
|
@@ -767,9 +772,12 @@ Promoting it to `x verify`'s `boundaries` host check is one line in `scripts/ver
|
|
|
767
772
|
rule over names. Two stronger rules were measured and rejected: "the read must not be a property
|
|
768
773
|
initializer" reports six flags, five of which work (`x db --allow-destructive`, `x jobs --queue`);
|
|
769
774
|
"the summary must match the behaviour" is undecidable. So the flag's summary now says what it does,
|
|
770
|
-
and forcing a reload
|
|
771
|
-
|
|
772
|
-
|
|
775
|
+
and forcing a reload is **not a thing this framework does**, `As of 2026-08`. `updateSignal`
|
|
776
|
+
had no runtime caller for four majors and 9.0.0 deleted it rather than wiring it: `pwa` is tier 4
|
|
777
|
+
and the two runtimes holding both build ids — `http` (2) and `sync` (3) — are below it, so no
|
|
778
|
+
legal import could ever have reached the function. A deploy command has no channel to a running
|
|
779
|
+
client regardless; the plan is `docker compose up` or `helm upgrade`. What ships is notification:
|
|
780
|
+
`useConnection().updateAvailable` from `@ultimat3/realtime`.
|
|
773
781
|
|
|
774
782
|
## Planned commands are commands
|
|
775
783
|
|
|
@@ -833,6 +841,18 @@ refuses). A guard returning `[1n]` is `X_GUARD_FINDING_INVALID`, per candidate,
|
|
|
833
841
|
entry costs its own line and not the real findings beside it. The mechanism whose job is producing
|
|
834
842
|
structured failures handing back a stack trace is the one outcome it exists to prevent.
|
|
835
843
|
|
|
844
|
+
**`x new` ships four guards, `As of 2026-08-22`.** The scaffolded `AGENTS.md` states nine
|
|
845
|
+
non-negotiables, and five of them used to be prose — each proven green on `x verify`: a hardcoded
|
|
846
|
+
JSX string beside a `t()` call, `color: #ff0000` in a stylesheet whose own scaffolded header called
|
|
847
|
+
it "a lint failure", `toLocaleDateString('en-US')` with no `timeZone`, `t.number` money, and a bare
|
|
848
|
+
`throw new Error` in a repo. Four of the five are now guards the scaffold writes —
|
|
849
|
+
`guard-raw-colour`, `guard-unzoned-date`, `guard-bare-error`, `guard-untranslated-string` — so the
|
|
850
|
+
rule is a build error the day the app is created rather than a sentence an agent may skip. The
|
|
851
|
+
fifth, money-as-float, has **no static signature**; the scaffolded `AGENTS.md` row now points at the
|
|
852
|
+
`MoneyInput` type error that already fires, because shipping a guard that cannot work is worse than
|
|
853
|
+
naming the mechanism that does. Their codes are app codes derived from the guard name, so none of
|
|
854
|
+
them appears in `wiki/Error-Codes.md` or the manifest.
|
|
855
|
+
|
|
836
856
|
`x g guard <name>` writes `guards/<name>.ts` and its test, and nothing else — no index, no
|
|
837
857
|
registry row, no manifest entry. The emitted rule is the class of failure a guard exists for: a
|
|
838
858
|
migration that adds a `NOT NULL` column with no `DEFAULT` applies cleanly to an empty local
|
package/README.md
CHANGED
|
@@ -53,13 +53,18 @@ x verify --json
|
|
|
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 manifest roadmap`
|
|
56
|
+
contract-diff budgets seo i18n manifest roadmap`
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
Nineteen, 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.
|
|
62
|
-
|
|
62
|
+
|
|
63
|
+
`--only <step>` runs one step, for an iteration loop — it prints `NOT A GATE RUN` in the human
|
|
64
|
+
summary **and** in `--json` (`data.notAGateRun`), and it writes no floor file. **The gate is this
|
|
65
|
+
command with no flag**, which is what "one command means shippable" means. There is no `--skip`:
|
|
66
|
+
a knob that removes a step from a run that still calls itself the gate is the one thing this
|
|
67
|
+
command must not offer. The exit code is non-zero if any step fails.
|
|
63
68
|
|
|
64
69
|
A committed `x.verify.json` is the floor, `As of 2026-08`: it names the steps this repo has already
|
|
65
70
|
proved it can run, and a step it names that reports nothing is `X_VERIFY_SUITE_VANISHED` rather
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.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,30 +37,31 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/
|
|
44
|
-
"@ultimat3/
|
|
45
|
-
"@ultimat3/
|
|
46
|
-
"@ultimat3/
|
|
47
|
-
"@ultimat3/
|
|
48
|
-
"@ultimat3/
|
|
49
|
-
"@ultimat3/
|
|
50
|
-
"@ultimat3/
|
|
51
|
-
"@ultimat3/
|
|
52
|
-
"@ultimat3/
|
|
53
|
-
"@ultimat3/
|
|
54
|
-
"@ultimat3/
|
|
55
|
-
"@ultimat3/
|
|
56
|
-
"@ultimat3/
|
|
57
|
-
"@ultimat3/
|
|
58
|
-
"@ultimat3/
|
|
59
|
-
"@ultimat3/
|
|
60
|
-
"@ultimat3/
|
|
61
|
-
"@ultimat3/
|
|
62
|
-
"@ultimat3/
|
|
63
|
-
"@ultimat3/
|
|
40
|
+
"@ultimat3/action": "9.0.0",
|
|
41
|
+
"@ultimat3/admin": "9.0.0",
|
|
42
|
+
"@ultimat3/ai": "9.0.0",
|
|
43
|
+
"@ultimat3/auth": "9.0.0",
|
|
44
|
+
"@ultimat3/cache": "9.0.0",
|
|
45
|
+
"@ultimat3/core": "9.0.0",
|
|
46
|
+
"@ultimat3/db": "9.0.0",
|
|
47
|
+
"@ultimat3/entity": "9.0.0",
|
|
48
|
+
"@ultimat3/http": "9.0.0",
|
|
49
|
+
"@ultimat3/i18n": "9.0.0",
|
|
50
|
+
"@ultimat3/jobs": "9.0.0",
|
|
51
|
+
"@ultimat3/mail": "9.0.0",
|
|
52
|
+
"@ultimat3/manifest": "9.0.0",
|
|
53
|
+
"@ultimat3/mcp": "9.0.0",
|
|
54
|
+
"@ultimat3/policy": "9.0.0",
|
|
55
|
+
"@ultimat3/pwa": "9.0.0",
|
|
56
|
+
"@ultimat3/query": "9.0.0",
|
|
57
|
+
"@ultimat3/realtime": "9.0.0",
|
|
58
|
+
"@ultimat3/render": "9.0.0",
|
|
59
|
+
"@ultimat3/schema": "9.0.0",
|
|
60
|
+
"@ultimat3/scraping": "9.0.0",
|
|
61
|
+
"@ultimat3/seo": "9.0.0",
|
|
62
|
+
"@ultimat3/storage": "9.0.0",
|
|
63
|
+
"@ultimat3/testing": "9.0.0",
|
|
64
|
+
"@ultimat3/time": "9.0.0",
|
|
64
65
|
"babel-preset-solid": "^1.9.15"
|
|
65
66
|
}
|
|
66
67
|
}
|
package/src/app-boundaries.ts
CHANGED
|
@@ -15,8 +15,9 @@ import { join as joinPath } from 'node:path';
|
|
|
15
15
|
// The POSIX variants resolve specifiers against import-graph keys, which are POSIX on every host.
|
|
16
16
|
import { dirname, join, normalize, relative } from 'node:path/posix';
|
|
17
17
|
import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
|
|
18
|
-
import { checkSurfaceBoundary, importGraph } from '@ultimat3/render';
|
|
18
|
+
import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
|
|
19
19
|
import type { Finding } from './output';
|
|
20
|
+
import { quoteArg } from './shell-quote';
|
|
20
21
|
|
|
21
22
|
export const BOUNDARY_CODES = [
|
|
22
23
|
'X_BOUNDARY_SITE_TO_APP',
|
|
@@ -132,8 +133,57 @@ const surfaceFindings = (graph: ImportGraph): readonly Finding[] =>
|
|
|
132
133
|
};
|
|
133
134
|
});
|
|
134
135
|
|
|
135
|
-
/**
|
|
136
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The surface names, from `@ultimat3/render`'s own list rather than a copy: a resource can never
|
|
138
|
+
* be called one, because the directory carrying that name is the surface itself.
|
|
139
|
+
*/
|
|
140
|
+
const SURFACE_NAMES: ReadonlySet<string> = new Set<string>(SURFACES);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `apps/web/app/posts/service.ts` → `posts`: the primitive a generator would be told to make.
|
|
144
|
+
*
|
|
145
|
+
* `undefined` at a surface ROOT, where the directory above the file is the surface. The old
|
|
146
|
+
* answer there was the surface's own name, so `X_BOUNDARY_ROUTE_TO_DB` on `apps/web/site/page.tsx`
|
|
147
|
+
* said `x g query site` — a runnable line that generates seven files and a `sites` table for a
|
|
148
|
+
* landing page whose only problem is one import. A fix that does the wrong thing successfully is
|
|
149
|
+
* worse than one that refuses, so the caller names the file and asks for a name instead.
|
|
150
|
+
*/
|
|
151
|
+
function subjectOf(path: string): string | undefined {
|
|
152
|
+
const parent = path.split('/').at(-2);
|
|
153
|
+
return parent === undefined || SURFACE_NAMES.has(parent) ? undefined : parent;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Control characters, `\u00xx`-escaped. The path is a value read off the repository being scanned,
|
|
158
|
+
* and it rides in a `#` comment: a directory holding a NEWLINE ends that comment, so everything
|
|
159
|
+
* after it is a second command in a line whose whole purpose is to be pasted into a shell.
|
|
160
|
+
* Escaped rather than deleted, because the comment still has to name the file the reader owns.
|
|
161
|
+
*/
|
|
162
|
+
const commentSafe = (path: string): string =>
|
|
163
|
+
[...path]
|
|
164
|
+
.map((char) => {
|
|
165
|
+
const code = char.codePointAt(0) ?? 0;
|
|
166
|
+
return code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : char;
|
|
167
|
+
})
|
|
168
|
+
.join('');
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `x g query posts` where the path names a resource, `x g query <name>` where it names a surface.
|
|
172
|
+
* The placeholder form is the shape `MissingPositionalError` already hands out (`x g route
|
|
173
|
+
* <name>`) and the one the `errors` step leaves unjudged in that slot — an open positional, where
|
|
174
|
+
* a reader substituting a word makes the line run. The path rides in the `#` comment because a
|
|
175
|
+
* `fix:` is copied on its own, and `Finding.at` is a field an agent pasting one line never sees.
|
|
176
|
+
*
|
|
177
|
+
* BOTH halves come from the scanned path, so both are hostile: the subject is an ARGUMENT and goes
|
|
178
|
+
* through `quoteArg` — a directory named `posts; id` emitted a fix that ran `id` — and the comment
|
|
179
|
+
* goes through `commentSafe`. `<name>` alone stays literal: it is a placeholder a reader replaces,
|
|
180
|
+
* and `'<name>'` would be pasted as a resource actually called that.
|
|
181
|
+
*/
|
|
182
|
+
const generate = (kind: 'query' | 'action', path: string, then: string): string => {
|
|
183
|
+
const subject = subjectOf(path);
|
|
184
|
+
const named = subject === undefined ? '<name>' : quoteArg(subject);
|
|
185
|
+
return `x g ${kind} ${named} # ${then} ${commentSafe(path)}`;
|
|
186
|
+
};
|
|
137
187
|
|
|
138
188
|
/**
|
|
139
189
|
* Both fixes are one runnable line, with the rest of the instruction behind a `#` — a fix a
|
|
@@ -147,7 +197,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
|
|
|
147
197
|
findings.push({
|
|
148
198
|
code: 'X_BOUNDARY_ROUTE_TO_DB',
|
|
149
199
|
cause: `route imports the database ("${specifier}") — routes call actions and queries`,
|
|
150
|
-
fix:
|
|
200
|
+
fix: generate('query', file.path, 'then call it from'),
|
|
151
201
|
docs: docs('X_BOUNDARY_ROUTE_TO_DB'),
|
|
152
202
|
at: file.path,
|
|
153
203
|
});
|
|
@@ -156,7 +206,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
|
|
|
156
206
|
findings.push({
|
|
157
207
|
code: 'X_BOUNDARY_SERVICE_TO_HTTP',
|
|
158
208
|
cause: `service imports HTTP ("${specifier}") — a service that knows about requests cannot be reused by a job`,
|
|
159
|
-
fix:
|
|
209
|
+
fix: generate('action', file.path, 'read the request there and pass plain values to'),
|
|
160
210
|
docs: docs('X_BOUNDARY_SERVICE_TO_HTTP'),
|
|
161
211
|
at: file.path,
|
|
162
212
|
});
|
package/src/app-load.ts
CHANGED
|
@@ -11,6 +11,13 @@ import { localeConfig } from '@ultimat3/i18n';
|
|
|
11
11
|
import type { ErrorCodeFact } from '@ultimat3/manifest';
|
|
12
12
|
import { registerQueries } from '@ultimat3/query';
|
|
13
13
|
import { isRouteConfig, pageComponentOf, registerRoute } from '@ultimat3/render';
|
|
14
|
+
// For the SIDE EFFECT, and it is this module's to hold: importing `@ultimat3/render/server`
|
|
15
|
+
// installs the `.tsx`/`.scss` Bun plugin, a plugin only transforms modules loaded AFTER it, and
|
|
16
|
+
// every app module below is loaded by the dynamic `import()` in this file. Before the render
|
|
17
|
+
// barrel split it came free with the line above; after it, the only other path to `/server` from
|
|
18
|
+
// here is six hops through `error-contract` → `fix-command` → the command registry, which is an
|
|
19
|
+
// accident one refactor away from compiling every app's `.tsx` to `React.createElement`.
|
|
20
|
+
import '@ultimat3/render/server';
|
|
14
21
|
import { collectDeclaredCodes } from './error-contract';
|
|
15
22
|
import type { Finding } from './output';
|
|
16
23
|
import { findingFrom } from './output';
|
package/src/bin.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// dispatch.ts, so the whole CLI is testable without spawning a process.
|
|
4
4
|
|
|
5
5
|
import { dispatch } from './dispatch';
|
|
6
|
-
// The
|
|
7
|
-
// and a second copy of a note about pipe truncation is a second copy that drifts.
|
|
8
|
-
|
|
6
|
+
// The writes themselves are `write-line.ts`: `create-ultimate`'s entry point needs the identical
|
|
7
|
+
// one, and a second copy of a note about pipe truncation is a second copy that drifts. Two sinks,
|
|
8
|
+
// because fd 1 is not always this process's to write on — `x mcp serve --transport stdio` hands it
|
|
9
|
+
// to the protocol, and `dispatch` addresses that result to the second.
|
|
10
|
+
import { writeErrorLine, writeLine } from './write-line';
|
|
9
11
|
|
|
10
12
|
const code = await dispatch({
|
|
11
13
|
argv: Bun.argv.slice(2),
|
|
@@ -13,6 +15,7 @@ const code = await dispatch({
|
|
|
13
15
|
env: Bun.env,
|
|
14
16
|
bunVersion: Bun.version,
|
|
15
17
|
write: writeLine,
|
|
18
|
+
writeError: writeErrorLine,
|
|
16
19
|
});
|
|
17
20
|
|
|
18
21
|
process.exit(code);
|
package/src/ci-log.ts
CHANGED
|
Binary file
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// `x db backfill`'s wiring alone: which of the four shapes an invocation asked for, and the one
|
|
2
|
+
// finding-and-table projection each answers with. Split from `cmd-db.ts` the way `cmd-db-branch.ts`
|
|
3
|
+
// was — that file had reached the 500-line ceiling, and "which subcommand ran" and "what a sweep
|
|
4
|
+
// pass reports" are two jobs. The facts live in `db-backfill.ts`; nothing here opens a database.
|
|
5
|
+
|
|
6
|
+
import { resolveEnvironment } from '@ultimat3/core';
|
|
7
|
+
import { BackfillPendingError } from '@ultimat3/jobs';
|
|
8
|
+
import { loadApp } from './app-load';
|
|
9
|
+
import type { CommandContext } from './command';
|
|
10
|
+
import type { BackfillAction, BackfillPlanRow } from './db-backfill';
|
|
11
|
+
import {
|
|
12
|
+
listBackfills,
|
|
13
|
+
pendingReport,
|
|
14
|
+
pendingToJson,
|
|
15
|
+
planToJson,
|
|
16
|
+
readAppliedMigrations,
|
|
17
|
+
renderBackfillTable,
|
|
18
|
+
renderPendingTable,
|
|
19
|
+
renderPlanTable,
|
|
20
|
+
runBackfills,
|
|
21
|
+
} from './db-backfill';
|
|
22
|
+
import { BadFlagError } from './errors';
|
|
23
|
+
import { withJobDriver } from './jobs-driver';
|
|
24
|
+
import { backfillToJson } from './jobs-json';
|
|
25
|
+
import { msg } from './messages';
|
|
26
|
+
import type { CommandResult } from './output';
|
|
27
|
+
import { findingFrom } from './output';
|
|
28
|
+
import type { ParsedArgs } from './parse';
|
|
29
|
+
import { flagBool, flagString } from './parse';
|
|
30
|
+
|
|
31
|
+
/** Which of the four questions an invocation asked. `pass` is `<name>` and `--all` both. */
|
|
32
|
+
type BackfillShape = 'list' | 'pending' | 'pass';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every flag that belongs to exactly ONE shape — `x db backfill`'s own usage line, executable.
|
|
36
|
+
*
|
|
37
|
+
* `--name` is absent because it is two things: `--list`'s filter and the pass's target, decided by
|
|
38
|
+
* `readShape` below. Everything else reads for one shape and is ignored by the other two, which is
|
|
39
|
+
* the silence this table ends.
|
|
40
|
+
*/
|
|
41
|
+
const SHAPE_OF_FLAG = Object.freeze<Record<string, BackfillShape>>({
|
|
42
|
+
status: 'list',
|
|
43
|
+
limit: 'list',
|
|
44
|
+
write: 'pass',
|
|
45
|
+
force: 'pass',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** One runnable line per shape, so a refusal hands back the invocation the caller meant. */
|
|
49
|
+
const FIX_OF_SHAPE = Object.freeze<Record<BackfillShape, string>>({
|
|
50
|
+
list: 'x db backfill --list --json',
|
|
51
|
+
pending: 'x db backfill --pending --json',
|
|
52
|
+
pass: 'x db backfill --all --write --json',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const refuseShape = (flag: string, reason: string, fix: string): never => {
|
|
56
|
+
throw new BadFlagError({ flag, command: 'db', reason, fix });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Exactly one shape, and only the flags that shape reads — refused before anything opens a
|
|
61
|
+
* database, never resolved by precedence.
|
|
62
|
+
*
|
|
63
|
+
* Precedence is what this replaces, and it was silent in the dangerous direction:
|
|
64
|
+
* `x db backfill cleanup --all --write` took the `--all` branch and ENQUEUED every pending sweep
|
|
65
|
+
* while the operator had named one, and `--list --pending` reported the ledger for a command that
|
|
66
|
+
* asked what was unswept. Axiom 1 — one way to do each thing — makes a second reading of one argv
|
|
67
|
+
* a refusal rather than a choice the command makes on the caller's behalf.
|
|
68
|
+
*/
|
|
69
|
+
function readShape(args: ParsedArgs): { readonly shape: BackfillShape; readonly name?: string } {
|
|
70
|
+
const list = flagBool(args, 'list');
|
|
71
|
+
const positional = args.positionals[0];
|
|
72
|
+
const named = flagString(args, 'name');
|
|
73
|
+
// `--name` is a FILTER under `--list` and a target everywhere else, so it selects a shape only
|
|
74
|
+
// where `--list` is absent. Two spellings of one target are still two, and are refused.
|
|
75
|
+
const target = list ? undefined : (positional ?? named);
|
|
76
|
+
if (!list && positional !== undefined && named !== undefined) {
|
|
77
|
+
return refuseShape(
|
|
78
|
+
'name',
|
|
79
|
+
`x db backfill names two backfills ("${positional}" and "${named}") — a pass sweeps the positional or --name, never both`,
|
|
80
|
+
`x db backfill ${positional} --write --json`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const asked = [
|
|
84
|
+
...(list ? ['--list'] : []),
|
|
85
|
+
...(flagBool(args, 'pending') ? ['--pending'] : []),
|
|
86
|
+
...(flagBool(args, 'all') ? ['--all'] : []),
|
|
87
|
+
...(target === undefined ? [] : [target]),
|
|
88
|
+
];
|
|
89
|
+
if (asked.length > 1) {
|
|
90
|
+
// The SECOND shape is the one that would have been dropped, so it is the one the cause names:
|
|
91
|
+
// `--all` won over a named sweep and the operator was never told which of the two ran.
|
|
92
|
+
const second = asked[1] ?? '';
|
|
93
|
+
return refuseShape(
|
|
94
|
+
second.startsWith('--') ? second.slice(2) : 'name',
|
|
95
|
+
`x db backfill was asked for ${asked.join(' and ')} — one shape per invocation: --list, --pending, <name> or --all`,
|
|
96
|
+
`x db backfill ${asked[0]} --json`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (asked.length === 0) {
|
|
100
|
+
return refuseShape(
|
|
101
|
+
'list',
|
|
102
|
+
'x db backfill needs a shape: --list (the ledger), --pending (declared minus completed), <name> or --all (run one, or every pending one)',
|
|
103
|
+
'x db backfill --pending --json',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const shape: BackfillShape = list ? 'list' : flagBool(args, 'pending') ? 'pending' : 'pass';
|
|
107
|
+
for (const [flag, owner] of Object.entries(SHAPE_OF_FLAG)) {
|
|
108
|
+
if (owner === shape || !args.flags.has(flag)) continue;
|
|
109
|
+
refuseShape(
|
|
110
|
+
flag,
|
|
111
|
+
`x db backfill --${flag} belongs to ${owner === 'pass' ? 'a <name>/--all pass' : `--${owner}`}, and this invocation asked for ${asked[0]}`,
|
|
112
|
+
FIX_OF_SHAPE[owner],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return target === undefined ? { shape } : { shape, name: target };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
|
|
120
|
+
* against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
|
|
121
|
+
* bare `x db backfill` is still refused rather than defaulted — the four answer four different
|
|
122
|
+
* questions, and picking one for the operator is the ambiguity axiom 1 exists to refuse.
|
|
123
|
+
*
|
|
124
|
+
* An empty ledger is `ok: true`. "Nothing has swept this database yet" is an answer to the
|
|
125
|
+
* question asked, and a command that failed over it would be unrunnable on a fresh app.
|
|
126
|
+
*/
|
|
127
|
+
export async function runBackfillCommand(
|
|
128
|
+
ctx: CommandContext,
|
|
129
|
+
root: string,
|
|
130
|
+
): Promise<CommandResult> {
|
|
131
|
+
const asked = readShape(ctx.args);
|
|
132
|
+
if (asked.shape === 'list') return runBackfillList(ctx, root);
|
|
133
|
+
if (asked.shape === 'pending') return runBackfillPending(ctx, root);
|
|
134
|
+
return runBackfillPass(ctx, root, asked.name === undefined ? 'all' : [asked.name]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function runBackfillList(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
138
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
139
|
+
const rows = await listBackfills(driver, {
|
|
140
|
+
name: flagString(ctx.args, 'name'),
|
|
141
|
+
status: flagString(ctx.args, 'status'),
|
|
142
|
+
limit: flagString(ctx.args, 'limit'),
|
|
143
|
+
});
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
command: 'db',
|
|
147
|
+
summary:
|
|
148
|
+
rows.length === 0
|
|
149
|
+
? msg('cli.db.backfill.empty')
|
|
150
|
+
: msg('cli.db.backfill.listed', { count: rows.length }),
|
|
151
|
+
lines: rows.length === 0 ? [] : renderBackfillTable(rows).map((line) => ` ${line}`),
|
|
152
|
+
data: rows.map(backfillToJson),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The alarm the framework did not have. Non-zero when anything is unswept, so a cron or a deploy
|
|
159
|
+
* check can read the exit code — a `--json` nobody has to parse to know something is wrong.
|
|
160
|
+
* `loadApp` first: importing the app's modules IS the declaration, and a diff run without it
|
|
161
|
+
* would report a clean database against an empty declaration list.
|
|
162
|
+
*/
|
|
163
|
+
async function runBackfillPending(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
164
|
+
await loadApp(root);
|
|
165
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
166
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
167
|
+
const report = await pendingReport(driver, environment);
|
|
168
|
+
return {
|
|
169
|
+
ok: report.pending.length === 0,
|
|
170
|
+
command: 'db',
|
|
171
|
+
summary:
|
|
172
|
+
report.pending.length === 0
|
|
173
|
+
? msg('cli.db.backfill.swept', { declared: report.rows.length })
|
|
174
|
+
: msg('cli.db.backfill.pending', {
|
|
175
|
+
count: report.pending.length,
|
|
176
|
+
declared: report.rows.length,
|
|
177
|
+
}),
|
|
178
|
+
findings: report.pending.map((row) =>
|
|
179
|
+
findingFrom(new BackfillPendingError({ backfill: row.name, environment })),
|
|
180
|
+
),
|
|
181
|
+
lines: report.rows.length === 0 ? [] : renderPendingTable(report).map((line) => ` ${line}`),
|
|
182
|
+
data: pendingToJson(report),
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* DRY RUN by default: `--write` is never implied, because the alternative is a command whose
|
|
189
|
+
* inspection form writes to a production table. What `--write` does is ENQUEUE — the queue is a
|
|
190
|
+
* job's execution surface, so the sweep runs on the workers already serving the new release
|
|
191
|
+
* rather than inside this process.
|
|
192
|
+
*/
|
|
193
|
+
async function runBackfillPass(
|
|
194
|
+
ctx: CommandContext,
|
|
195
|
+
root: string,
|
|
196
|
+
names: readonly string[] | 'all',
|
|
197
|
+
): Promise<CommandResult> {
|
|
198
|
+
await loadApp(root);
|
|
199
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
200
|
+
const write = flagBool(ctx.args, 'write');
|
|
201
|
+
return withJobDriver(root, ctx, async (driver) => {
|
|
202
|
+
const rows = await runBackfills({
|
|
203
|
+
driver,
|
|
204
|
+
names,
|
|
205
|
+
write,
|
|
206
|
+
force: flagBool(ctx.args, 'force'),
|
|
207
|
+
environment,
|
|
208
|
+
appliedMigrations: await readAppliedMigrations(),
|
|
209
|
+
});
|
|
210
|
+
return backfillPassResult(rows, write);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A blocked or deduped name is a finding and a non-zero exit, and every OTHER name still ran —
|
|
216
|
+
* that isolation is what stops one wedged cleanup blocking every later one forever.
|
|
217
|
+
*/
|
|
218
|
+
function backfillPassResult(rows: readonly BackfillPlanRow[], write: boolean): CommandResult {
|
|
219
|
+
const findings = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
|
|
220
|
+
// Counted per action, never derived from the total: a deduped pass is neither enqueued nor
|
|
221
|
+
// blocked, and `rows.length - enqueued` reported it as blocked while `--json` reported it as
|
|
222
|
+
// deduped. `planToJson` is the same list, so the two renders now add up to the same run.
|
|
223
|
+
const tally = (action: BackfillAction): number =>
|
|
224
|
+
rows.filter((row) => row.action === action).length;
|
|
225
|
+
return {
|
|
226
|
+
ok: findings.length === 0,
|
|
227
|
+
command: 'db',
|
|
228
|
+
summary: write
|
|
229
|
+
? msg('cli.db.backfill.planned', {
|
|
230
|
+
count: rows.length,
|
|
231
|
+
enqueued: tally('enqueued'),
|
|
232
|
+
deduped: tally('deduped'),
|
|
233
|
+
blocked: tally('blocked'),
|
|
234
|
+
})
|
|
235
|
+
: msg('cli.db.backfill.dryRun', { count: rows.length }),
|
|
236
|
+
findings,
|
|
237
|
+
lines: rows.length === 0 ? [] : renderPlanTable(rows).map((line) => ` ${line}`),
|
|
238
|
+
data: planToJson(rows),
|
|
239
|
+
};
|
|
240
|
+
}
|
package/src/cmd-db-branch.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// `x db branch ls` used to clone a database called `ls`, because the argument was the name.
|
|
4
4
|
// The facts (what a branch is, per mode) are `db-branch.ts`; the client lifetime is here.
|
|
5
5
|
|
|
6
|
+
import { nearestName } from '@ultimat3/core';
|
|
6
7
|
import { createPostgresClient, type DbClient } from '@ultimat3/db';
|
|
7
8
|
import type { CommandContext } from './command';
|
|
8
9
|
import type { BranchRow } from './db-branch';
|
|
@@ -27,7 +28,7 @@ import { resolveServices } from './dev-services';
|
|
|
27
28
|
import { MissingPositionalError, UnknownCommandError } from './errors';
|
|
28
29
|
import { msg } from './messages';
|
|
29
30
|
import type { CommandResult, Finding } from './output';
|
|
30
|
-
import { flagString
|
|
31
|
+
import { flagString } from './parse';
|
|
31
32
|
import { portFromEnv } from './serve';
|
|
32
33
|
import { renderTable } from './table';
|
|
33
34
|
|
|
@@ -46,7 +47,7 @@ const LIST_FIX = `x ${LIST_ARGV}`;
|
|
|
46
47
|
* `x` excluded — the error class adds it.
|
|
47
48
|
*/
|
|
48
49
|
function branchRetry(word: string, name: string | undefined): string {
|
|
49
|
-
const near =
|
|
50
|
+
const near = nearestName(word, [...BRANCH_SUBCOMMANDS]);
|
|
50
51
|
if (near !== undefined) return name === undefined ? LIST_ARGV : `db branch ${near} ${name}`;
|
|
51
52
|
return isBranchName(word) ? `db branch create ${word}` : LIST_ARGV;
|
|
52
53
|
}
|