@ultimat3/cli 3.0.0 → 4.1.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 +69 -10
- package/package.json +24 -24
- package/src/budgets.ts +8 -5
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-errors.ts +5 -4
- 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 +9 -13
- 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-verify.ts +55 -3
- package/src/command.ts +10 -2
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-runtime.ts +2 -2
- package/src/dispatch.ts +33 -4
- package/src/error-codes.ts +5 -0
- package/src/error-contract.ts +31 -4
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +8 -1
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +5 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +3 -0
- package/src/otlp-export.ts +14 -0
- package/src/output.ts +8 -5
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -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/ts-scan.ts +12 -174
- 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
|
|
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 `
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
317
|
-
|
|
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
|
+
"version": "4.1.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.1.0",
|
|
39
|
+
"@ultimat3/admin": "4.1.0",
|
|
40
|
+
"@ultimat3/ai": "4.1.0",
|
|
41
|
+
"@ultimat3/cache": "4.1.0",
|
|
42
|
+
"@ultimat3/core": "4.1.0",
|
|
43
|
+
"@ultimat3/db": "4.1.0",
|
|
44
|
+
"@ultimat3/entity": "4.1.0",
|
|
45
|
+
"@ultimat3/http": "4.1.0",
|
|
46
|
+
"@ultimat3/i18n": "4.1.0",
|
|
47
|
+
"@ultimat3/jobs": "4.1.0",
|
|
48
|
+
"@ultimat3/mail": "4.1.0",
|
|
49
|
+
"@ultimat3/manifest": "4.1.0",
|
|
50
|
+
"@ultimat3/mcp": "4.1.0",
|
|
51
|
+
"@ultimat3/policy": "4.1.0",
|
|
52
|
+
"@ultimat3/pwa": "4.1.0",
|
|
53
|
+
"@ultimat3/query": "4.1.0",
|
|
54
|
+
"@ultimat3/realtime": "4.1.0",
|
|
55
|
+
"@ultimat3/render": "4.1.0",
|
|
56
|
+
"@ultimat3/schema": "4.1.0",
|
|
57
|
+
"@ultimat3/seo": "4.1.0",
|
|
58
|
+
"@ultimat3/storage": "4.1.0",
|
|
59
|
+
"@ultimat3/testing": "4.1.0",
|
|
60
|
+
"@ultimat3/time": "4.1.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,
|
|
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
|
|
32
|
-
* `templates/scaffold-container.ts` scaffolds
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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-errors.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { singleLine } from '@ultimat3/core';
|
|
1
2
|
// `x errors explain <CODE>` / `x errors list` — the error table, programmatically. An agent that
|
|
2
3
|
// hits an `X_*` code should not have to leave the terminal to learn what it means, and a code it
|
|
3
4
|
// invented should come back refused: the answer to an unregistered code is "no such code", never
|
|
@@ -35,9 +36,9 @@ const asJson = (explanation: ErrorExplanation): JsonValue => {
|
|
|
35
36
|
|
|
36
37
|
/** The 3-line contract format, minus the leading blank code line `renderFinding` would add. */
|
|
37
38
|
const detailLines = (explanation: ErrorExplanation): readonly string[] => [
|
|
38
|
-
` cause: ${explanation.cause}`,
|
|
39
|
-
` fix: ${explanation.fix}`,
|
|
40
|
-
` docs: ${explanation.docs}`,
|
|
39
|
+
` cause: ${singleLine(explanation.cause)}`,
|
|
40
|
+
` fix: ${singleLine(explanation.fix)}`,
|
|
41
|
+
` docs: ${singleLine(explanation.docs)}`,
|
|
41
42
|
];
|
|
42
43
|
|
|
43
44
|
function explainOne(code: string): CommandResult {
|
|
@@ -69,7 +70,7 @@ function listAll(catalog: ErrorCatalog): CommandResult {
|
|
|
69
70
|
ok: catalog.failed.length === 0,
|
|
70
71
|
command: 'errors',
|
|
71
72
|
summary: msg('cli.errors.count', { count: all.length }),
|
|
72
|
-
lines: all.map((entry) => ` ${entry.code.padEnd(30)} ${entry.cause}`),
|
|
73
|
+
lines: all.map((entry) => ` ${entry.code.padEnd(30)} ${singleLine(entry.cause)}`),
|
|
73
74
|
findings: catalog.failed,
|
|
74
75
|
data: {
|
|
75
76
|
codes: all.map(asJson),
|
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
|
-
|
|
109
|
-
files.map((
|
|
120
|
+
file,
|
|
121
|
+
files.map((source) => source.path),
|
|
110
122
|
);
|
|
111
123
|
const cuts = planBoundaryCuts(target, appImportGraph(files));
|
|
112
124
|
|
package/src/cmd-generate.ts
CHANGED
|
@@ -286,7 +286,12 @@ type WritePlan =
|
|
|
286
286
|
| { readonly kind: 'skip' }
|
|
287
287
|
| { readonly kind: 'conflict'; readonly finding: Finding };
|
|
288
288
|
|
|
289
|
-
function planFile(
|
|
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
|
-
|
|
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'
|
|
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(
|
|
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
|
|
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:
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
command:
|
|
39
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
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: {
|