@ultimat3/cli 3.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 (52) hide show
  1. package/CLAUDE.md +69 -10
  2. package/package.json +24 -24
  3. package/src/budgets.ts +8 -5
  4. package/src/cmd-deploy.ts +42 -14
  5. package/src/cmd-docs.ts +7 -3
  6. package/src/cmd-fix.ts +15 -3
  7. package/src/cmd-generate.ts +29 -4
  8. package/src/cmd-help.ts +25 -4
  9. package/src/cmd-i18n.ts +8 -5
  10. package/src/cmd-jobs.ts +6 -5
  11. package/src/cmd-mcp.ts +16 -12
  12. package/src/cmd-new.ts +9 -13
  13. package/src/cmd-planned.ts +13 -0
  14. package/src/cmd-policy.ts +8 -6
  15. package/src/cmd-registries.ts +7 -6
  16. package/src/cmd-routes.ts +27 -4
  17. package/src/cmd-secrets.ts +6 -6
  18. package/src/cmd-verify.ts +55 -3
  19. package/src/command.ts +10 -2
  20. package/src/dev-cache.ts +9 -9
  21. package/src/dev-render.ts +6 -1
  22. package/src/dev-runtime.ts +2 -2
  23. package/src/dispatch.ts +33 -4
  24. package/src/error-codes.ts +5 -0
  25. package/src/error-contract.ts +31 -4
  26. package/src/fix-command.ts +9 -2
  27. package/src/fix-imports.ts +118 -0
  28. package/src/fix-scan.ts +251 -0
  29. package/src/flag-reads.ts +114 -0
  30. package/src/i18n-audit.ts +2 -1
  31. package/src/index.ts +8 -1
  32. package/src/jobs-drain.ts +6 -1
  33. package/src/mcp-errors.ts +5 -0
  34. package/src/mcp-host.ts +4 -2
  35. package/src/messages.ts +3 -0
  36. package/src/otlp-export.ts +14 -0
  37. package/src/parse.ts +6 -1
  38. package/src/seo-meta.ts +105 -0
  39. package/src/templates/action.ts +39 -7
  40. package/src/templates/backfill.ts +3 -1
  41. package/src/templates/index.ts +10 -1
  42. package/src/templates/job.ts +6 -2
  43. package/src/templates/query.ts +6 -1
  44. package/src/templates/route.ts +18 -9
  45. package/src/templates/scaffold-api.ts +100 -0
  46. package/src/templates/scaffold-app.ts +8 -48
  47. package/src/templates/scaffold-container.ts +44 -9
  48. package/src/templates/scaffold-helm-templates.ts +327 -0
  49. package/src/templates/scaffold-helm.ts +144 -0
  50. package/src/templates/scaffold-repo.ts +25 -8
  51. package/src/ts-scan.ts +12 -174
  52. package/src/verify-step.ts +5 -0
package/CLAUDE.md CHANGED
@@ -10,6 +10,9 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
10
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 |
11
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 |
12
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`) |
13
16
  | I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
14
17
  | Staying up | a command still listening when `run` resolves returns `hold` (`hold.ts`), or `bin.ts` exits out from under it |
15
18
  | `--json` | every command, no exceptions — same data as the human render |
@@ -117,7 +120,9 @@ a shallow spread keeps one of them.
117
120
 
118
121
  | File | Job |
119
122
  |---|---|
120
- | `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 |
121
126
  | `error-contract.ts` | the rules, the two checks that turn them into findings, and `collectDeclaredCodes` |
122
127
  | `fix-command.ts` | resolving an `x <command>` a `fix:` cites against the registry |
123
128
  | `source-files.ts` | which files are shipped source — shared with `filesize`, never a second list |
@@ -146,6 +151,8 @@ declared from the constant the command validates against), because `x jobs show
146
151
  working invocations. `positionalChoices` cannot express it: `fix-command.ts` reads that field only
147
152
  where a command declares no subcommands at all.
148
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
+
149
156
  **And in that one slot, a `<placeholder>` is a finding too.** `x db branch <name>` is what two
150
157
  `@ultimat3/mcp` fix lines said; the citation reader does not read `<name>` as a word, so the slot
151
158
  was never examined and the line resolved clean while running it answers `X_CLI_UNKNOWN_COMMAND` —
@@ -177,7 +184,7 @@ and the step says so rather than guessing.
177
184
  helper, so the file held no `fix:` at all and `scanFixes` returned `[]` for all of it — the
178
185
  citation resolver was never given a string to judge, and two stale `x db branch <name>` lines
179
186
  shipped through the hole. `scanFixes` now also reads the argument in the `fix: string` position of
180
- 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
181
188
  BUILD an error (`code` key or `new …Error(` in its body), or `citedCommandProblem(fix, catalog)` —
182
189
  which takes a fix to *judge* it — would have its call sites read as declarations; the parameter
183
190
  list may hold no rest or destructured parameter, because neither has a reliable position; the call
@@ -186,12 +193,31 @@ because `prefix + 'x doctor'` reads as one literal there and publishing half a f
186
193
  publishing none. Measured over the whole tree: 16 files gained readable fixes, `readonly-sql.ts`
187
194
  went from 0 to 7, and **zero** new findings.
188
195
 
189
- What it still cannot see is **cross-file**: `dbNotImplemented` is exported from `@ultimat3/db` and
190
- called from `pglite-branch.ts`, and resolving that means an import graph and a per-symbol parameter
191
- table. Same for an error class with a positional `constructor(cause, fix)` — `@ultimat3/render`'s
192
- `errors.ts` has fourteen, and 15 of its codes have never had a fix line read. Measured: **zero**
193
- same-file call sites for that form, so a constructor rule would be dead code today. Named, not
194
- 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.
195
221
 
196
222
  `cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`): `x dev` **mounts** the
197
223
  dashboard, it never grows a second one. The CLI's only contribution is the facts no registry
@@ -313,8 +339,12 @@ The typo is impossible rather than the keystroke tedious — and `@ultimat3/db`
313
339
  `x db branch drop <name>` as `X_BRANCH_EXISTS`'s `fix:` with no flag on it, so a flag here would
314
340
  break a shipped instruction.
315
341
 
316
- **The prefix half is not decoration: the marker records WHEN a clone was made and never what it was
317
- 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
318
348
  apps' clones, and `branchNameOf` reduced `postly_branch_feat` and `analytics_branch_feat` to the
319
349
  same branch name — so `x db branch drop feat`, run against `postly`, was authorised by
320
350
  `analytics`'s row and then issued `drop database if exists "postly_branch_feat"` against a database
@@ -646,6 +676,35 @@ locked by a process that no longer exists.
646
676
 
647
677
  Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.
648
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
+
649
708
  ## Planned commands are commands
650
709
 
651
710
  Every command in `wiki/CLI-Reference.md`'s planned table is in the registry, built from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "3.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": "3.0.0",
39
- "@ultimat3/admin": "3.0.0",
40
- "@ultimat3/ai": "3.0.0",
41
- "@ultimat3/cache": "3.0.0",
42
- "@ultimat3/core": "3.0.0",
43
- "@ultimat3/db": "3.0.0",
44
- "@ultimat3/entity": "3.0.0",
45
- "@ultimat3/http": "3.0.0",
46
- "@ultimat3/i18n": "3.0.0",
47
- "@ultimat3/jobs": "3.0.0",
48
- "@ultimat3/mail": "3.0.0",
49
- "@ultimat3/manifest": "3.0.0",
50
- "@ultimat3/mcp": "3.0.0",
51
- "@ultimat3/policy": "3.0.0",
52
- "@ultimat3/pwa": "3.0.0",
53
- "@ultimat3/query": "3.0.0",
54
- "@ultimat3/realtime": "3.0.0",
55
- "@ultimat3/render": "3.0.0",
56
- "@ultimat3/schema": "3.0.0",
57
- "@ultimat3/seo": "3.0.0",
58
- "@ultimat3/storage": "3.0.0",
59
- "@ultimat3/testing": "3.0.0",
60
- "@ultimat3/time": "3.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[];
@@ -201,11 +209,6 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
201
209
  return { jsBytes, entries };
202
210
  }
203
211
 
204
- /** The total alone, for a caller with nothing to say about which module was the heavy one. */
205
- export async function measureJsBytes(html: string, out: string): Promise<number> {
206
- return (await measureDocumentJs(html, out)).jsBytes;
207
- }
208
-
209
212
  /**
210
213
  * The file `checkBudgets` reads. Written by the build and by nothing else — a stats file produced
211
214
  * anywhere but from real output is the false green this gate exists to prevent.
package/src/cmd-deploy.ts CHANGED
@@ -2,11 +2,10 @@
2
2
  // not know the name of a cloud, a KV store or an edge runtime (axiom 7). What it emits is a plan
3
3
  // anything that runs containers can execute.
4
4
 
5
- import { existsSync } from 'node:fs';
6
5
  import { join } from 'node:path';
7
6
  import { requireAppRoot } from './app-root';
8
7
  import type { CliCommand, CommandContext } from './command';
9
- import { BadFlagError, CliNotImplementedError } from './errors';
8
+ import { BadFlagError, UnknownCommandError } from './errors';
10
9
  import { msg } from './messages';
11
10
  import type { CommandResult, JsonValue } from './output';
12
11
  import { flagBool, flagString } from './parse';
@@ -28,11 +27,35 @@ import { flagBool, flagString } from './parse';
28
27
  * the serving roles were asked to start and not after they are ready. The barrier that makes
29
28
  * "after" true is declarative and belongs to the compose file, not to this plan: the `backfill`
30
29
  * service needs `depends_on: { web: { condition: service_healthy } }`, which `docker compose run`
31
- * honours. Both compose definitions — `docker/docker-compose.prod.yml` and the one
32
- * `templates/scaffold-container.ts` scaffolds — still owe that service and that condition.
30
+ * honours. Both compose definitions carry it — `docker/docker-compose.prod.yml`'s `backfill` and
31
+ * the one `templates/scaffold-container.ts` scaffolds, which also gates on `migrate` completing.
32
+ * This paragraph said they "still owe" both for as long as they have had them.
33
33
  */
34
34
  export const DEPLOY_ROLES = ['migrate', 'web', 'sync', 'worker', 'scheduler', 'backfill'] as const;
35
35
 
36
+ /** The two ways to run the plan. Closed, and read three ways: the default, the check, the refusal. */
37
+ export const DEPLOY_METHODS = ['compose', 'helm'] as const;
38
+
39
+ export type DeployMethod = (typeof DEPLOY_METHODS)[number];
40
+
41
+ /**
42
+ * Refused, never defaulted. `=== 'helm' ? 'helm' : 'compose'` made every other spelling a Compose
43
+ * deploy that reported `ok: true` and `method: "compose"` — so `x deploy --method helmm` (or
44
+ * `Helm`, or `kubectl`) ran the six-step Compose plan against a cluster whose operator had asked
45
+ * for a Helm upgrade, and the report agreed with the plan rather than with the request.
46
+ * `cmd-build.ts`'s `readTarget` is the same shape for the same reason.
47
+ */
48
+ export function readMethod(raw: string | undefined): DeployMethod {
49
+ const methods: readonly string[] = DEPLOY_METHODS;
50
+ if (raw === undefined) return 'compose';
51
+ if (methods.includes(raw)) return raw as DeployMethod;
52
+ throw new UnknownCommandError({
53
+ path: `deploy --method ${raw}`,
54
+ known: DEPLOY_METHODS,
55
+ suggestion: 'deploy --method compose',
56
+ });
57
+ }
58
+
36
59
  /** The roles that run to completion and exit, as against the ones that stay up serving. */
37
60
  const ONE_SHOT_ROLES: readonly string[] = ['migrate', 'backfill'];
38
61
 
@@ -62,7 +85,7 @@ export function helmImageOverrides(image: string): readonly string[] {
62
85
  : ['--set', `image.repository=${repository}`, '--set', `image.tag=${tag}`];
63
86
  }
64
87
 
65
- export function planDeploy(image: string, method: 'compose' | 'helm', root: string): DeployPlan {
88
+ export function planDeploy(image: string, method: DeployMethod, root: string): DeployPlan {
66
89
  if (method === 'helm') {
67
90
  // `repo@sha256:…` is a reference this chart cannot express: it renders `repository:tag` and
68
91
  // has no digest branch, so passing one through would deploy `repo@sha256:…:<appVersion>` —
@@ -119,24 +142,29 @@ export const deployCommand: CliCommand = {
119
142
  { name: 'image', type: 'string', summary: 'image reference to deploy' },
120
143
  { name: 'method', type: 'string', summary: 'compose | helm', default: 'compose' },
121
144
  { name: 'dry-run', type: 'boolean', summary: 'print the plan, run nothing' },
122
- { name: 'critical', type: 'boolean', summary: 'security deploy: forces clients to reload' },
145
+ // `--critical` was here and is gone. It parsed, it was echoed into the plan JSON as
146
+ // `critical: <bool>`, and no file in `packages/` read that field — so the flag changed
147
+ // nothing about what `x deploy` did, on either method. `flag-reads.ts`'s
148
+ // `X_CLI_FLAG_UNREAD` passed it, because that gate proves a flag is READ and this one was:
149
+ // into a field with no reader. Forcing a reload is `@ultimat3/pwa`'s
150
+ // `updateSignal({ reason: 'security' })`, which has no runtime caller either; a flag that
151
+ // triggers it is a change in that package, and this was not it.
123
152
  ],
124
153
  },
125
154
  async run(ctx: CommandContext): Promise<CommandResult> {
126
155
  const root = requireAppRoot('deploy', ctx.cwd).dir;
127
156
  const image = flagString(ctx.args, 'image') ?? 'ultimate-app:dev';
128
- const method = flagString(ctx.args, 'method') === 'helm' ? 'helm' : 'compose';
129
- if (method === 'helm' && !existsSync(join(root, 'docker', 'helm'))) {
130
- throw new CliNotImplementedError({
131
- feature: 'helm deploy without docker/helm in the app',
132
- fix: 'copy docker/helm from the framework repo, or use: x deploy --method compose',
133
- });
134
- }
157
+ // No "is there a chart?" branch. It threw X_NOT_IMPLEMENTED — "this build does not implement
158
+ // helm" — over a build that implements it completely (`planDeploy` above); what was missing was
159
+ // a FILE, and its fix said to copy it from the framework repository, which `packages/cli`'s
160
+ // `files:` ships in no tarball. `x new` writes `docker/helm` now, the way it has always written
161
+ // `docker/docker-compose.prod.yml`. An app that deleted the chart gets helm's own error through
162
+ // X_DEPLOY_FAILED, whose fix is the exact command to rerun.
163
+ const method = readMethod(flagString(ctx.args, 'method'));
135
164
  const plan = planDeploy(image, method, root);
136
165
  const planJson: JsonValue = {
137
166
  image: plan.image,
138
167
  method,
139
- critical: flagBool(ctx.args, 'critical'),
140
168
  steps: plan.steps.map((step) => ({ role: step.role, command: step.command.join(' ') })),
141
169
  };
142
170
  if (flagBool(ctx.args, 'dry-run')) {
package/src/cmd-docs.ts CHANGED
@@ -9,8 +9,10 @@ import { nearestTopics, scanInstalledDocs, searchDocs } from '@ultimat3/manifest
9
9
  import type { CliCommand, CommandContext } from './command';
10
10
  import { MissingPositionalError } from './errors';
11
11
  import { frameworkScopeDir } from './framework-scope';
12
+ import { parseLimitFlag } from './jobs-report';
12
13
  import { msg } from './messages';
13
14
  import type { CommandResult, Finding, JsonValue } from './output';
15
+ import { flagString } from './parse';
14
16
 
15
17
  /** Matches printed by default. Enough to choose between, few enough to read all of. */
16
18
  const DEFAULT_LIMIT = 5;
@@ -150,9 +152,11 @@ export const docsCommand: CliCommand = {
150
152
  }
151
153
 
152
154
  const entries = await scanInstalledDocs(scope);
153
- const rawLimit = ctx.args.flags.get('limit');
154
- const parsed = typeof rawLimit === 'string' ? Number.parseInt(rawLimit, 10) : Number.NaN;
155
- const limit = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIMIT;
155
+ // `x jobs ls --limit`'s reader, and its `command` parameter exists for exactly this second
156
+ // caller. A local `Number.parseInt` accepted `--limit 1e9` as 1 and answered with one match,
157
+ // and fell silently through to the default for `abc`, `0` and `-3` — a bound other than the one
158
+ // typed, from the same binary that refuses all four one command over.
159
+ const limit = parseLimitFlag(flagString(ctx.args, 'limit'), 'docs') ?? DEFAULT_LIMIT;
156
160
  const hits = searchDocs(entries, query, limit);
157
161
  if (hits.length === 0) return missResult(query, entries);
158
162
 
package/src/cmd-fix.ts CHANGED
@@ -8,7 +8,7 @@ import { requireAppRoot } from './app-root';
8
8
  import type { BoundaryCut } from './boundary-cuts';
9
9
  import { planBoundaryCuts } from './boundary-cuts';
10
10
  import type { CliCommand, CommandContext } from './command';
11
- import { BadFlagError, FixTargetUnknownError } from './errors';
11
+ import { BadFlagError, FixTargetUnknownError, MissingPositionalError } from './errors';
12
12
  import { msg } from './messages';
13
13
  import type { CommandResult, Finding, JsonValue } from './output';
14
14
  import { nearest } from './parse';
@@ -103,10 +103,22 @@ export const fixCommand: CliCommand = {
103
103
  },
104
104
  async run(ctx: CommandContext): Promise<CommandResult> {
105
105
  const root = requireAppRoot('fix', ctx.cwd).dir;
106
+ // Refused before the scan, never defaulted to `''`: an empty string reached `resolveTarget` as
107
+ // a file NAME, so a bare `x fix` answered X_FIX_TARGET_UNKNOWN with `"" is not one of the 42
108
+ // source file(s)…` — and `nearest('')` almost never suggests anything, so the fix degraded to
109
+ // `x routes --json` for a caller who had simply not said which file.
110
+ const file = ctx.args.positionals[0];
111
+ if (file === undefined) {
112
+ throw new MissingPositionalError({
113
+ command: 'fix boundary',
114
+ positional: 'file',
115
+ example: 'x routes --json # every registered route file, app-root-relative',
116
+ });
117
+ }
106
118
  const files = await readAppSources(root);
107
119
  const target = resolveTarget(
108
- ctx.args.positionals[0] ?? '',
109
- files.map((file) => file.path),
120
+ file,
121
+ files.map((source) => source.path),
110
122
  );
111
123
  const cuts = planBoundaryCuts(target, appImportGraph(files));
112
124
 
@@ -286,7 +286,12 @@ type WritePlan =
286
286
  | { readonly kind: 'skip' }
287
287
  | { readonly kind: 'conflict'; readonly finding: Finding };
288
288
 
289
- function planFile(file: GeneratedFile, absolute: string, force: boolean): WritePlan {
289
+ function planFile(
290
+ file: GeneratedFile,
291
+ absolute: string,
292
+ force: boolean,
293
+ invocation: string,
294
+ ): WritePlan {
290
295
  // A foundation file belongs to the slice, not to the generator that needs it: several generators
291
296
  // emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
292
297
  // overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
@@ -303,7 +308,10 @@ function planFile(file: GeneratedFile, absolute: string, force: boolean): WriteP
303
308
  finding: {
304
309
  code: 'X_GENERATE_CONFLICT',
305
310
  cause: `${file.path} already exists`,
306
- fix: `x g --force to overwrite, or pass a different name`,
311
+ // The caller's own invocation, not `x g <kind>`: `x g --force` is X_CLI_UNKNOWN_COMMAND
312
+ // when run, and a `fix:` is copied and pasted verbatim. Same construction as
313
+ // `generate-kinds.ts`'s `assertSurfaceSupported`.
314
+ fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
307
315
  docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
308
316
  at: file.path,
309
317
  },
@@ -325,12 +333,20 @@ export async function writeFiles(
325
333
  root: string,
326
334
  files: readonly GeneratedFile[],
327
335
  force: boolean,
336
+ /**
337
+ * The command line that produced these files, so a conflict's `fix:` can hand it back with
338
+ * `--force` on the end. Optional for a caller assembling files itself; the fallback is the
339
+ * shape, not a runnable line, and every generator path supplies the real one.
340
+ */
341
+ invocation = 'x g <kind> <name>',
328
342
  ): Promise<WriteReport> {
329
343
  const plans: WritePlan[] = [];
330
344
  for (const file of files) {
331
345
  const absolute = containedPath(root, file.path);
332
346
  plans.push(
333
- file.merge === 'json' ? await planJsonMerge(file, absolute) : planFile(file, absolute, force),
347
+ file.merge === 'json'
348
+ ? await planJsonMerge(file, absolute)
349
+ : planFile(file, absolute, force, invocation),
334
350
  );
335
351
  }
336
352
  const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
@@ -381,6 +397,10 @@ export const generateCommand: CliCommand = {
381
397
  // drifted — it omitted `backfill` — and a usage line that can disagree with the list it
382
398
  // describes is exactly the second source of truth axiom 2 forbids.
383
399
  usage: `x g ${GENERATORS.join('|')} <name> [--feature f]`,
400
+ // Declared from the SAME constant `readKind` validates against: without it `fix-command.ts`
401
+ // has no set to judge the word after `x g`, and two shipped `@ultimat3/admin` fix lines said
402
+ // `x g migration` — a generator that has never existed — straight through the `errors` gate.
403
+ positionalChoices: GENERATORS,
384
404
  requiresApp: true,
385
405
  flags: [
386
406
  { name: 'feature', type: 'string', summary: 'feature slice to write into' },
@@ -425,7 +445,12 @@ export const generateCommand: CliCommand = {
425
445
  lines: files.map((file) => msg('cli.file.added', { path: file.path })),
426
446
  };
427
447
  }
428
- const report = await writeFiles(root, files, flagBool(ctx.args, 'force'));
448
+ const report = await writeFiles(
449
+ root,
450
+ files,
451
+ flagBool(ctx.args, 'force'),
452
+ `x g ${kind} ${name}`,
453
+ );
429
454
  // A locale's catalog existing on disk and the app being able to select it are two different
430
455
  // facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
431
456
  // this same invocation just wrote never gets projected against a stale catalog registration.
package/src/cmd-help.ts CHANGED
@@ -14,10 +14,31 @@ const flagLine = (flag: FlagSpec): string => {
14
14
  return ` ${short}${name.padEnd(24)} ${flag.summary}`;
15
15
  };
16
16
 
17
+ /**
18
+ * The one resolution of a topic, read by both renderers. `--json` filtered on `spec.name === topic`
19
+ * of its own, so the two disagreed about exactly the inputs a caller is least sure of: `x help
20
+ * generate --json` answered `[]` — "that command does not exist" — while the page beside it printed
21
+ * `g`, and `x help nosuch --json` answered `[]` while the page printed the whole catalogue.
22
+ */
23
+ const specFor = (
24
+ specs: readonly CommandSpec[],
25
+ topic: string | undefined,
26
+ ): CommandSpec | undefined =>
27
+ topic === undefined
28
+ ? undefined
29
+ : specs.find((entry) => entry.name === topic || (entry.aliases ?? []).includes(topic));
30
+
31
+ /** What `--json` reports: the one resolved command, or — as the human render does — all of them. */
32
+ export function helpTopic(
33
+ specs: readonly CommandSpec[],
34
+ topic: string | undefined,
35
+ ): readonly CommandSpec[] {
36
+ const spec = specFor(specs, topic);
37
+ return spec === undefined ? specs : [spec];
38
+ }
39
+
17
40
  export function renderHelp(specs: readonly CommandSpec[], topic: string | undefined): string[] {
18
- const spec = specs.find(
19
- (entry) => entry.name === topic || (entry.aliases ?? []).includes(topic ?? ''),
20
- );
41
+ const spec = specFor(specs, topic);
21
42
  if (spec === undefined) {
22
43
  // `cli.hint.help` is deliberately absent from this list: it is the command's own `summary`, and
23
44
  // `renderHuman` prints every line and THEN the summary — so the catalogue ended with the same
@@ -77,7 +98,7 @@ export function createHelpCommand(specs: () => readonly CommandSpec[]): CliComma
77
98
  command: 'help',
78
99
  summary: msg('cli.hint.help'),
79
100
  lines: renderHelp(all, topic),
80
- data: topic === undefined ? catalogue(all) : catalogue(all.filter((s) => s.name === topic)),
101
+ data: catalogue(helpTopic(all, topic)),
81
102
  };
82
103
  },
83
104
  };
package/src/cmd-i18n.ts CHANGED
@@ -12,7 +12,7 @@ import { catalogKeys, catalogMissingKeys } from '@ultimat3/i18n';
12
12
  import { loadApp } from './app-load';
13
13
  import { requireAppRoot } from './app-root';
14
14
  import type { CliCommand, CommandContext } from './command';
15
- import { BadFlagError, CatalogExistsError } from './errors';
15
+ import { BadFlagError, CatalogExistsError, MissingPositionalError } from './errors';
16
16
  import {
17
17
  auditApp,
18
18
  loadCatalogs,
@@ -64,10 +64,13 @@ async function writeNewCatalog(absolute: string, locale: string, contents: strin
64
64
  function requireLocalePositional(ctx: CommandContext, sub: string): string {
65
65
  const raw = ctx.args.positionals[0];
66
66
  if (raw === undefined) {
67
- throw new BadFlagError({
68
- flag: 'locale',
69
- command: 'i18n',
70
- reason: `"x i18n ${sub}" needs a locale: x i18n ${sub} <locale>`,
67
+ // The locale is a positional. `--locale on "x i18n"` is the cause a MALFORMED one takes, from
68
+ // the shared validator below — and there the flag name is what `resolveLocales` was told to
69
+ // report; here there is no value at all, and naming a flag sends the retry to a flag loop.
70
+ throw new MissingPositionalError({
71
+ command: `i18n ${sub}`,
72
+ positional: 'locale',
73
+ example: `x i18n ${sub} es`,
71
74
  });
72
75
  }
73
76
  return raw;
package/src/cmd-jobs.ts CHANGED
@@ -8,7 +8,7 @@ import type { JobDriver } from '@ultimat3/jobs';
8
8
  import { cancelJob, createMemoryDriver, createNatsDriver, createRedisDriver } from '@ultimat3/jobs';
9
9
  import { requireAppRoot } from './app-root';
10
10
  import type { CliCommand, CommandContext } from './command';
11
- import { BadFlagError, JobUnknownError } from './errors';
11
+ import { BadFlagError, JobUnknownError, MissingPositionalError } from './errors';
12
12
  import { drainJobs } from './jobs-drain';
13
13
  import { withJobDriver } from './jobs-driver';
14
14
  import {
@@ -33,10 +33,11 @@ const DRAIN_TARGETS = ['memory', 'redis', 'nats'] as const;
33
33
  function requireIdPositional(ctx: CommandContext, sub: string): string {
34
34
  const id = ctx.args.positionals[0];
35
35
  if (id === undefined) {
36
- throw new BadFlagError({
37
- flag: 'id',
38
- command: 'jobs',
39
- reason: `"x jobs ${sub}" needs a job id: x jobs ${sub} <id>`,
36
+ // `--id on "x jobs"` is a flag `x jobs` does not declare; the id is a positional and says so.
37
+ throw new MissingPositionalError({
38
+ command: `jobs ${sub}`,
39
+ positional: 'id',
40
+ example: 'x jobs ls --json',
40
41
  });
41
42
  }
42
43
  return id;
package/src/cmd-mcp.ts CHANGED
@@ -8,6 +8,7 @@ import { mcpHttpRoute, serveStdio } from '@ultimat3/mcp';
8
8
  import { requireAppRoot } from './app-root';
9
9
  import type { CliCommand, CommandContext } from './command';
10
10
  import { BadFlagError } from './errors';
11
+ import { intFlagOr, PORT_RANGE } from './flag-number';
11
12
  import { holdUntilShutdown } from './hold';
12
13
  import type { CliMcpServer } from './mcp-host';
13
14
  import { createDevMcpServer, DEV_TOOL_SCOPES } from './mcp-host';
@@ -128,19 +129,22 @@ async function serveOverStdio(host: CliMcpServer): Promise<CommandResult> {
128
129
  };
129
130
  }
130
131
 
131
- function readPort(ctx: CommandContext): number {
132
- const raw = flagString(ctx.args, 'port') ?? String(DEFAULT_PORT);
133
- const port = Number.parseInt(raw, 10);
134
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
135
- throw new BadFlagError({
136
- flag: 'port',
132
+ /**
133
+ * `flag-number.ts`'s reader, never a bare `Number.parseInt`: the range check alone accepted every
134
+ * prefix parse, so `--port 1e5` bound port 1 and `--port 0x10` bound 0 — a socket at an address the
135
+ * operator did not type, on the transport whose whole output is the url it is reachable at.
136
+ */
137
+ const readPort = (ctx: CommandContext): number =>
138
+ intFlagOr(
139
+ ctx.args,
140
+ {
141
+ name: 'port',
137
142
  command: 'mcp serve',
138
- reason: `expects a port in 0..65535, got "${raw}"`,
139
- fix: `x mcp serve --transport http --port ${DEFAULT_PORT}`,
140
- });
141
- }
142
- return port;
143
- }
143
+ ...PORT_RANGE,
144
+ example: `x mcp serve --transport http --port ${DEFAULT_PORT}`,
145
+ },
146
+ DEFAULT_PORT,
147
+ );
144
148
 
145
149
  export const mcpCommand: CliCommand = {
146
150
  spec: {