@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.
Files changed (75) hide show
  1. package/CLAUDE.md +109 -13
  2. package/README.md +1 -0
  3. package/package.json +24 -24
  4. package/src/budgets.ts +31 -8
  5. package/src/cmd-db-branch.ts +6 -2
  6. package/src/cmd-db.ts +138 -10
  7. package/src/cmd-deploy.ts +42 -14
  8. package/src/cmd-dev.ts +9 -2
  9. package/src/cmd-docs.ts +7 -3
  10. package/src/cmd-doctor.ts +16 -7
  11. package/src/cmd-fix.ts +15 -3
  12. package/src/cmd-generate.ts +29 -4
  13. package/src/cmd-help.ts +25 -4
  14. package/src/cmd-i18n.ts +8 -5
  15. package/src/cmd-jobs.ts +6 -5
  16. package/src/cmd-mcp.ts +16 -12
  17. package/src/cmd-new.ts +10 -14
  18. package/src/cmd-planned.ts +13 -0
  19. package/src/cmd-policy.ts +8 -6
  20. package/src/cmd-registries.ts +7 -6
  21. package/src/cmd-routes.ts +27 -4
  22. package/src/cmd-secrets.ts +6 -6
  23. package/src/cmd-test.ts +14 -3
  24. package/src/cmd-verify.ts +80 -10
  25. package/src/command.ts +10 -2
  26. package/src/db-branch.ts +18 -0
  27. package/src/db-generate.ts +38 -6
  28. package/src/db-seed.ts +294 -0
  29. package/src/dev-assets.ts +22 -3
  30. package/src/dev-cache.ts +9 -9
  31. package/src/dev-render.ts +6 -1
  32. package/src/dev-roles.ts +5 -3
  33. package/src/dev-runtime.ts +2 -2
  34. package/src/dev-storage.ts +6 -4
  35. package/src/dev-traces.ts +26 -4
  36. package/src/dispatch.ts +33 -4
  37. package/src/drift.ts +41 -1
  38. package/src/error-catalog.ts +1 -0
  39. package/src/error-codes.ts +11 -0
  40. package/src/error-contract.ts +31 -4
  41. package/src/exec.ts +42 -8
  42. package/src/fix-command.ts +9 -2
  43. package/src/fix-imports.ts +118 -0
  44. package/src/fix-scan.ts +251 -0
  45. package/src/flag-number.ts +11 -0
  46. package/src/flag-reads.ts +114 -0
  47. package/src/i18n-audit.ts +2 -1
  48. package/src/index.ts +19 -5
  49. package/src/jobs-drain.ts +6 -1
  50. package/src/mcp-errors.ts +13 -0
  51. package/src/mcp-host.ts +4 -2
  52. package/src/messages.ts +15 -0
  53. package/src/metrics-endpoint.ts +60 -13
  54. package/src/otlp-export.ts +14 -0
  55. package/src/parse.ts +6 -1
  56. package/src/seo-meta.ts +105 -0
  57. package/src/serve.ts +15 -3
  58. package/src/shell-quote.ts +15 -0
  59. package/src/templates/action.ts +39 -7
  60. package/src/templates/backfill.ts +3 -1
  61. package/src/templates/index.ts +10 -1
  62. package/src/templates/job.ts +6 -2
  63. package/src/templates/query.ts +6 -1
  64. package/src/templates/route.ts +18 -9
  65. package/src/templates/scaffold-api.ts +100 -0
  66. package/src/templates/scaffold-app.ts +8 -48
  67. package/src/templates/scaffold-container.ts +44 -9
  68. package/src/templates/scaffold-helm-templates.ts +327 -0
  69. package/src/templates/scaffold-helm.ts +144 -0
  70. package/src/templates/scaffold-repo.ts +25 -8
  71. package/src/test-shards.ts +1 -10
  72. package/src/test-workers.ts +4 -1
  73. package/src/ts-scan.ts +25 -176
  74. package/src/tsconfig-references.ts +27 -2
  75. 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 strings a `fix:` can evaluate to, the `X_*` codes a file declares, and the ones it says it borrows |
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 `ts-scan.test.ts`: the helper must
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
- What it still cannot see is **cross-file**: `dbNotImplemented` is exported from `@ultimat3/db` and
189
- called from `pglite-branch.ts`, and resolving that means an import graph and a per-symbol parameter
190
- table. Same for an error class with a positional `constructor(cause, fix)` — `@ultimat3/render`'s
191
- `errors.ts` has fourteen, and 15 of its codes have never had a fix line read. Measured: **zero**
192
- same-file call sites for that form, so a constructor rule would be dead code today. Named, not
193
- guessed at.
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: the marker records WHEN a clone was made and never what it was
315
- cloned FROM.** One Postgres server hosting two Ultimate apps answers `listBranches()` with both
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 (publish-then-mark is at-least-once and the idempotency key collapses the
492
- repeat) but pointless.
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": "2.0.0",
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": "2.0.0",
39
- "@ultimat3/admin": "2.0.0",
40
- "@ultimat3/ai": "2.0.0",
41
- "@ultimat3/cache": "2.0.0",
42
- "@ultimat3/core": "2.0.0",
43
- "@ultimat3/db": "2.0.0",
44
- "@ultimat3/entity": "2.0.0",
45
- "@ultimat3/http": "2.0.0",
46
- "@ultimat3/i18n": "2.0.0",
47
- "@ultimat3/jobs": "2.0.0",
48
- "@ultimat3/mail": "2.0.0",
49
- "@ultimat3/manifest": "2.0.0",
50
- "@ultimat3/mcp": "2.0.0",
51
- "@ultimat3/policy": "2.0.0",
52
- "@ultimat3/pwa": "2.0.0",
53
- "@ultimat3/query": "2.0.0",
54
- "@ultimat3/realtime": "2.0.0",
55
- "@ultimat3/render": "2.0.0",
56
- "@ultimat3/schema": "2.0.0",
57
- "@ultimat3/seo": "2.0.0",
58
- "@ultimat3/storage": "2.0.0",
59
- "@ultimat3/testing": "2.0.0",
60
- "@ultimat3/time": "2.0.0"
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 chunk it boots. Measured
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 src = SRC_ATTR.exec(match.groups?.['attrs'] ?? '')?.groups?.['src'];
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.
@@ -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
- const port = Number.parseInt(ctx.env['PORT'] ?? '3000', 10);
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.split('/').at(-1) ?? 'postgres', name);
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 = ['gen', 'migrate', 'reset', 'studio', 'branch', 'backfill'] as const;
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
- { name: 'name', type: 'string', summary: 'migration or branch name, or backfill to filter' },
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 nothing and still exits 0: "no change" is
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
- summary: msg('cli.db.gen.unchanged'),
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
- data: { migration: null, files: [] },
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