@ultimat3/cli 2.0.0 → 4.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 +109 -13
- package/README.md +1 -0
- package/package.json +24 -24
- package/src/budgets.ts +31 -8
- package/src/cmd-db-branch.ts +6 -2
- package/src/cmd-db.ts +138 -10
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-dev.ts +9 -2
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-doctor.ts +16 -7
- package/src/cmd-fix.ts +15 -3
- package/src/cmd-generate.ts +29 -4
- package/src/cmd-help.ts +25 -4
- package/src/cmd-i18n.ts +8 -5
- package/src/cmd-jobs.ts +6 -5
- package/src/cmd-mcp.ts +16 -12
- package/src/cmd-new.ts +10 -14
- package/src/cmd-planned.ts +13 -0
- package/src/cmd-policy.ts +8 -6
- package/src/cmd-registries.ts +7 -6
- package/src/cmd-routes.ts +27 -4
- package/src/cmd-secrets.ts +6 -6
- package/src/cmd-test.ts +14 -3
- package/src/cmd-verify.ts +80 -10
- package/src/command.ts +10 -2
- package/src/db-branch.ts +18 -0
- package/src/db-generate.ts +38 -6
- package/src/db-seed.ts +294 -0
- package/src/dev-assets.ts +22 -3
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-roles.ts +5 -3
- package/src/dev-runtime.ts +2 -2
- package/src/dev-storage.ts +6 -4
- package/src/dev-traces.ts +26 -4
- package/src/dispatch.ts +33 -4
- package/src/drift.ts +41 -1
- package/src/error-catalog.ts +1 -0
- package/src/error-codes.ts +11 -0
- package/src/error-contract.ts +31 -4
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-number.ts +11 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +19 -5
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +13 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +15 -0
- package/src/metrics-endpoint.ts +60 -13
- package/src/otlp-export.ts +14 -0
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -0
- package/src/serve.ts +15 -3
- package/src/shell-quote.ts +15 -0
- package/src/templates/action.ts +39 -7
- package/src/templates/backfill.ts +3 -1
- package/src/templates/index.ts +10 -1
- package/src/templates/job.ts +6 -2
- package/src/templates/query.ts +6 -1
- package/src/templates/route.ts +18 -9
- package/src/templates/scaffold-api.ts +100 -0
- package/src/templates/scaffold-app.ts +8 -48
- package/src/templates/scaffold-container.ts +44 -9
- package/src/templates/scaffold-helm-templates.ts +327 -0
- package/src/templates/scaffold-helm.ts +144 -0
- package/src/templates/scaffold-repo.ts +25 -8
- package/src/test-shards.ts +1 -10
- package/src/test-workers.ts +4 -1
- package/src/ts-scan.ts +25 -176
- package/src/tsconfig-references.ts +27 -2
- package/src/verify-step.ts +5 -0
package/CLAUDE.md
CHANGED
|
@@ -7,12 +7,16 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
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
9
|
| 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
|
+
| 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 |
|
|
10
11
|
| 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 |
|
|
11
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>`, never `x <command> --help`: the subcommand is resolved *after* the flag loop, so the latter throws the same error again — a fix line that reproduced its own failure |
|
|
13
|
+
| 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
|
+
| 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
|
+
| 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 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
|
|
12
16
|
| I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
|
|
13
17
|
| Staying up | a command still listening when `run` resolves returns `hold` (`hold.ts`), or `bin.ts` exits out from under it |
|
|
14
18
|
| `--json` | every command, no exceptions — same data as the human render |
|
|
15
|
-
| Errors | codes + titles in `src/error-codes.ts`, classes in `src/errors.ts`, subclass `UltimateError`, never a bare `Error` |
|
|
19
|
+
| Errors | codes + titles in `src/error-codes.ts`, classes in `src/errors.ts`, subclass `UltimateError`, never a bare `Error`. A class may sit beside its one thrower when `errors.ts` has no room under the 500-line ceiling — `db-seed.ts` and `metrics-endpoint.ts` do |
|
|
16
20
|
| Subprocesses | only through `exec.ts`, so a test can inject a fake `Runner` |
|
|
17
21
|
| Templates | `templates/*.ts` return strings; no fixture files on disk |
|
|
18
22
|
| Strings | rendered output through `messages.ts`, missing key renders `⟦key⟧` — see below for what is *not* rendered output |
|
|
@@ -116,7 +120,9 @@ a shallow spread keeps one of them.
|
|
|
116
120
|
|
|
117
121
|
| File | Job |
|
|
118
122
|
|---|---|
|
|
119
|
-
| `ts-scan.ts` | the
|
|
123
|
+
| `ts-scan.ts` | the masking every scan shares, the `X_*` codes a file declares, and the ones it says it borrows |
|
|
124
|
+
| `fix-scan.ts` | the strings a `fix:` can evaluate to: under a key, at a factory's argument, at a class constructor's |
|
|
125
|
+
| `fix-imports.ts` | which of those factories a file can call that it did not declare — one relative specifier, one file read |
|
|
120
126
|
| `error-contract.ts` | the rules, the two checks that turn them into findings, and `collectDeclaredCodes` |
|
|
121
127
|
| `fix-command.ts` | resolving an `x <command>` a `fix:` cites against the registry |
|
|
122
128
|
| `source-files.ts` | which files are shipped source — shared with `filesize`, never a second list |
|
|
@@ -145,6 +151,8 @@ declared from the constant the command validates against), because `x jobs show
|
|
|
145
151
|
working invocations. `positionalChoices` cannot express it: `fix-command.ts` reads that field only
|
|
146
152
|
where a command declares no subcommands at all.
|
|
147
153
|
|
|
154
|
+
**A command with no subcommands must still declare `positionalChoices`, or its second word is unjudged.** `x g migration` shipped in two `@ultimat3/admin` fix lines — a generator that has never existed, answered with `X_CLI_UNKNOWN_COMMAND` when run — because the `g` spec declared none and `fix-command.ts` returns `undefined` for an absent set. The third instance of this class (#131, then `x db branch <name>`). `cmd-generate.ts` now declares `positionalChoices: GENERATORS`, from the SAME constant `readKind` validates against, exactly as `cmd-test.ts` declares `TEST_TYPES` — `fix-command.test.ts` pins `x g migration` as a finding against the real catalog. A new command whose first positional is a closed set and does not declare it is a hole this rule cannot see.
|
|
155
|
+
|
|
148
156
|
**And in that one slot, a `<placeholder>` is a finding too.** `x db branch <name>` is what two
|
|
149
157
|
`@ultimat3/mcp` fix lines said; the citation reader does not read `<name>` as a word, so the slot
|
|
150
158
|
was never examined and the line resolved clean while running it answers `X_CLI_UNKNOWN_COMMAND` —
|
|
@@ -176,7 +184,7 @@ and the step says so rather than guessing.
|
|
|
176
184
|
helper, so the file held no `fix:` at all and `scanFixes` returned `[]` for all of it — the
|
|
177
185
|
citation resolver was never given a string to judge, and two stale `x db branch <name>` lines
|
|
178
186
|
shipped through the hole. `scanFixes` now also reads the argument in the `fix: string` position of
|
|
179
|
-
a **local** helper, under four rules, each with its own case in `
|
|
187
|
+
a **local** helper, under four rules, each with its own case in `fix-scan.test.ts`: the helper must
|
|
180
188
|
BUILD an error (`code` key or `new …Error(` in its body), or `citedCommandProblem(fix, catalog)` —
|
|
181
189
|
which takes a fix to *judge* it — would have its call sites read as declarations; the parameter
|
|
182
190
|
list may hold no rest or destructured parameter, because neither has a reliable position; the call
|
|
@@ -185,12 +193,31 @@ because `prefix + 'x doctor'` reads as one literal there and publishing half a f
|
|
|
185
193
|
publishing none. Measured over the whole tree: 16 files gained readable fixes, `readonly-sql.ts`
|
|
186
194
|
went from 0 to 7, and **zero** new findings.
|
|
187
195
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
196
|
+
**And a fix does not always arrive in the file that declares its builder**, which is where four
|
|
197
|
+
bad `fix:` lines in `packages/ui/src/icons/build-icons.ts` shipped: `invalidIconDataError` is
|
|
198
|
+
declared in `packages/ui/src/errors.ts`, and a per-package `errors.ts` full of factories is the
|
|
199
|
+
house pattern, so the same-file rule left the most common shape of all unchecked. `fix-imports.ts`
|
|
200
|
+
resolves it — the specifier is relative, the candidate paths are `<base>.ts{,x}` and
|
|
201
|
+
`<base>/index.ts{,x}`, and the parameter position is the callee's. An alias is renamed to what the
|
|
202
|
+
CALLER writes; a local declaration of the same name wins, because that is the function the call
|
|
203
|
+
actually reaches. One module cache per run: `errors.ts` is imported by every file in its package.
|
|
204
|
+
|
|
205
|
+
**An error CLASS is the same helper one keyword away**, and is now read too: the name is the
|
|
206
|
+
class's, the parameter list its `constructor`'s, `new X(…)` is a call like any other. It was
|
|
207
|
+
measured as dead code in the same-file rule — zero same-file call sites — and cross-file it is
|
|
208
|
+
`@ultimat3/render`'s fourteen classes plus `@ultimat3/core`'s three image ones.
|
|
209
|
+
|
|
210
|
+
Measured over the whole tree, `As of 2026-08`: **791 → 877** fix literals read, 37 files gained
|
|
211
|
+
one, and **3 findings** the gate had never been able to see — `x verify --contract` and
|
|
212
|
+
`x build --route` (two flags no command declares) and one `check …` line with no command token.
|
|
213
|
+
|
|
214
|
+
What it still cannot see is a builder imported from another **package**: `candidatePaths` refuses a
|
|
215
|
+
non-relative specifier, because resolving one means guessing which of 29 packages a bare name came
|
|
216
|
+
from and a wrong guess reads an unrelated function's argument as a fix. Measured: 3 call sites in
|
|
217
|
+
this repo, none of them a finding. It is **not** left silent — the step's `output` carries
|
|
218
|
+
`checked {n} fix line(s), could not read {m}`, counted at `FixScan.unreadable`: an argument in a
|
|
219
|
+
KNOWN fix position that is not one literal. Deliberately not "imports I could not open", which is
|
|
220
|
+
1504 names here and 1310 of them are `join` and `UltimateError` — a number nobody can act on.
|
|
194
221
|
|
|
195
222
|
`cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`): `x dev` **mounts** the
|
|
196
223
|
dashboard, it never grows a second one. The CLI's only contribution is the facts no registry
|
|
@@ -285,6 +312,7 @@ regex and `+` is a quantifier — `n1` is what actually selects these tests.
|
|
|
285
312
|
| `drift.ts` | `checkSourceDrift`: the `.hash` sidecar `x verify`'s `drift` step compares, no database needed |
|
|
286
313
|
| `db-destructive.ts` | `checkDestructiveMigrations`: the same step's second half — every committed `up` that drops, truncates or retypes must carry `-- destructive: true` |
|
|
287
314
|
| `db-backfill.ts` | `x db backfill --list`: the flag parsing, the ledger read and the table |
|
|
315
|
+
| `db-seed.ts` | `x db seed`, everything except the argv: `SEED_GLOBS` (where a seed is declared), `discoverSeeds`, `parseSeedTierFlag`, `selectSeeds` (which seeds this invocation runs, and its two refusals — `X_DECLARATION_UNKNOWN` and `X_SEED_ENVIRONMENT`), `runSeeds` (one transaction **per seed**, never one around the run) and the two renderers. The `db-backfill.ts` split repeated: a driver plus plain strings in, plain rows out, so every rule is testable with no `ParsedArgs` and no boot. Which tiers an environment takes is `@ultimat3/entity`'s `seedTiersFor` — two copies of "may this seed run" would be two answers |
|
|
288
316
|
|
|
289
317
|
`jobs-driver.ts` is the ONE place a CLI command gets hold of the app's queue — `withJobDriver`,
|
|
290
318
|
which `x jobs` and `x db backfill` both call. It reuses an ambient `jobDriver()` when a process
|
|
@@ -311,8 +339,12 @@ The typo is impossible rather than the keystroke tedious — and `@ultimat3/db`
|
|
|
311
339
|
`x db branch drop <name>` as `X_BRANCH_EXISTS`'s `fix:` with no flag on it, so a flag here would
|
|
312
340
|
break a shipped instruction.
|
|
313
341
|
|
|
314
|
-
**The prefix half is not decoration
|
|
315
|
-
|
|
342
|
+
**The prefix half is not decoration, and it is no longer the only source guard.** The marker records
|
|
343
|
+
the base `As of 2026-08-19` — `ultimate:branch:<base>:<iso>`, read back as `BranchInfo.base` — so
|
|
344
|
+
`reapBranches` can skip another app's clones on its own. The prefix guard still stands and is what
|
|
345
|
+
`ls`/`drop` read, because an **older** marker records no base at all: it is skipped by the reaper
|
|
346
|
+
rather than dropped, which leaves `drop` needing an answer that does not depend on a field half the
|
|
347
|
+
branches lack. One Postgres server hosting two Ultimate apps answers `listBranches()` with both
|
|
316
348
|
apps' clones, and `branchNameOf` reduced `postly_branch_feat` and `analytics_branch_feat` to the
|
|
317
349
|
same branch name — so `x db branch drop feat`, run against `postly`, was authorised by
|
|
318
350
|
`analytics`'s row and then issued `drop database if exists "postly_branch_feat"` against a database
|
|
@@ -399,6 +431,29 @@ with nothing running, and against a database three migrations behind. An app who
|
|
|
399
431
|
load generates **nothing**: a short registry is indistinguishable from deleted entities, and the
|
|
400
432
|
diff would be a DROP nobody asked for.
|
|
401
433
|
|
|
434
|
+
**An empty diff re-records the `.hash` sidecar, and that is what makes `X_DB_DRIFT` followable.**
|
|
435
|
+
The hash `checkSourceDrift` compares covers every non-test file under `packages/db/src` — a seed, a
|
|
436
|
+
helper, a decorator — not only the ones that imply DDL, and narrowing that glob would trade a loud
|
|
437
|
+
error for a silent gap in the one check that catches "entities changed and no migration was
|
|
438
|
+
generated". So detection stays broad and the REMEDY carries the weight: `x db gen "describe the
|
|
439
|
+
change"` — the exact `fix:` the error hands out — records the current hash against the newest
|
|
440
|
+
migration when the diff is empty, instead of writing nothing and leaving the gate red forever with
|
|
441
|
+
hand-editing a generated file as the only way out. `GeneratedFiles.outcome` is the four things a run
|
|
442
|
+
can be — `generated`, `hash-recorded`, `unchanged`, `blocked` — and `runGen` projects it onto
|
|
443
|
+
`--json` on **every** branch: reporting `hash-recorded` as `generated` would name a migration nobody
|
|
444
|
+
can apply, and reporting it as `unchanged` would hide a file this command wrote. That second one
|
|
445
|
+
shipped: the no-migration branch hardcoded `data: { migration: null, files: [] }`, so the run that
|
|
446
|
+
wrote the sidecar reported writing nothing to the machine reading the output.
|
|
447
|
+
|
|
448
|
+
Nothing is masked, and the branch proves it rather than promising it: `loadApp` reported no findings
|
|
449
|
+
(the registry is whole, never short), `declaredSchema` returned a real snapshot (`X_MIGRATION_SNAPSHOT_MISSING`
|
|
450
|
+
otherwise), and the emptiness is `generateMigration`'s own verdict — the same call the written path
|
|
451
|
+
takes. A migration with no migration id to record against writes nothing, which is the
|
|
452
|
+
`x new --no-example` case: an entity against zero migrations is `create table` for all of it and
|
|
453
|
+
never an empty diff. `reconcileSchemaHash` also declines to write when an OLDER migration already
|
|
454
|
+
recorded the hash, because `checkSourceDrift` already answers clean there and restamping the newest
|
|
455
|
+
sidecar would claim it produced a schema it did not — one predicate, `isRecorded`, read by both.
|
|
456
|
+
|
|
402
457
|
One migration is one file, split by a lone `-- down` line. `<id>.down.sql` is a pre-1.2.0
|
|
403
458
|
hand-written layout and `readMigrations` skips it — read as a migration it sorts next to its own
|
|
404
459
|
`up` and drops every table the pair exists to reverse.
|
|
@@ -488,8 +543,10 @@ session-scoped and the grant dies when the connection returns to the pool, so ev
|
|
|
488
543
|
itself as leader and a rolling update double-fires every task.
|
|
489
544
|
|
|
490
545
|
The relay runs on `worker` and only `worker` — the role that exists wherever jobs run at all.
|
|
491
|
-
Duplicating it is safe
|
|
492
|
-
|
|
546
|
+
Duplicating it is safe — the claim is a **lease** taken in the statement that locks the row
|
|
547
|
+
(`@ultimat3/jobs`' `outbox-pg.ts`, fenced on `claimed_by`), so two relays never hold one batch —
|
|
548
|
+
but pointless. The idempotency key is not the reason and never was: its conflict target is a
|
|
549
|
+
partial index over live states, so it collapses a repeat only while the first job is still live.
|
|
493
550
|
|
|
494
551
|
`SQL_IDEMPOTENCY_TABLE` is applied beside `SQL_JOBS_TABLE`, and the store is installed by the boot
|
|
495
552
|
rather than by the app, even though `@ultimat3/action` documents
|
|
@@ -581,6 +638,16 @@ route: a tenant-scoped key takes `AUTHORIZED_OBJECT_CACHE` (`private, max-age=0`
|
|
|
581
638
|
`authorization`/`cookie`), and only a key no tenant owns keeps `immutable`. A genuinely public image
|
|
582
639
|
belongs under `apps/web/site/`, which is a static asset and never touches that disk.
|
|
583
640
|
|
|
641
|
+
**A variant is CACHED only at a width the framework can mint.** The cache key is built entirely
|
|
642
|
+
from caller-supplied query values, so `?w=1`, `?w=2`, … each wrote a new object to the app's only
|
|
643
|
+
disk, on a route every signed-in tenant may reach for their own keys. `@ultimat3/seo`'s
|
|
644
|
+
`MAX_IMAGE_WIDTH` (8192) bounds that and does not close it. `isMintableWidth` is the bound:
|
|
645
|
+
`DEFAULT_WIDTHS` **plus the source's own intrinsic width**, which is exactly the set `usableWidths`
|
|
646
|
+
puts in a `srcset` — the constant alone would refuse the widest entry of any image whose intrinsic
|
|
647
|
+
width is not one of the eight. Anything outside it is still served; only the `put` is refused, so
|
|
648
|
+
no caller gains a new 4xx. `?q=` is deliberately still unbounded here — the closed set for quality
|
|
649
|
+
is `@ultimat3/seo`'s to declare, not this file's.
|
|
650
|
+
|
|
584
651
|
`ICON_SOURCE` lives here, not in `cmd-doctor.ts`, because this is the module that reads it: the
|
|
585
652
|
diagnostic checks what `x dev` serves, so one constant cannot pass the check and serve nothing.
|
|
586
653
|
It is a **PNG** — core decodes PNG and JPEG only, and the SVG this used to name could never
|
|
@@ -609,6 +676,35 @@ locked by a process that no longer exists.
|
|
|
609
676
|
|
|
610
677
|
Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.
|
|
611
678
|
|
|
679
|
+
## A declared flag with no reader is a promise `x help` makes and nothing keeps
|
|
680
|
+
|
|
681
|
+
`x deploy --critical` said *"security deploy: forces clients to reload"* and forced nothing: the
|
|
682
|
+
value is written into the plan JSON (`cmd-deploy.ts`) and **no package reads that field**. The
|
|
683
|
+
parser accepts every declared flag, so this is neither a parse error nor a type error — the flag
|
|
684
|
+
worked perfectly and meant nothing, to the operator most likely to be shipping a security patch.
|
|
685
|
+
|
|
686
|
+
`flag-reads.ts` is the rule that can see the class of defect: **every flag a command declares is
|
|
687
|
+
read by something in the CLI's own source**, as `X_CLI_FLAG_UNREAD`. The four global flags are
|
|
688
|
+
excluded — `--json`, `--help`, `--cwd` and `--verbose` are the parser's, read once for every
|
|
689
|
+
command, and a per-command rule would report all thirty declarations of `--json`. The read test is
|
|
690
|
+
deliberately generous: a bare `'name'` literal anywhere outside a `name:`/`short:` spec field
|
|
691
|
+
counts, so a flag echoed only into `--json`, or read through a shared constant, is read. A gate
|
|
692
|
+
that guessed at intent would report findings about working commands.
|
|
693
|
+
|
|
694
|
+
It is enforced by `flag-reads.test.ts`, in the `unit` step — the same shape `cmd-planned.test.ts`
|
|
695
|
+
and `error-catalog.test.ts` use for a rule about the CLI's own declarations, and the reason its
|
|
696
|
+
`fix:` is a `bun test` line rather than an `x` command: the rule can only ever fire in this repo.
|
|
697
|
+
Promoting it to `x verify`'s `boundaries` host check is one line in `scripts/verify.ts`.
|
|
698
|
+
|
|
699
|
+
**It does not catch `--critical`, and that is the honest limit.** The flag IS read —
|
|
700
|
+
`flagBool(ctx.args, 'critical')` — and what had no consumer was the plan FIELD, one level below any
|
|
701
|
+
rule over names. Two stronger rules were measured and rejected: "the read must not be a property
|
|
702
|
+
initializer" reports six flags, five of which work (`x db --allow-destructive`, `x jobs --queue`);
|
|
703
|
+
"the summary must match the behaviour" is undecidable. So the flag's summary now says what it does,
|
|
704
|
+
and forcing a reload stays what it always was — `@ultimat3/pwa`'s `updateSignal({ reason:
|
|
705
|
+
'security' })`, which `As of 2026-08` has **no runtime caller anywhere**, in that package or
|
|
706
|
+
outside it. Wiring the flag means giving that function a caller first.
|
|
707
|
+
|
|
612
708
|
## Planned commands are commands
|
|
613
709
|
|
|
614
710
|
Every command in `wiki/CLI-Reference.md`'s planned table is in the registry, built from
|
package/README.md
CHANGED
|
@@ -82,6 +82,7 @@ is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUAR
|
|
|
82
82
|
| `dispatch.ts` | parse → run → render → exit; the only I/O boundary |
|
|
83
83
|
| `parse.ts` | flags, subcommands, `--json`, `--help`, suggestions |
|
|
84
84
|
| `flag-number.ts` | the one integer-flag reader — `--port`, `--workers`, `--shard` |
|
|
85
|
+
| `shell-quote.ts` | the one POSIX quoter for a value pasted into a `fix:` or a reproduce line |
|
|
85
86
|
| `output.ts` | one data shape, two renderers, the 3-line error format |
|
|
86
87
|
| `registry.ts` | the one command list |
|
|
87
88
|
| `generate-kinds.ts` | which generators exist, and how a command line names one |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.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",
|
|
@@ -35,28 +35,28 @@
|
|
|
35
35
|
"dev": "bun run src/bin.ts dev"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@ultimat3/action": "
|
|
39
|
-
"@ultimat3/admin": "
|
|
40
|
-
"@ultimat3/ai": "
|
|
41
|
-
"@ultimat3/cache": "
|
|
42
|
-
"@ultimat3/core": "
|
|
43
|
-
"@ultimat3/db": "
|
|
44
|
-
"@ultimat3/entity": "
|
|
45
|
-
"@ultimat3/http": "
|
|
46
|
-
"@ultimat3/i18n": "
|
|
47
|
-
"@ultimat3/jobs": "
|
|
48
|
-
"@ultimat3/mail": "
|
|
49
|
-
"@ultimat3/manifest": "
|
|
50
|
-
"@ultimat3/mcp": "
|
|
51
|
-
"@ultimat3/policy": "
|
|
52
|
-
"@ultimat3/pwa": "
|
|
53
|
-
"@ultimat3/query": "
|
|
54
|
-
"@ultimat3/realtime": "
|
|
55
|
-
"@ultimat3/render": "
|
|
56
|
-
"@ultimat3/schema": "
|
|
57
|
-
"@ultimat3/seo": "
|
|
58
|
-
"@ultimat3/storage": "
|
|
59
|
-
"@ultimat3/testing": "
|
|
60
|
-
"@ultimat3/time": "
|
|
38
|
+
"@ultimat3/action": "4.0.0",
|
|
39
|
+
"@ultimat3/admin": "4.0.0",
|
|
40
|
+
"@ultimat3/ai": "4.0.0",
|
|
41
|
+
"@ultimat3/cache": "4.0.0",
|
|
42
|
+
"@ultimat3/core": "4.0.0",
|
|
43
|
+
"@ultimat3/db": "4.0.0",
|
|
44
|
+
"@ultimat3/entity": "4.0.0",
|
|
45
|
+
"@ultimat3/http": "4.0.0",
|
|
46
|
+
"@ultimat3/i18n": "4.0.0",
|
|
47
|
+
"@ultimat3/jobs": "4.0.0",
|
|
48
|
+
"@ultimat3/mail": "4.0.0",
|
|
49
|
+
"@ultimat3/manifest": "4.0.0",
|
|
50
|
+
"@ultimat3/mcp": "4.0.0",
|
|
51
|
+
"@ultimat3/policy": "4.0.0",
|
|
52
|
+
"@ultimat3/pwa": "4.0.0",
|
|
53
|
+
"@ultimat3/query": "4.0.0",
|
|
54
|
+
"@ultimat3/realtime": "4.0.0",
|
|
55
|
+
"@ultimat3/render": "4.0.0",
|
|
56
|
+
"@ultimat3/schema": "4.0.0",
|
|
57
|
+
"@ultimat3/seo": "4.0.0",
|
|
58
|
+
"@ultimat3/storage": "4.0.0",
|
|
59
|
+
"@ultimat3/testing": "4.0.0",
|
|
60
|
+
"@ultimat3/time": "4.0.0"
|
|
61
61
|
}
|
|
62
62
|
}
|
package/src/budgets.ts
CHANGED
|
@@ -17,6 +17,14 @@ export const BUILD_STATS_FILE = join('.x', 'build-stats.json');
|
|
|
17
17
|
export interface RouteStats {
|
|
18
18
|
readonly path: string;
|
|
19
19
|
readonly jsBytes: number;
|
|
20
|
+
/**
|
|
21
|
+
* **Written by nothing, `As of 2026-08`.** `apps/web/prerender.ts` is the only producer of this
|
|
22
|
+
* file and it emits static HTML — there is no browser in the build to observe a paint. So the
|
|
23
|
+
* comparison in `checkBudgets` below is reachable only for an app that writes its own stats, and
|
|
24
|
+
* `x new` no longer scaffolds an `lcp` budget for exactly that reason: a budget the build cannot
|
|
25
|
+
* weigh passes silently the moment a stats row exists, which is the false green this file's
|
|
26
|
+
* header is about. `RouteBudget.lcp` still accepts one — that key is `@ultimat3/render`'s.
|
|
27
|
+
*/
|
|
20
28
|
readonly lcpMs?: number;
|
|
21
29
|
/** Import chain that pulled the heaviest module into this route. */
|
|
22
30
|
readonly heaviestChain?: readonly string[];
|
|
@@ -123,6 +131,23 @@ export async function readBuildStats(root: string): Promise<BuildStats | undefin
|
|
|
123
131
|
|
|
124
132
|
const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
|
|
125
133
|
const SRC_ATTR = /\ssrc="(?<src>[^"]*)"/;
|
|
134
|
+
const TYPE_ATTR = /\stype="(?<type>[^"]*)"/;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `application/ld+json`, `application/json`, any `…+json`: the body is data, not code — the rule
|
|
138
|
+
* `@ultimat3/render`'s `head.ts` already states, restated because its `carriesJson` reads a
|
|
139
|
+
* `HeadTag` and is not exported, and this side has an attribute string off the emitted document.
|
|
140
|
+
* Without it a page shipping only `meta.ld` structured data and island props measured 8kb of JS
|
|
141
|
+
* and failed a 2kb budget with a `fix:` naming an import chain that does not exist.
|
|
142
|
+
*/
|
|
143
|
+
const carriesJson = (attrs: string): boolean => {
|
|
144
|
+
// Everything from the first `;` is a MIME PARAMETER and not the type: a real document writes
|
|
145
|
+
// `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
|
|
146
|
+
// test alone charged an SEO structured-data block as executable JavaScript all over again.
|
|
147
|
+
const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
|
|
148
|
+
return type.trim().toLowerCase().endsWith('json');
|
|
149
|
+
};
|
|
150
|
+
|
|
126
151
|
/**
|
|
127
152
|
* An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
|
|
128
153
|
* as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
|
|
@@ -144,8 +169,9 @@ export interface MeasuredJs {
|
|
|
144
169
|
}
|
|
145
170
|
|
|
146
171
|
/**
|
|
147
|
-
* What a rendered document actually makes the browser execute: the bytes of every inline script
|
|
148
|
-
* the size of every file a `src` points at, and the size of every island
|
|
172
|
+
* What a rendered document actually makes the browser execute: the bytes of every inline script
|
|
173
|
+
* the parser will run, the size of every file a `src` points at, and the size of every island
|
|
174
|
+
* chunk it boots. A JSON-typed script is skipped — it is data the parser never runs. Measured
|
|
149
175
|
* from the emitted HTML rather than from the declared graph, because the graph is what a route
|
|
150
176
|
* *says* it ships and this gate exists to catch the case where those two disagree.
|
|
151
177
|
*/
|
|
@@ -162,7 +188,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
162
188
|
};
|
|
163
189
|
|
|
164
190
|
for (const match of html.matchAll(SCRIPT_TAG)) {
|
|
165
|
-
const
|
|
191
|
+
const attrs = match.groups?.['attrs'] ?? '';
|
|
192
|
+
if (carriesJson(attrs)) continue;
|
|
193
|
+
const src = SRC_ATTR.exec(attrs)?.groups?.['src'];
|
|
166
194
|
if (src === undefined) {
|
|
167
195
|
jsBytes += Buffer.byteLength(match.groups?.['body'] ?? '', 'utf8');
|
|
168
196
|
continue;
|
|
@@ -181,11 +209,6 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
181
209
|
return { jsBytes, entries };
|
|
182
210
|
}
|
|
183
211
|
|
|
184
|
-
/** The total alone, for a caller with nothing to say about which module was the heavy one. */
|
|
185
|
-
export async function measureJsBytes(html: string, out: string): Promise<number> {
|
|
186
|
-
return (await measureDocumentJs(html, out)).jsBytes;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
212
|
/**
|
|
190
213
|
* The file `checkBudgets` reads. Written by the build and by nothing else — a stats file produced
|
|
191
214
|
* anywhere but from real output is the false green this gate exists to prevent.
|
package/src/cmd-db-branch.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
branchDatabaseName,
|
|
12
12
|
createExternalBranch,
|
|
13
13
|
createPgliteBranch,
|
|
14
|
+
databaseNameOf,
|
|
14
15
|
dropExternalBranch,
|
|
15
16
|
dropPgliteBranch,
|
|
16
17
|
isBranchName,
|
|
@@ -27,6 +28,7 @@ import { MissingPositionalError, UnknownCommandError } from './errors';
|
|
|
27
28
|
import { msg } from './messages';
|
|
28
29
|
import type { CommandResult, Finding } from './output';
|
|
29
30
|
import { flagString, nearest } from './parse';
|
|
31
|
+
import { portFromEnv } from './serve';
|
|
30
32
|
import { renderTable } from './table';
|
|
31
33
|
|
|
32
34
|
/**
|
|
@@ -145,7 +147,9 @@ async function runCreate(
|
|
|
145
147
|
services: DevServices,
|
|
146
148
|
name: string,
|
|
147
149
|
): Promise<CommandResult> {
|
|
148
|
-
|
|
150
|
+
// `portFromEnv`, never a bare `Number.parseInt`: the latter reads `PORT=abc` as `NaN` and put
|
|
151
|
+
// `http://feat.localhost:NaN` in `data.preview` — a machine-readable field naming no port.
|
|
152
|
+
const port = portFromEnv(ctx.env);
|
|
149
153
|
let branch: BranchRow;
|
|
150
154
|
try {
|
|
151
155
|
branch =
|
|
@@ -205,7 +209,7 @@ function notABranch(services: DevServices, name: string): CommandResult {
|
|
|
205
209
|
const target =
|
|
206
210
|
services.db.mode === 'embedded'
|
|
207
211
|
? pgliteBranchLocation(services.db.url, name)
|
|
208
|
-
: branchDatabaseName(services.db.url
|
|
212
|
+
: branchDatabaseName(databaseNameOf(services.db.url), name);
|
|
209
213
|
return failure(msg('cli.db.branch.failed'), {
|
|
210
214
|
code: 'X_DB_BRANCH_FAILED',
|
|
211
215
|
cause: `"${name}" is not a branch of this database, so nothing was dropped (it would be ${target})`,
|
package/src/cmd-db.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// `x db gen|migrate|reset|studio|branch|backfill` — everything that touches the database. One
|
|
1
|
+
// `x db gen|migrate|reset|seed|studio|branch|backfill` — everything that touches the database. One
|
|
2
2
|
// subcommand per line and no fall-through: a word this file does not know is refused, never
|
|
3
3
|
// re-read as an argument to the last branch. `branch` itself is `cmd-db-branch.ts`.
|
|
4
4
|
//
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
import { rm } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { resolveEnvironment } from '@ultimat3/core';
|
|
15
|
-
import { type DriftReport, driftError } from '@ultimat3/db';
|
|
15
|
+
import { type DriftReport, driftError, withTransaction } from '@ultimat3/db';
|
|
16
|
+
import { postgresDriver } from '@ultimat3/entity';
|
|
16
17
|
import { BackfillPendingError } from '@ultimat3/jobs';
|
|
17
18
|
import { loadApp } from './app-load';
|
|
18
19
|
import { requireAppRoot } from './app-root';
|
|
@@ -34,6 +35,16 @@ import {
|
|
|
34
35
|
import { BRANCH_SUBCOMMANDS } from './db-branch';
|
|
35
36
|
import { stepFinding } from './db-finding';
|
|
36
37
|
import { generateAppMigration } from './db-generate';
|
|
38
|
+
import type { SeedPassRow } from './db-seed';
|
|
39
|
+
import {
|
|
40
|
+
discoverSeeds,
|
|
41
|
+
parseSeedTierFlag,
|
|
42
|
+
renderSeedTable,
|
|
43
|
+
runSeeds,
|
|
44
|
+
seedPassToJson,
|
|
45
|
+
seedTotals,
|
|
46
|
+
selectSeeds,
|
|
47
|
+
} from './db-seed';
|
|
37
48
|
import { resolveServices } from './dev-services';
|
|
38
49
|
import {
|
|
39
50
|
BadFlagError,
|
|
@@ -49,14 +60,22 @@ import { findingFrom } from './output';
|
|
|
49
60
|
import { flagBool, flagString } from './parse';
|
|
50
61
|
import { runMigrations } from './serve';
|
|
51
62
|
|
|
52
|
-
export const DB_SUBCOMMANDS = [
|
|
63
|
+
export const DB_SUBCOMMANDS = [
|
|
64
|
+
'gen',
|
|
65
|
+
'migrate',
|
|
66
|
+
'reset',
|
|
67
|
+
'seed',
|
|
68
|
+
'studio',
|
|
69
|
+
'branch',
|
|
70
|
+
'backfill',
|
|
71
|
+
] as const;
|
|
53
72
|
|
|
54
73
|
export const dbCommand: CliCommand = {
|
|
55
74
|
spec: {
|
|
56
75
|
name: 'db',
|
|
57
|
-
summary: 'gen, migrate, reset, studio, branch, backfill',
|
|
76
|
+
summary: 'gen, migrate, reset, seed, studio, branch, backfill',
|
|
58
77
|
usage:
|
|
59
|
-
'x db gen "add publish_at" | migrate | reset | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
|
|
78
|
+
'x db gen "add publish_at" | migrate | reset | seed [<name>] [--tier reference|dev] [--dry-run] | studio | branch ls | branch create <name> | branch drop <name> | backfill [<name>|--all] [--write] [--force] | backfill --pending | backfill --list [--name n] [--status s] [--limit n]',
|
|
60
79
|
requiresApp: true,
|
|
61
80
|
subcommands: DB_SUBCOMMANDS,
|
|
62
81
|
// Declared from the constant `runBranchCommand` validates against, never a second literal: it
|
|
@@ -64,7 +83,21 @@ export const dbCommand: CliCommand = {
|
|
|
64
83
|
// hand out, which read `ls` as a branch name and cloned a database until 1.2.x.
|
|
65
84
|
subcommandPositionals: { branch: BRANCH_SUBCOMMANDS },
|
|
66
85
|
flags: [
|
|
67
|
-
{
|
|
86
|
+
{
|
|
87
|
+
name: 'name',
|
|
88
|
+
type: 'string',
|
|
89
|
+
summary: 'migration, branch or seed name, or backfill to filter',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'tier',
|
|
93
|
+
type: 'string',
|
|
94
|
+
summary: 'seed: which tier to run — reference or dev; also ULTIMATE_SEED_TIER',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'dry-run',
|
|
98
|
+
type: 'boolean',
|
|
99
|
+
summary: 'seed: report what each seed would write, and write nothing',
|
|
100
|
+
},
|
|
68
101
|
{ name: 'list', type: 'boolean', summary: 'backfill: print the x_backfills ledger' },
|
|
69
102
|
{
|
|
70
103
|
name: 'pending',
|
|
@@ -106,6 +139,7 @@ export const dbCommand: CliCommand = {
|
|
|
106
139
|
if (sub === 'gen') return runGen(ctx, root, argument ?? 'change');
|
|
107
140
|
if (sub === 'migrate') return runMigrate(ctx, root, msg('cli.db.migrate.applied'));
|
|
108
141
|
if (sub === 'reset') return runReset(ctx, root);
|
|
142
|
+
if (sub === 'seed') return runSeed(ctx, root);
|
|
109
143
|
if (sub === 'studio') throw plannedSubcommand('db', 'studio');
|
|
110
144
|
if (sub === 'backfill') return runBackfill(ctx, root);
|
|
111
145
|
if (sub === 'branch') return runBranchCommand(ctx, root);
|
|
@@ -125,8 +159,14 @@ export const dbCommand: CliCommand = {
|
|
|
125
159
|
|
|
126
160
|
/**
|
|
127
161
|
* Source in, files out — no database is opened, so this answers the same in CI and on a laptop
|
|
128
|
-
* with nothing running. A diff that finds nothing writes
|
|
129
|
-
* an answer, and an empty migration would take a ledger row and a checksum forever.
|
|
162
|
+
* with nothing running. A diff that finds nothing writes no MIGRATION and still exits 0: "no
|
|
163
|
+
* change" is an answer, and an empty migration would take a ledger row and a checksum forever. It
|
|
164
|
+
* may still write the `.hash` sidecar `x verify`'s `drift` step reads, which is what makes
|
|
165
|
+
* `X_DB_DRIFT`'s `fix:` — this command — a real instruction rather than a no-op.
|
|
166
|
+
*
|
|
167
|
+
* So there are THREE answers, not two, and `--json` carries `outcome` on every one: collapsing
|
|
168
|
+
* `hash-recorded` into either neighbour tells the machine reading this output that a migration
|
|
169
|
+
* exists when none does, or that nothing was written when the sidecar was.
|
|
130
170
|
*/
|
|
131
171
|
async function runGen(ctx: CommandContext, root: string, name: string): Promise<CommandResult> {
|
|
132
172
|
let generated: Awaited<ReturnType<typeof generateAppMigration>>;
|
|
@@ -148,9 +188,21 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
|
|
|
148
188
|
return {
|
|
149
189
|
ok: generated.findings.length === 0,
|
|
150
190
|
command: 'db',
|
|
151
|
-
|
|
191
|
+
// The sidecar path, never a bare id: `hash-recorded` writes exactly one, and it is the file
|
|
192
|
+
// the `drift` step reads back.
|
|
193
|
+
summary:
|
|
194
|
+
generated.outcome === 'hash-recorded'
|
|
195
|
+
? msg('cli.db.gen.recorded', { file: generated.files[0] ?? '' })
|
|
196
|
+
: msg('cli.db.gen.unchanged'),
|
|
152
197
|
findings: generated.findings,
|
|
153
|
-
|
|
198
|
+
// `files` is what this command WROTE, so the empty array here was a false claim.
|
|
199
|
+
lines: generated.files.map((file) => ` ${file}`),
|
|
200
|
+
data: {
|
|
201
|
+
outcome: generated.outcome,
|
|
202
|
+
migration: null,
|
|
203
|
+
files: [...generated.files],
|
|
204
|
+
schemaHash: generated.schemaHash ?? null,
|
|
205
|
+
},
|
|
154
206
|
};
|
|
155
207
|
}
|
|
156
208
|
return {
|
|
@@ -159,6 +211,7 @@ async function runGen(ctx: CommandContext, root: string, name: string): Promise<
|
|
|
159
211
|
summary: msg('cli.db.gen.written', { id: migration.id }),
|
|
160
212
|
lines: generated.files.map((file) => ` ${file}`),
|
|
161
213
|
data: {
|
|
214
|
+
outcome: generated.outcome,
|
|
162
215
|
migration: migration.id,
|
|
163
216
|
name: migration.name,
|
|
164
217
|
files: [...generated.files],
|
|
@@ -233,6 +286,81 @@ async function runReset(ctx: CommandContext, root: string): Promise<CommandResul
|
|
|
233
286
|
return runMigrate(ctx, root, msg('cli.db.reset.done'));
|
|
234
287
|
}
|
|
235
288
|
|
|
289
|
+
/**
|
|
290
|
+
* `x db seed [<name>]` — the fixture graph, applied and replayable.
|
|
291
|
+
*
|
|
292
|
+
* The environment is resolved BEFORE anything is imported or connected: a run this environment does
|
|
293
|
+
* not take must refuse without having opened a connection to the database it was refusing to write
|
|
294
|
+
* to. `selectSeeds` asks the same question a second time, on the seeds themselves, because seeding
|
|
295
|
+
* is the one irreversible thing this command does (`db-seed.ts`).
|
|
296
|
+
*
|
|
297
|
+
* `withJobDriver` is the boot, though nothing here claims a job: it is the CLI's one answer to
|
|
298
|
+
* "which database is this command talking to", and it also puts a real queue behind any
|
|
299
|
+
* `handle.enqueue()` a seeded write triggers. A second boot path would be a second answer.
|
|
300
|
+
*/
|
|
301
|
+
async function runSeed(ctx: CommandContext, root: string): Promise<CommandResult> {
|
|
302
|
+
const environment = resolveEnvironment({ env: ctx.env });
|
|
303
|
+
const requested = parseSeedTierFlag(
|
|
304
|
+
flagString(ctx.args, 'tier') ?? ctx.env['ULTIMATE_SEED_TIER'],
|
|
305
|
+
);
|
|
306
|
+
const name = ctx.args.positionals[0] ?? flagString(ctx.args, 'name');
|
|
307
|
+
const dryRun = flagBool(ctx.args, 'dry-run');
|
|
308
|
+
const discovery = await discoverSeeds(root);
|
|
309
|
+
const chosen = selectSeeds({
|
|
310
|
+
discovered: discovery.seeds,
|
|
311
|
+
...(name === undefined ? {} : { name }),
|
|
312
|
+
environment,
|
|
313
|
+
requested,
|
|
314
|
+
});
|
|
315
|
+
if (chosen.length === 0) {
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
command: 'db',
|
|
319
|
+
summary: msg('cli.db.seed.none'),
|
|
320
|
+
findings: discovery.findings,
|
|
321
|
+
data: seedPassToJson([]),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return withJobDriver(root, ctx, async () => {
|
|
325
|
+
const rows = await runSeeds({
|
|
326
|
+
seeds: chosen,
|
|
327
|
+
driver: postgresDriver(),
|
|
328
|
+
dryRun,
|
|
329
|
+
env: ctx.env,
|
|
330
|
+
// One transaction per seed, so a seed that throws takes only its own rows with it.
|
|
331
|
+
transaction: (work) => withTransaction(() => work()),
|
|
332
|
+
});
|
|
333
|
+
return seedPassResult(rows, dryRun, discovery.findings);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function seedPassResult(
|
|
338
|
+
rows: readonly SeedPassRow[],
|
|
339
|
+
dryRun: boolean,
|
|
340
|
+
findings: readonly Finding[],
|
|
341
|
+
): CommandResult {
|
|
342
|
+
const totals = seedTotals(rows);
|
|
343
|
+
const failures = rows.flatMap((row) => (row.finding === null ? [] : [row.finding]));
|
|
344
|
+
return {
|
|
345
|
+
ok: failures.length === 0 && findings.length === 0,
|
|
346
|
+
command: 'db',
|
|
347
|
+
summary:
|
|
348
|
+
totals.failed > 0
|
|
349
|
+
? msg('cli.db.seed.failed', { failed: totals.failed, count: rows.length })
|
|
350
|
+
: dryRun
|
|
351
|
+
? msg('cli.db.seed.dryRun', { count: rows.length })
|
|
352
|
+
: msg('cli.db.seed.done', {
|
|
353
|
+
count: rows.length,
|
|
354
|
+
inserted: totals.inserted,
|
|
355
|
+
updated: totals.updated,
|
|
356
|
+
skipped: totals.skipped,
|
|
357
|
+
}),
|
|
358
|
+
findings: [...failures, ...findings],
|
|
359
|
+
lines: renderSeedTable(rows).map((line) => ` ${line}`),
|
|
360
|
+
data: seedPassToJson(rows),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
236
364
|
/**
|
|
237
365
|
* Four shapes, one subcommand: `--list` reads the `x_backfills` ledger, `--pending` diffs it
|
|
238
366
|
* against what the app DECLARED, and `<name>` / `--all` gate a pass and put it on the queue. A
|