@ultimat3/cli 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
# @ultimat3/cli — boundary
|
|
2
|
+
|
|
3
|
+
Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
4
|
+
|
|
5
|
+
| Rule | Detail |
|
|
6
|
+
|---|---|
|
|
7
|
+
| Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only |
|
|
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
|
+
| 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
|
+
| 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
|
+
| 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 |
|
|
12
|
+
| I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
|
|
13
|
+
| Staying up | a command still listening when `run` resolves returns `hold` (`hold.ts`), or `bin.ts` exits out from under it |
|
|
14
|
+
| `--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` |
|
|
16
|
+
| Subprocesses | only through `exec.ts`, so a test can inject a fake `Runner` |
|
|
17
|
+
| Templates | `templates/*.ts` return strings; no fixture files on disk |
|
|
18
|
+
| Strings | rendered output through `messages.ts`, missing key renders `⟦key⟧` — see below for what is *not* rendered output |
|
|
19
|
+
| Facts | load the app (`app-load.ts`), then project it — never parse source for primitives |
|
|
20
|
+
|
|
21
|
+
Every fact the CLI reports comes from a framework package: the manifest from
|
|
22
|
+
`@ultimat3/manifest`, `openapi.json` from `@ultimat3/action`, the route table from
|
|
23
|
+
`@ultimat3/render`, budget units from `@ultimat3/render`, the `/_x` panels from
|
|
24
|
+
`@ultimat3/admin`, the MCP tool catalog from `@ultimat3/mcp`, eval coverage from
|
|
25
|
+
`@ultimat3/ai`. A check that reimplements one of those here is the bug, not the fix.
|
|
26
|
+
|
|
27
|
+
`app-evals.ts` is why the `eval` step can apply with no eval suite at all: a prompt no eval
|
|
28
|
+
names is `X_EVAL_MISSING`, an eval whose baseline was never recorded is `X_EVAL_BASELINE_MISSING`,
|
|
29
|
+
and a skipped step would read as a green gate over untested code. Its third rule runs *before* the
|
|
30
|
+
suite rather than beside it — `ULTIMATE_EVAL_RECORD` makes every eval write its own numbers and
|
|
31
|
+
pass, so a gate that inherited the flag would rewrite the committed baselines during the run, and
|
|
32
|
+
a finding after the fact does not put them back.
|
|
33
|
+
|
|
34
|
+
`verify-floor.ts` is the suite ratchet, and it is split across two owners on purpose. `runVerify`
|
|
35
|
+
judges the **suites**: a step the committed `x.verify.json` names that reports nothing to check is
|
|
36
|
+
recorded failed and not skipped, so the failure count, `data.failed` and every step table another
|
|
37
|
+
gate parses all carry it. The `manifest` step judges the **file**: a floor that does not parse, or
|
|
38
|
+
that names a step the gate does not run, enforces nothing — and a ratchet nobody notices is off is
|
|
39
|
+
the false green it exists to close. Nothing writes the file; a gate that edits its own floor
|
|
40
|
+
ratchets in both directions.
|
|
41
|
+
|
|
42
|
+
**"Nothing to check" is two conditions and one code.** `applies` sees the first — no files — and
|
|
43
|
+
cannot see the second, because `describe.skipIf` is decided inside the child process: measured with
|
|
44
|
+
no `TEST_DATABASE_URL`, `live` is `4 pass, 114 skip` and the step reported green over a suite whose
|
|
45
|
+
whole subject is the database. `test-counts.ts` reads bun's own summary back (`parseBunTest`, the
|
|
46
|
+
same reader `x mcp`'s `test.run` uses — a second regex over one format is drift), each runner
|
|
47
|
+
attaches `StepOutcome.tests`, and a floor step whose `ran` is zero is `X_VERIFY_SUITE_VANISHED`
|
|
48
|
+
with `skippedSuiteFinding`'s cause. **Zero, not a ratio**: one real assertion is a suite that runs,
|
|
49
|
+
and a threshold would be a number nobody can defend. An absent `tests` is a step that spawned no
|
|
50
|
+
test process at all (`eval` answering with declarations alone), which is not the same claim.
|
|
51
|
+
|
|
52
|
+
`x new` writes an `x.verify.json` (`templates/scaffold-repo.ts`), or the code above is unreachable
|
|
53
|
+
in every generated app — the repo shape that grows suites fastest. It names the eleven steps the
|
|
54
|
+
scaffold has proved apply, and deliberately not `e2e`: the scaffolded `page.e2e.test.ts` is an
|
|
55
|
+
`e2eTest`, which is `test.skip` until the app registers a browser driver, so pinning it would fail
|
|
56
|
+
the app's first gate on the scaffold's own placeholder.
|
|
57
|
+
|
|
58
|
+
`tsconfig-references.ts` is `package-shape`'s fourth rule: **every published workspace is in the
|
|
59
|
+
root `references`**. `bun run typecheck` is `tsc -b`, which builds referenced projects and nothing
|
|
60
|
+
else, so a package no reference names is one the gate's own `typecheck` step passes over without
|
|
61
|
+
reading a line of it — `X_PACKAGE_UNREFERENCED`, whose `fix:` is the exact `{ "path": … }` entry
|
|
62
|
+
and the `tsc -b` that proves it took. Private packages are exempt (a generated app's are all
|
|
63
|
+
private), and a root that declares **no** `references` array is not judged at all: project
|
|
64
|
+
references are opt-in, and a scaffolded app builds through `extends` + `include`.
|
|
65
|
+
|
|
66
|
+
`app-agents-md.ts` is why the `manifest` step declares no `applies` at all. The drift half needs
|
|
67
|
+
a committed `x.manifest.json` to compare against, but `AGENTS.md` is required of every repo the
|
|
68
|
+
gate runs in — so the step always has a question to answer, and gating both halves on the file
|
|
69
|
+
that only the first one needs is how `X_AGENTS_MD_MISSING` stayed unreachable while its wiki row
|
|
70
|
+
said it fails builds.
|
|
71
|
+
|
|
72
|
+
## What goes in `messages.ts`, and what does not
|
|
73
|
+
|
|
74
|
+
`messages.ts` holds the strings a command *renders* — `CommandResult.summary`, `lines`, anything
|
|
75
|
+
the human renderer prints. Three things stay inline, deliberately, and a review asking to move
|
|
76
|
+
them is answered by this table rather than by a second convention:
|
|
77
|
+
|
|
78
|
+
| Not in the catalog | Why |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `CommandSpec.summary` / `.usage` / `FlagSpec.summary` | the spec is the command's declaration, next to the `run` it describes; parsing and `x help` both derive from it. All command modules declare it inline — moving a subset creates two places to look for one command's help |
|
|
81
|
+
| `Finding.cause` / `Finding.fix`, and `BadFlagError`'s `reason` | stable machine-readable diagnostics. A `fix:` is copied and run verbatim; a translated one is a broken command |
|
|
82
|
+
| Fixed-width table headers (`renderJobTable`, `renderRouteTable`) | column keys, not prose — the widths are computed from them and `--json` carries the same names |
|
|
83
|
+
|
|
84
|
+
## The introspection commands project registries, they never re-derive facts
|
|
85
|
+
|
|
86
|
+
| Command | Files | Reads |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `x actions` / `x queries` / `x entities` | `cmd-registries.ts` | the three declaration registries |
|
|
89
|
+
| `x jobs` | `cmd-jobs.ts`, `jobs-{driver,report,drain,json,table}.ts` | `@ultimat3/jobs`' own introspection |
|
|
90
|
+
| `x tasks` | `cmd-tasks.ts`, `tasks-facts.ts` | `registeredTasks()` + `@ultimat3/time`'s cron resolution |
|
|
91
|
+
| `x policy` | `cmd-policy.ts`, `policy-facts.ts` | `@ultimat3/policy`'s `policyMatrix()` over the app's own `Policy` objects |
|
|
92
|
+
| `x i18n` | `cmd-i18n.ts`, `i18n-audit.ts` | `@ultimat3/i18n`'s `extractFromFiles` + `auditCatalogs` |
|
|
93
|
+
|
|
94
|
+
Each pairs a `cmd-*.ts` of CLI wiring with a facts module that takes plain inputs and returns plain
|
|
95
|
+
data, so the projection is testable without a `ParsedArgs` — the `cmd-jobs.ts` / `jobs-report.ts`
|
|
96
|
+
split, repeated. Tables go through `table.ts`; a second padding helper is the drift it prevents.
|
|
97
|
+
|
|
98
|
+
`x policy explain` exists because five packages already print it as the `fix:` on an authz denial
|
|
99
|
+
(`policy`, `action`, `query`, `http`, `auth`), and `x i18n` because all three of `@ultimat3/i18n`'s
|
|
100
|
+
own error fixes name it. A `fix:` line naming a command this build does not ship is the failure
|
|
101
|
+
mode `cmd-planned.ts` closes for planned commands and these close for real ones.
|
|
102
|
+
|
|
103
|
+
`x i18n check` scans source, which the "never parse source for primitives" rule below does not
|
|
104
|
+
forbid: a `t()` call is not a primitive and no registry holds it. It uses `source-files.ts`, the
|
|
105
|
+
same walk `errors` and `filesize` use, so the three cannot disagree on what the app's source is.
|
|
106
|
+
|
|
107
|
+
**A catalog is authored nested and read flat.** `Catalog` (`{ 'nav.home': 'Home' }`) is the
|
|
108
|
+
translator's form; the file on disk holds `{ nav: { home: 'Home' } }`, and `parseNestedCatalog`
|
|
109
|
+
refuses a dot inside a key — so anything writing a catalog goes through `nestCatalog`
|
|
110
|
+
(`serializeCatalog` for `x i18n add|sync`, `templates/catalog-json.ts` for every generator) or it
|
|
111
|
+
emits a file `defineCatalogs` rejects at the app's first boot. `merge: 'json'` unions **deeply**
|
|
112
|
+
(`json-merge.ts`) for the same reason: `x new` and `x g resource` both contribute under `app`, and
|
|
113
|
+
a shallow spread keeps one of them.
|
|
114
|
+
|
|
115
|
+
## The `errors` step enforces the error contract
|
|
116
|
+
|
|
117
|
+
| File | Job |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `ts-scan.ts` | the strings a `fix:` can evaluate to, the `X_*` codes a file declares, and the ones it says it borrows |
|
|
120
|
+
| `error-contract.ts` | the rules, the two checks that turn them into findings, and `collectDeclaredCodes` |
|
|
121
|
+
| `fix-command.ts` | resolving an `x <command>` a `fix:` cites against the registry |
|
|
122
|
+
| `source-files.ts` | which files are shipped source — shared with `filesize`, never a second list |
|
|
123
|
+
|
|
124
|
+
**A `fix:` may not cite a command this build does not ship.** Six shipped fix lines named
|
|
125
|
+
`x db status`, `x logs tail`, `x trace`, `x metrics`, `x auth whoami` and `x ai prompts`, and every
|
|
126
|
+
one passed — the text rule checks that a fix NAMES a command, never that the registry holds it.
|
|
127
|
+
`fix-command.ts` resolves the citation, and a PLANNED command fails too: `x logs` parses, `x help`
|
|
128
|
+
lists it, and running it hands the reader `X_NOT_IMPLEMENTED` instead of the fix.
|
|
129
|
+
|
|
130
|
+
The rule is **conditional, and that is load-bearing**: *if* a fix cites `x <command>`, it must
|
|
131
|
+
resolve. It does not require every fix to name one — `set OTEL_EXPORTER_OTLP_ENDPOINT=…` and
|
|
132
|
+
`counter('orders_total', { maxSeries: 4000 })` are executable and correctly cite nothing, and a
|
|
133
|
+
universal rule would push an author into citing a command that does not really fix it. A second
|
|
134
|
+
word is judged as a subcommand only when the spec declares subcommands, or `x new my-app` reports
|
|
135
|
+
`my-app` as one. The registry arrives through `await import('./registry')` — `registry → cmd-verify
|
|
136
|
+
→ error-contract` closes a cycle back to the caller, and the precedent for the break is
|
|
137
|
+
`cmd-build.ts`.
|
|
138
|
+
|
|
139
|
+
**It reads a THIRD word, under the same condition.** `x db branch ls --json` resolved — `db` is a
|
|
140
|
+
command, `branch` is one of its subcommands — and the word that decided what actually ran was never
|
|
141
|
+
looked at, so a fix line that created a stray database passed every check the repo had. A third
|
|
142
|
+
word is judged only where the subcommand declares a closed set (`CommandSpec.subcommandPositionals`,
|
|
143
|
+
declared from the constant the command validates against), because `x jobs show <id>` and
|
|
144
|
+
`x db gen "add publish_at"` take open positionals and a universal rule would report findings about
|
|
145
|
+
working invocations. `positionalChoices` cannot express it: `fix-command.ts` reads that field only
|
|
146
|
+
where a command declares no subcommands at all.
|
|
147
|
+
|
|
148
|
+
**And in that one slot, a `<placeholder>` is a finding too.** `x db branch <name>` is what two
|
|
149
|
+
`@ultimat3/mcp` fix lines said; the citation reader does not read `<name>` as a word, so the slot
|
|
150
|
+
was never examined and the line resolved clean while running it answers `X_CLI_UNKNOWN_COMMAND` —
|
|
151
|
+
the same blind spot in a second disguise. A closed set means the slot is a verb, so there is
|
|
152
|
+
nothing a reader could substitute that would make it run. `CITATION` therefore matches a
|
|
153
|
+
placeholder in the third slot **only**: `x jobs show <id>` and `x db branch drop <name>` are correct
|
|
154
|
+
fix lines and must stay invisible to this rule.
|
|
155
|
+
|
|
156
|
+
`collectDeclaredCodes` is the only answer to "which codes exist, and where is each declared?" — one
|
|
157
|
+
walk, one entry per code, the owning registry preferred over any throw site and over a registry
|
|
158
|
+
that named the code in its `<PKG>_BORROWED_ERROR_CODES`. The docs check reads it and so does the
|
|
159
|
+
framework's own `framework.manifest.json`, because a second scanner over a narrower file set is a
|
|
160
|
+
manifest that claims completeness it does not have.
|
|
161
|
+
|
|
162
|
+
An empty `fix`, or a `fix` that says `check` / `make sure` / `try` / `see the docs` and names no
|
|
163
|
+
command, call or file path, is `X_ERROR_FIX_INVALID`. A declared code the host's error reference
|
|
164
|
+
does not name is `X_ERROR_CODE_UNDOCUMENTED` — `wiki/Error-Codes.md` here, nothing in a generated
|
|
165
|
+
app, which is why that half arrives as a host check (`scripts/verify.ts`) rather than a hardcoded
|
|
166
|
+
path in this package.
|
|
167
|
+
|
|
168
|
+
`ts-scan.ts` masks comments and string contents before it looks for structure. The contract's own
|
|
169
|
+
3-line rendering appears verbatim in doc blocks and interpolated messages, and a scanner that read
|
|
170
|
+
those as declarations would report findings nobody can fix. What it cannot see is a `fix` with no
|
|
171
|
+
literal — a parameter, or a table lookup with no fallback. Those are out of a static scan's reach,
|
|
172
|
+
and the step says so rather than guessing.
|
|
173
|
+
|
|
174
|
+
**A fix does not always arrive under a key**, and until `As of 2026-08` the scanner assumed it did.
|
|
175
|
+
`@ultimat3/mcp`'s `readonly-sql.ts` hands every fix positionally to a local `rejected(cause, fix)`
|
|
176
|
+
helper, so the file held no `fix:` at all and `scanFixes` returned `[]` for all of it — the
|
|
177
|
+
citation resolver was never given a string to judge, and two stale `x db branch <name>` lines
|
|
178
|
+
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
|
|
180
|
+
BUILD an error (`code` key or `new …Error(` in its body), or `citedCommandProblem(fix, catalog)` —
|
|
181
|
+
which takes a fix to *judge* it — would have its call sites read as declarations; the parameter
|
|
182
|
+
list may hold no rest or destructured parameter, because neither has a reliable position; the call
|
|
183
|
+
may not be a member access; and the argument must BE one literal, stricter than the key path,
|
|
184
|
+
because `prefix + 'x doctor'` reads as one literal there and publishing half a fix is worse than
|
|
185
|
+
publishing none. Measured over the whole tree: 16 files gained readable fixes, `readonly-sql.ts`
|
|
186
|
+
went from 0 to 7, and **zero** new findings.
|
|
187
|
+
|
|
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.
|
|
194
|
+
|
|
195
|
+
`cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`): `x dev` **mounts** the
|
|
196
|
+
dashboard, it never grows a second one. The CLI's only contribution is the facts no registry
|
|
197
|
+
holds — a SQL runner, the caught outbox, the committed manifest, the process's own services, the
|
|
198
|
+
spans it recorded — supplied as `defaultDevSources({ hooks })`.
|
|
199
|
+
|
|
200
|
+
Wired means answerable: all eleven panels answer in a `x dev` process, and a hook the CLI does
|
|
201
|
+
not supply is a panel that refuses with a wiring line, never one that renders empty. `timeline`
|
|
202
|
+
is core's tracer (`x dev` is what calls `configureTelemetry`), `cache` is
|
|
203
|
+
`recentInvalidations()`, `policy` is `@ultimat3/policy`'s own `policyMatrix()` over the app's
|
|
204
|
+
roles — a verdict re-derived here would be the second authz the framework exists to prevent.
|
|
205
|
+
`subscribers` is the one source left unwired: `@ultimat3/realtime` retains no matcher trace, and
|
|
206
|
+
that trace is the live panel's question, so the panel degrades to its own note instead.
|
|
207
|
+
|
|
208
|
+
`dev-traces.ts` reads a span's panel kind off its **name prefix** — a subsystem that starts emitting
|
|
209
|
+
spans adds its prefix to `KIND_BY_PREFIX` or its work is filed under `action`. `db.` is there
|
|
210
|
+
because `@ultimat3/db`'s two funnels open one span per statement (`db.select`, `db.begin`), and a
|
|
211
|
+
statement is the one span that states its own identity — `STATEMENT_ATTRIBUTE`, **imported** from
|
|
212
|
+
`@ultimat3/db` by both `dev-traces.ts` and its test rather than spelled as a literal, which the
|
|
213
|
+
recorder prefers over the name, so the timeline's `repeatedSql` groups SQL texts and not span names. Those spans
|
|
214
|
+
exist only where a `StatementObserver` is installed, so a trace with no DB children is a process
|
|
215
|
+
with no statement diagnostic, not a broken recorder.
|
|
216
|
+
|
|
217
|
+
`dev-n-plus-one.ts` is that observer, and `cmd-dev.ts` is the **only** place that installs it —
|
|
218
|
+
`serve.ts` installs neither it nor the in-process trace RECORDER (`createTraceRecorder`, which is
|
|
219
|
+
`/_x/timeline`'s source), the same line that file already draws for `/_x`. **It is not "no
|
|
220
|
+
exporter"**, `As of 2026-08`: `serve.ts` calls `startOtlpExport(options.env)`, because a collector
|
|
221
|
+
named in the chart has to receive spans from the container and not only from a laptop. What a
|
|
222
|
+
production process does without is the *statement* diagnostic and the in-memory timeline — the
|
|
223
|
+
ledger and the recorder go in together and come out together in `stop()`, because the timeline's
|
|
224
|
+
SQL rows and the repeat counts are one feature with one toggle, and uninstalled the seam costs the
|
|
225
|
+
one `undefined` branch it already pays (axiom 6).
|
|
226
|
+
|
|
227
|
+
Three rules hold the ledger, each load-bearing. **Per request, keyed by the `Ctx` object** — a
|
|
228
|
+
`WeakMap` whose entry dies with the request, so nothing sweeps and nothing accumulates across a dev
|
|
229
|
+
session; a statement issued outside a request is not counted at all, because "five of one shape"
|
|
230
|
+
only means something inside one unit of work. The price of keying on identity is that a
|
|
231
|
+
`withChildContext` scope is its own tally. **A shape is `entity.op` when attributed**, the
|
|
232
|
+
statement's own text with whitespace collapsed when it is not — `members.findById` fifty times is
|
|
233
|
+
what an author can act on, and grouping fifty point lookups by their SQL would report bind values.
|
|
234
|
+
That rule is **not written here**: `statementFingerprint`/`statementKind` are `@ultimat3/db`'s and
|
|
235
|
+
the threshold is `@ultimat3/entity`'s `N_PLUS_ONE_THRESHOLD`, because `@ultimat3/testing`'s
|
|
236
|
+
`statements` fixture is a second detector and a copy of either would let a loop that fails a test be
|
|
237
|
+
a different loop from the one this ledger warns about. What stays here is what only a dev *server*
|
|
238
|
+
knows: the request as the unit of work, the bound report list, one log line per request per code.
|
|
239
|
+
**An expected statement is not counted** — `expectedQueryLoop` suppresses a verdict and this ledger
|
|
240
|
+
is the verdict, so the span and the timeline still show the loop while the thing that warns is told
|
|
241
|
+
the author already answered. A shape is promoted to a verdict exactly once, on the statement that
|
|
242
|
+
crosses the threshold, and its count keeps rising: a loop of fifty is one report reading fifty. The
|
|
243
|
+
report list is bounded and drops its oldest.
|
|
244
|
+
|
|
245
|
+
`statement-loop.ts` is the **one** projection those verdicts reach four surfaces through, and the
|
|
246
|
+
reason there is only one is that four renderings of one loop must be one sentence. It hands a
|
|
247
|
+
verdict to `@ultimat3/entity`'s `nPlusOne()` — the `fix:` speaks that package's vocabulary and is
|
|
248
|
+
derived from the relations the schema already declared — and each surface takes a field of what
|
|
249
|
+
comes back: `cmd-dev.ts` appends `loopFinding` to the `findings` getter (text and `--json` render it
|
|
250
|
+
for free), `dev-dashboard.ts` supplies `statementLoops` so `/_x/timeline` shows `nPlusOne` for the
|
|
251
|
+
request on screen, `cmd-dev.ts` again passes `devNotices` down `startRoles` so the browser overlay
|
|
252
|
+
renders the loop under the error, and the ledger itself emits `warnLoop` — one `logger.warn` per
|
|
253
|
+
request per code, the ids riding along from core's `setLoggerContextFields`.
|
|
254
|
+
|
|
255
|
+
Two rules about *when* a count is read. **A surface reads it live**: the finding, the panel row and
|
|
256
|
+
the notice all say `ran 50 times` because they ask after the loop finished, while the log line says
|
|
257
|
+
`ran 5 times` because it was written the moment the threshold was crossed — same verdict, two
|
|
258
|
+
honest moments. **A verdict belongs to its request**: `repeatsFor(ctx)` reads the request's own
|
|
259
|
+
tally rather than filtering the bounded global list, so the overlay still names a loop the bound
|
|
260
|
+
already dropped. `serve.ts` supplies no `devNotices`, so the seam it boots through is a key that is
|
|
261
|
+
absent, not a hook answering an empty list.
|
|
262
|
+
|
|
263
|
+
`dev-n-plus-one.test.ts` and `statement-loop.test.ts` drive the ledger and the projection with
|
|
264
|
+
hand-built `StatementEvent`s — fast, and enough to pin every rule above. `n-plus-one-detector.test.ts`
|
|
265
|
+
proves the loop those events stand in for: real `posts`/`authors` entities, `postgresRepo` and
|
|
266
|
+
`createPgliteClient` (an injected fake driver so no `@electric-sql/pglite` build is needed, but a
|
|
267
|
+
real client — `createRecordingClient` implements `DbClient` on its own and never reaches the
|
|
268
|
+
observer, so it cannot stand in here) — a naive per-row `findById` loop trips `X_N_PLUS_ONE_QUERY`
|
|
269
|
+
with the exact `preload('author')` line, the `preload()` form of the same read stays quiet,
|
|
270
|
+
`expectedQueryLoop` silences the naive form without stopping it from running, and a naive per-row
|
|
271
|
+
`delete` loop trips `X_N_PLUS_ONE_WRITE`. Its describe block spells the pattern `n1`, matching
|
|
272
|
+
`packages/entity/src/n-plus-one.test.ts`'s own fixture prefix, because `bun test -t 'n+1'` is a
|
|
273
|
+
regex and `+` is a quantifier — `n1` is what actually selects these tests.
|
|
274
|
+
|
|
275
|
+
## One migration engine, four environments
|
|
276
|
+
|
|
277
|
+
| File | Job |
|
|
278
|
+
|---|---|
|
|
279
|
+
| `migrations.ts` | the app's `packages/db/migrations` read into `@ultimat3/db`'s `Migration` shape — the **one** reader |
|
|
280
|
+
| `db-generate.ts` | `x db gen`: entities diffed against what the migrations declare, written as `.sql` + `.snapshot.json` + `.hash` |
|
|
281
|
+
| `cmd-db.ts` | the subcommands, and nothing else — `gen` calls `db-generate.ts`, `migrate`/`reset` call `serve.ts`'s `runMigrations` |
|
|
282
|
+
| `db-branch.ts` | what a branch IS: the closed verb set, the name it takes on disk and in `pg_database`, and list/create/drop per mode |
|
|
283
|
+
| `cmd-db-branch.ts` | `x db branch`'s wiring alone — which verb, which refusal, and the one connection an external clone runs on |
|
|
284
|
+
| `db-finding.ts` | one thrown value → one `Finding`, shared by `cmd-db.ts` and `cmd-db-branch.ts` |
|
|
285
|
+
| `drift.ts` | `checkSourceDrift`: the `.hash` sidecar `x verify`'s `drift` step compares, no database needed |
|
|
286
|
+
| `db-destructive.ts` | `checkDestructiveMigrations`: the same step's second half — every committed `up` that drops, truncates or retypes must carry `-- destructive: true` |
|
|
287
|
+
| `db-backfill.ts` | `x db backfill --list`: the flag parsing, the ledger read and the table |
|
|
288
|
+
|
|
289
|
+
`jobs-driver.ts` is the ONE place a CLI command gets hold of the app's queue — `withJobDriver`,
|
|
290
|
+
which `x jobs` and `x db backfill` both call. It reuses an ambient `jobDriver()` when a process
|
|
291
|
+
already installed one (inside `x dev` or `x mcp serve`, booting a second queue talks to the wrong
|
|
292
|
+
database) and otherwise boots `startQueue` and releases it in a `finally`, or a CLI that exits
|
|
293
|
+
holding the PGlite lock breaks the next command run against this app. A second copy of that boot
|
|
294
|
+
would be two answers to "which queue is this command talking to".
|
|
295
|
+
|
|
296
|
+
**`x db branch` takes a VERB, and a branch name can never be one.** `ls`, `create <name>`,
|
|
297
|
+
`drop <name>` — a closed set, declared once in `BRANCH_SUBCOMMANDS` and read three ways: the
|
|
298
|
+
command validates against it, `dbCommand.spec.subcommandPositionals` declares it so the `errors`
|
|
299
|
+
step can resolve a citation against it, and the refusal for an unknown word lists it. The bare-name
|
|
300
|
+
form it replaces is why: the argument *was* the name, so `x db branch ls --json` — the `fix:` on
|
|
301
|
+
the planned `x branch`, on `X_DB_BRANCH_FAILED`, and (as `create`/`drop`) on `@ultimat3/db`'s own
|
|
302
|
+
`X_BRANCH_EXISTS` and `X_SQL_UNSAFE` — cloned a database called `ls` and returned no listing. All
|
|
303
|
+
four passed every check the repo had, because `fix-command.ts` resolved two words and the third was
|
|
304
|
+
the one that decided what ran.
|
|
305
|
+
|
|
306
|
+
**`drop` has no confirmation flag, and that is the design.** It may only drop what `ls` shows: an
|
|
307
|
+
external branch is a database carrying the marker comment `createBranch` writes **and** this
|
|
308
|
+
database's own `<source>_branch_` prefix, an embedded one is a `pgdata-<name>` directory, so the
|
|
309
|
+
shared database this session is connected to is in neither set.
|
|
310
|
+
The typo is impossible rather than the keystroke tedious — and `@ultimat3/db` already ships
|
|
311
|
+
`x db branch drop <name>` as `X_BRANCH_EXISTS`'s `fix:` with no flag on it, so a flag here would
|
|
312
|
+
break a shipped instruction.
|
|
313
|
+
|
|
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
|
|
316
|
+
apps' clones, and `branchNameOf` reduced `postly_branch_feat` and `analytics_branch_feat` to the
|
|
317
|
+
same branch name — so `x db branch drop feat`, run against `postly`, was authorised by
|
|
318
|
+
`analytics`'s row and then issued `drop database if exists "postly_branch_feat"` against a database
|
|
319
|
+
carrying no marker at all: a `DROP DATABASE` the guard had never approved, and nothing recoverable
|
|
320
|
+
about it. `branchNameIn(source, database)` is the source-scoped inverse of `branchDatabaseName` and
|
|
321
|
+
the one `ls` and `drop` both read; `branchNameOf` survives for `mcp-db-target.ts` alone, which has
|
|
322
|
+
a URL and no connection to ask `current_database()` with.
|
|
323
|
+
|
|
324
|
+
**The membership check lives inside `dropExternalBranch`, not in the wiring above it.** One
|
|
325
|
+
connection, one listing, one statement before the `DROP` — a listing taken by the caller and acted
|
|
326
|
+
on afterwards is two connections and a window wide enough to hold a whole `create`. It is still not
|
|
327
|
+
atomic and cannot be: `DROP DATABASE` runs in no transaction, so no single statement both verifies
|
|
328
|
+
the marker and deletes. Closing the last gap means a lock around both halves inside
|
|
329
|
+
`@ultimat3/db`'s `dropBranch` — which a `psql` at the next terminal would not hold either.
|
|
330
|
+
|
|
331
|
+
**`ls` is the reason `create` no longer shells out to `psql`.** `listBranches()` finds branches by
|
|
332
|
+
`createBranch`'s marker comment; the `psql` path wrote the `CREATE DATABASE` and no comment, so
|
|
333
|
+
every branch the CLI made was invisible to the only lister the framework has. External branching
|
|
334
|
+
now runs through `@ultimat3/db` on one `role: 'migrate'` client — `max: 1`, no statement timeout,
|
|
335
|
+
both load-bearing: `CREATE DATABASE … TEMPLATE` is refused while any *other* session holds the
|
|
336
|
+
template, and cloning a real database outlives a `web` profile's 10s.
|
|
337
|
+
|
|
338
|
+
**`DatabaseTarget.production` is a fact this package supplies, and it was the literal `false`.**
|
|
339
|
+
`mcp-db-target.ts` is the only place one is ever built, so `assertBranchDatabase`'s first refusal —
|
|
340
|
+
"production is never migratable from MCP at all" — could not run for any database the CLI produced;
|
|
341
|
+
a production database was refused only incidentally, because its name lacked `_branch_`, and one
|
|
342
|
+
named `shop_branch_hotfix` read as a branch and was migratable. It is now core's one key, read the
|
|
343
|
+
way `x doctor` reads it. An **unreadable** `ULTIMATE_ENV` counts as production: `tryResolveEnvironment`
|
|
344
|
+
answers `undefined` for exactly one input — a value that is not an environment — and a guard that
|
|
345
|
+
read a typo as "not production" would be defeated by the misconfiguration it exists to survive.
|
|
346
|
+
`staging` stays false; `branch: null` is already what refuses it, and widening the flag would make
|
|
347
|
+
the refusal say something untrue.
|
|
348
|
+
|
|
349
|
+
`x db backfill` has four shapes and a **dry run is the default**: `--list` reports the ledger,
|
|
350
|
+
`--pending` reports declared-minus-completed and exits non-zero when there is drift, `<name>` plans
|
|
351
|
+
one sweep, and `--all` plans every pending one. `--write` is never implied — the inspection forms
|
|
352
|
+
and the acting form are the same command, and the flag is the only thing that separates them.
|
|
353
|
+
`--all --write` isolates per name and continues past a failure, exiting non-zero naming each, so one
|
|
354
|
+
wedged cleanup cannot block every later one forever.
|
|
355
|
+
|
|
356
|
+
Until 1.2.0 a bare `x db backfill <name>` threw `X_NOT_IMPLEMENTED`, and the ledger was the only
|
|
357
|
+
half that existed: `x_backfills` recorded what had run, and **nothing recorded what was pending**, so
|
|
358
|
+
a scaffolded backfill could be merged and deployed and silently never run. `--pending` is the alarm
|
|
359
|
+
that closes it; `registeredBackfills()` is what makes a declaration visible before its first pass.
|
|
360
|
+
|
|
361
|
+
`x db migrate` and `ROLE=migrate` are the same function call. That is the whole design: until
|
|
362
|
+
1.2.0 the CLI shelled out to `bunx drizzle-kit` — a second engine, a second journal, declared in no
|
|
363
|
+
`package.json` and fetched unpinned at run time — while the release phase used the framework's
|
|
364
|
+
ledger, so "what has been applied" had two answers that only agreed by luck. `cmd-db.test.ts`
|
|
365
|
+
holds the line from both ends: no shipped source spawns a second migrator, and this file still
|
|
366
|
+
imports `runMigrations` from `./serve`.
|
|
367
|
+
|
|
368
|
+
**The post-condition is one check too, and it is the database one.** `runMigrations` runs
|
|
369
|
+
`@ultimat3/db`'s `checkDrift()` inside the queue's lifetime — the connection it opened for the
|
|
370
|
+
migrator is the only one there is — and returns the report on `MigratedApp.drift`, so a developer
|
|
371
|
+
and a release phase verify the same thing. `x db migrate` renders it through `driftFindings` and
|
|
372
|
+
exits non-zero; `runRole` throws the first difference for `ROLE=migrate`, so the release phase
|
|
373
|
+
exits non-zero too. Both entrypoints call the same `runMigrations` and both fail — the difference
|
|
374
|
+
is only the channel each has. `ROLE=migrate` logged and exited 0 until it did not: a release phase
|
|
375
|
+
whose only signal is the exit code reported success over a schema nobody can reconstruct, which is
|
|
376
|
+
the failure the post-migrate check exists to catch.
|
|
377
|
+
|
|
378
|
+
**The `drift` step asks a third thing, off the same directory and with no database either: is every
|
|
379
|
+
destructive statement declared?** `db-destructive.ts` reads each committed migration through
|
|
380
|
+
`migrations.ts` — the reader `x db migrate` applies from, because a rail checking a list the
|
|
381
|
+
migrator does not run enforces nothing — and refuses an `up` that drops a table, drops a column,
|
|
382
|
+
truncates or retypes without a `-- destructive: true` line, as `X_MIGRATION_DESTRUCTIVE`. It decides
|
|
383
|
+
none of that itself: `@ultimat3/db`'s `destructive.ts` owns the classifier `db-generate.ts` already
|
|
384
|
+
wrote the marker from, so the generator and the gate cannot disagree about one file. One finding per
|
|
385
|
+
file, never one per statement — the marker declares the whole migration. It rides on `drift` rather
|
|
386
|
+
than becoming an eighteenth step because it is this step's own question over this step's own files;
|
|
387
|
+
a new step is for a genuinely new question.
|
|
388
|
+
|
|
389
|
+
The *source* half is a different question with a different answer: `checkSourceDrift` hashes the
|
|
390
|
+
entity source against what `x db gen` recorded, answers the same before and after a migration, and
|
|
391
|
+
opens nothing — which is what lets the gate run it in a CI with no database. It stays on `x verify`
|
|
392
|
+
and `x doctor` and is deliberately **not** repeated on `x db migrate`; two reporters of one
|
|
393
|
+
condition is the duplication this package's own rule forbids. Both were called `checkDrift` until
|
|
394
|
+
1.2.0, and the one that was wired everywhere was the one that cannot see a column added by hand.
|
|
395
|
+
|
|
396
|
+
Generation opens no database. It diffs `describeEntities()` against `declaredSchema(readMigrations(root))`
|
|
397
|
+
— the snapshot the newest migration wrote down — so `x db gen` answers the same in CI, on a laptop
|
|
398
|
+
with nothing running, and against a database three migrations behind. An app whose modules will not
|
|
399
|
+
load generates **nothing**: a short registry is indistinguishable from deleted entities, and the
|
|
400
|
+
diff would be a DROP nobody asked for.
|
|
401
|
+
|
|
402
|
+
One migration is one file, split by a lone `-- down` line. `<id>.down.sql` is a pre-1.2.0
|
|
403
|
+
hand-written layout and `readMigrations` skips it — read as a migration it sorts next to its own
|
|
404
|
+
`up` and drops every table the pair exists to reverse.
|
|
405
|
+
|
|
406
|
+
## `x dev` boots the app; it does not simulate one
|
|
407
|
+
|
|
408
|
+
| File | Job |
|
|
409
|
+
|---|---|
|
|
410
|
+
| `api-routes.ts` | the app's API over HTTP: every registered action AND every registered query |
|
|
411
|
+
| `dev-services.ts` | resolve which service each binding points at — embedded or external |
|
|
412
|
+
| `dev-queue.ts` | the db + queue pair alone, and the one place that takes every ambient accessor back |
|
|
413
|
+
| `dev-runtime.ts` | start the rest on top of it and install the remaining accessors (storage, mail, transport) |
|
|
414
|
+
| `dev-cache.ts` | which cache tiers this process reads through, and the cross-instance invalidation hop |
|
|
415
|
+
| `dev-sync.ts` | the `sync` role: its live-query registry, who is dialling it, and the socket it owns |
|
|
416
|
+
| `runtime-overrides.ts` | the one field a host hands the framework a driver through |
|
|
417
|
+
| `sync-authenticator.ts` | the app's HTTP authenticator, seen as the sync node's |
|
|
418
|
+
| `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
|
|
419
|
+
| `dev-render.ts` | one HTTP route per registered `route`, through render's own mode function |
|
|
420
|
+
| `style-csp.ts` | the `style-src` sha256 of every inline `<style>` the web role serves |
|
|
421
|
+
| `dev-assets.ts` | the image pipeline's only HTTP surface: `/icons/*` and `/media/*` |
|
|
422
|
+
| `dev-hooks.ts` | the pipeline's `authorize` seam, decided from the app's own `Policy` objects |
|
|
423
|
+
| `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` |
|
|
424
|
+
| `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels |
|
|
425
|
+
| `dev-traces.ts` | core's spans → the `/_x` timeline's request traces |
|
|
426
|
+
| `dev-n-plus-one.ts` | statement shapes counted per request, and the ones that repeat past the threshold |
|
|
427
|
+
| `statement-loop.ts` | one verdict → the finding, the panel fact, the overlay notice and the log line |
|
|
428
|
+
| `dev-policy.ts` | which actors to ask about, and which capability each policy gates |
|
|
429
|
+
| `cmd-dev.ts` | boot order, mounting `/_x`, installing the span exporter, the file watcher |
|
|
430
|
+
| `mcp-host.ts` | the `DevCapabilities` half of `@ultimat3/mcp`'s `DevHost` — db, tests, logs, verify |
|
|
431
|
+
| `mcp-db-target.ts` | which database the host is pointed at: whether it is a branch, and whether it is production |
|
|
432
|
+
| `mcp-errors.ts` | `errors.explain`: one runnable command per code, typed over `CliErrorCode` |
|
|
433
|
+
| `error-catalog.ts` | imports every `@ultimat3/*` package so `x errors` answers for codes no command loads |
|
|
434
|
+
| `mcp-test-output.ts` | reading `bun test`'s own summary back into a `TestRun` |
|
|
435
|
+
| `cmd-mcp.ts` | `x mcp serve`: the two transports, and the local developer's caller |
|
|
436
|
+
|
|
437
|
+
`api-routes.ts` is the app's own API surface, composed **once** and mounted by both `cmd-dev.ts`
|
|
438
|
+
and `serve.ts`: `listActions().map(toRoute)` from `@ultimat3/action` plus
|
|
439
|
+
`listQueries().map(toQueryRoute)` from `@ultimat3/query`. Two lists is how `query.client()`
|
|
440
|
+
shipped deriving `/_x/query/<kebab>` against a route neither file mounted — a typed read that
|
|
441
|
+
compiled everywhere and 404'd everywhere — and a surface that answers in `x dev` and not in the
|
|
442
|
+
container is the same failure one release later. It reads the registries at call time, never at
|
|
443
|
+
import: importing the app IS the registration, and it happens after this module loads.
|
|
444
|
+
|
|
445
|
+
`startWeb` warns when the route table declares `auth: 'required'` and the app configured no
|
|
446
|
+
authenticator: `hooks.authenticate` is the only place an actor can come from, so such a process
|
|
447
|
+
boots clean, reports healthy, and refuses every valid session. A warning and not a throw, because
|
|
448
|
+
`x new` scaffolds guarded routes before it scaffolds an authenticator.
|
|
449
|
+
|
|
450
|
+
The roles live in `@ultimat3/core` (`ROLES`, `isRole`), never in a second list here. A dev-only
|
|
451
|
+
driver, a dev-only authorizer or a dev-only queue is the bug this design exists to prevent — the
|
|
452
|
+
only thing dev changes is which driver is behind an interface.
|
|
453
|
+
|
|
454
|
+
### `RuntimeOverrides` is the only way to hand the framework a driver
|
|
455
|
+
|
|
456
|
+
`ServeOptions` was `{ root, env, role?, port?, metricsPort? }`, so the ONLY way an app could
|
|
457
|
+
install a driver was an ambient setter at module scope — and `loadApp` imports the app's modules
|
|
458
|
+
*after* `startServices` has captured its own. The slot moved and the capture did not: every
|
|
459
|
+
`handle.enqueue()` went to the app's queue while the worker claimed from Postgres, and `/_x` read
|
|
460
|
+
the ambient one, so the dashboard agreed with the enqueue side and disagreed with reality.
|
|
461
|
+
|
|
462
|
+
Every field REPLACES the env-selected default rather than sitting beside it — `overrides?.x ?? <the
|
|
463
|
+
env switch>`, one expression, one answer (axiom 1). A field nothing consumes is not there: the
|
|
464
|
+
entity `Driver` in particular, because `@ultimat3/entity` exposes no installer for one
|
|
465
|
+
(`database(entities, { driver })` is the app's own call), and a slot the boot cannot honour is the
|
|
466
|
+
class of defect this seam exists to end.
|
|
467
|
+
|
|
468
|
+
**The split is refused, not reconciled.** `assertOneJobDriver` runs first in `startRoles` and
|
|
469
|
+
throws `X_RUNTIME_DRIVER_SPLIT` when `jobDriver()` is not the object this process serves. Reading
|
|
470
|
+
through the accessor instead would make the split invisible rather than impossible — and the app
|
|
471
|
+
would still have installed a driver the boot never saw, with no outbox store bound to it and no
|
|
472
|
+
relay draining it.
|
|
473
|
+
|
|
474
|
+
### What the boot now calls that nothing called before
|
|
475
|
+
|
|
476
|
+
| Mechanism | Where | Was |
|
|
477
|
+
|---|---|---|
|
|
478
|
+
| the transactional outbox | `dev-queue.ts` installs the store + facade, `worker` runs the relay | staged rows nothing published |
|
|
479
|
+
| the durable scheduler | `pgSchedulerState` + `createPgLeaseLeader` in `startRoles` | a watermark forgotten on restart, and every replica its own leader |
|
|
480
|
+
| the Postgres event bus | `dev-queue.ts` | `step.waitForEvent` forgot every correlation on restart |
|
|
481
|
+
| the shared idempotency store | `dev-queue.ts` | a retry on another replica charged the card twice |
|
|
482
|
+
| the cache tiers | `dev-cache.ts` | only the CDN tier was registered; memo, LRU and Redis had zero callers |
|
|
483
|
+
| WebSocket authentication | `dev-sync.ts` | `actorId: null` on every socket — realtime was single-tenant by wiring |
|
|
484
|
+
| OTLP export | `otlp-export.ts` | the chart set the variable and no code read it |
|
|
485
|
+
|
|
486
|
+
`createPgLeaseLeader`, never `createPgLeader`: the latter's `pg_try_advisory_lock` is
|
|
487
|
+
session-scoped and the grant dies when the connection returns to the pool, so every node reads
|
|
488
|
+
itself as leader and a rolling update double-fires every task.
|
|
489
|
+
|
|
490
|
+
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.
|
|
493
|
+
|
|
494
|
+
`SQL_IDEMPOTENCY_TABLE` is applied beside `SQL_JOBS_TABLE`, and the store is installed by the boot
|
|
495
|
+
rather than by the app, even though `@ultimat3/action` documents
|
|
496
|
+
`postgresIdempotencyStore({ executor: Bun.sql })`: **`Bun.sql` has no `.query(text, values)`** — it
|
|
497
|
+
is a tagged template whose positional form is `unsafe` — so that line does not satisfy `PgExecutor`,
|
|
498
|
+
and a second executor would open a second pool against a URL this boot already resolved. The app
|
|
499
|
+
owes only the declaration, `configureIdempotency({ scope: 'shared' })`, which `x new` names in
|
|
500
|
+
`apps/web/server.ts`.
|
|
501
|
+
|
|
502
|
+
The per-TENANT subscription cap is deliberately unset, and **both halves of it are**:
|
|
503
|
+
`assertCapacity` returns early unless `maxPerTenant` AND `tenantOf` are given, so passing one arms
|
|
504
|
+
nothing — and no default is defensible when one tenant is a person and the next is five thousand
|
|
505
|
+
seats. The per-socket 128 stands because a socket is one browser tab.
|
|
506
|
+
|
|
507
|
+
`trustProxy` is read from `TRUSTED_PROXY_HOPS` in `startWeb`, the way `PORT` and `ROLE` are read: it
|
|
508
|
+
is a fact about the deployment, not an app config choice, and one image runs behind an ingress in
|
|
509
|
+
one cluster and behind nothing on a laptop. Without it `ctx.ip` is the ingress's socket address on
|
|
510
|
+
every request, so the limiter keys the whole fleet's anonymous traffic into one bucket.
|
|
511
|
+
|
|
512
|
+
### `island-bundle.ts` is the bundler half of `hydrate`
|
|
513
|
+
|
|
514
|
+
`@ultimat3/render` shipped `island()`, the collector, `emitIslandAttributes`, `hydrateRuntime`,
|
|
515
|
+
`RouteEntry.islands` and `routeJsBytes` — and **nothing constructed or populated any of them**.
|
|
516
|
+
`hydrate` was a documented capability with no implementation, to the point that `render-static.ts`
|
|
517
|
+
told authors to "move the request-dependent part into an island", naming a mechanism the framework
|
|
518
|
+
could not express. This package is the half that can see a file on disk, so it is the half that was
|
|
519
|
+
missing.
|
|
520
|
+
|
|
521
|
+
| File | Job |
|
|
522
|
+
|---|---|
|
|
523
|
+
| `island-bundle.ts` | discover `*.island.tsx`, build each as its own entry point, hash it, resolve a page's specifier to its URL |
|
|
524
|
+
| `island-routes.ts` | serve those chunks, at `ISLAND_BASE_PATH`, immutable |
|
|
525
|
+
| `dev-render.ts` | one collector **per render**, and `hydrateRuntime` after the body |
|
|
526
|
+
| `prerender.ts` | build first, write the chunks into the export, then measure |
|
|
527
|
+
| `budgets.ts` | `measureDocumentJs` weighs `data-x-entry` as well as `<script src>` |
|
|
528
|
+
|
|
529
|
+
**One `Bun.build` per island, never one call with N entry points**, and `splitting: false`. The
|
|
530
|
+
island's `src` is a string, so no import edge reaches it and the page's graph stays the page's
|
|
531
|
+
(axiom 6) — a shared chunk would put that number back behind a graph walk, and the budget compares
|
|
532
|
+
against bytes. Two islands that both import the same helper each carry a copy; that is the honest
|
|
533
|
+
number for what booting either one costs.
|
|
534
|
+
|
|
535
|
+
**The chunk URL is content-addressed with render's own `contentHash`** — the function that already
|
|
536
|
+
stamps an ETag and a precache revision. One identity for a byte string, not a third.
|
|
537
|
+
|
|
538
|
+
**`x dev`, the container and the static export all mount the same table.** `serve.ts` builds the
|
|
539
|
+
islands at boot for the same reason it mounts `apiRoutes()`: a seam that works in dev and not in the
|
|
540
|
+
image is the same failure one release later. `x dev` rebuilds them on the watcher tick, and that is
|
|
541
|
+
the one reload that actually takes effect — an island is the single module this process never
|
|
542
|
+
imports, so there is no Bun module cache to invalidate.
|
|
543
|
+
|
|
544
|
+
**`app-load.ts` skips `*.island.tsx` deliberately.** It registers no primitive, and importing it
|
|
545
|
+
would put the one module guaranteed to be outside the server's graph inside this process's, where a
|
|
546
|
+
top-level `document` reference takes the whole scan down.
|
|
547
|
+
|
|
548
|
+
**The budget is charged from the emitted document, and it names the island.** An island's chunk is
|
|
549
|
+
reached by `import()` from inside the hydration runtime, so it is never a `<script src>` — weighing
|
|
550
|
+
script tags alone charged a page for the runtime and never for the code that runtime boots.
|
|
551
|
+
`measureDocumentJs` reads `data-x-entry` as what it is, dedupes it (two instances of one island are
|
|
552
|
+
one module), and `prerenderSite` maps the heaviest URL back through the bundle so
|
|
553
|
+
`X_BUDGET_EXCEEDED` names `apps/web/site/pricing/calculator.island.tsx` and not a hash.
|
|
554
|
+
|
|
555
|
+
`X_ISLAND_INVALID` is **borrowed** from `@ultimat3/render`, not twinned: "this src cannot become a
|
|
556
|
+
client entry" is what that code already means, and the bundler is simply the half that can see
|
|
557
|
+
whether the file exists. A failed compile is `X_BUILD_FAILED` — an island is a bundle entry point
|
|
558
|
+
like any other, and `Bun.build` *rejects* rather than answering `success: false`, so the catch is
|
|
559
|
+
the real path.
|
|
560
|
+
|
|
561
|
+
### `dev-assets.ts` is where the image pipeline meets HTTP
|
|
562
|
+
|
|
563
|
+
Three packages declare what an image is and none of them serves one: `@ultimat3/seo` says what a
|
|
564
|
+
variant URL means (`parseImageQuery`) and produces the bytes (`builtinImageDriver`),
|
|
565
|
+
`@ultimat3/storage` says what a variant is called and where it is cached (`variantKey`), and
|
|
566
|
+
`@ultimat3/pwa` says which icons a web manifest promises (`planIcons`, `BuiltinImagePipeline`).
|
|
567
|
+
Pixels are `@ultimat3/core`'s pipeline, only ever. This file picks two base paths — `ICON_BASE_PATH`
|
|
568
|
+
and `MEDIA_BASE_PATH` — and decides nothing else; a resize, a format table or a second cache key
|
|
569
|
+
here is the drift the split exists to prevent.
|
|
570
|
+
|
|
571
|
+
**`/media` and `/_storage` are one authz decision, not two.** Both serve objects off the app's only
|
|
572
|
+
disk, so `/media/*key` declares what `dev-storage.ts` declares — `auth: 'required'` +
|
|
573
|
+
`STORAGE_READ_PERMISSION` + `enforcedBy: 'handler'` — and calls the same two functions, in the same
|
|
574
|
+
order: `authorizeStorageRead` then `assertReadableKey`. It shipped `auth: 'public'` with no policy
|
|
575
|
+
and no tenant check while its twin required both, which made every tenant's uploads one URL away in
|
|
576
|
+
production (`serve.ts` mounts it), and `?w=` made it an unauthenticated `put` besides. The tenant
|
|
577
|
+
test lives in ONE function both routes call, and `storage-surfaces.test.ts` pins the pair against
|
|
578
|
+
each other — every case names the verdict absolutely as well as comparing the two, because equality
|
|
579
|
+
alone is satisfied by both surfaces failing open together. Cacheability follows the key, not the
|
|
580
|
+
route: a tenant-scoped key takes `AUTHORIZED_OBJECT_CACHE` (`private, max-age=0`, varying on
|
|
581
|
+
`authorization`/`cookie`), and only a key no tenant owns keeps `immutable`. A genuinely public image
|
|
582
|
+
belongs under `apps/web/site/`, which is a static asset and never touches that disk.
|
|
583
|
+
|
|
584
|
+
`ICON_SOURCE` lives here, not in `cmd-doctor.ts`, because this is the module that reads it: the
|
|
585
|
+
diagnostic checks what `x dev` serves, so one constant cannot pass the check and serve nothing.
|
|
586
|
+
It is a **PNG** — core decodes PNG and JPEG only, and the SVG this used to name could never
|
|
587
|
+
become an icon.
|
|
588
|
+
|
|
589
|
+
The routes mount whether or not the source icon exists, and a missing one is refused with
|
|
590
|
+
`X_PWA_ICON_MISSING` and its fix — a route that silently disappears is a 404 whose meaning an agent
|
|
591
|
+
has to guess. Deliberately **not** also a boot finding: `x doctor` already reports this condition,
|
|
592
|
+
with this code, and two reporters of one condition is the duplication this package's own rule
|
|
593
|
+
forbids. `x dev` owns the runtime half; the diagnostic owns the other.
|
|
594
|
+
|
|
595
|
+
### `hold.ts` is why a long-running command outlives its own result
|
|
596
|
+
|
|
597
|
+
`dispatch` renders a `CommandResult` and `bin.ts` exits on the code — so a command whose server is
|
|
598
|
+
still listening when `run` resolves is a command the exit code takes down, between the line that
|
|
599
|
+
announced the url and the first request to it. `x dev` and `x mcp serve --transport http` both did.
|
|
600
|
+
|
|
601
|
+
The one answer is `CommandResult.hold`: report first, then `dispatch` awaits the hold before the
|
|
602
|
+
exit code. `holdUntilShutdown` installs core's signal handlers (`installSignalHandlers` — until
|
|
603
|
+
this it had no callers anywhere, which is why `cmd-mcp.ts`'s `onShutdown` registration was never
|
|
604
|
+
reached), waits on the **drain's first phase** rather than on a signal list of its own, and
|
|
605
|
+
releases what core's lifecycle never learned about — the embedded Postgres, the worker, the
|
|
606
|
+
watcher — *after* the drain, so an in-flight request still has the database it opened against.
|
|
607
|
+
Ctrl-C is therefore the same three phases production runs, not a kill that leaves `.x/pgdata`
|
|
608
|
+
locked by a process that no longer exists.
|
|
609
|
+
|
|
610
|
+
Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.
|
|
611
|
+
|
|
612
|
+
## Planned commands are commands
|
|
613
|
+
|
|
614
|
+
Every command in `wiki/CLI-Reference.md`'s planned table is in the registry, built from
|
|
615
|
+
`PLANNED_COMMANDS` in `cmd-planned.ts`, and exits `X_NOT_IMPLEMENTED` with a `fix:` naming the
|
|
616
|
+
closest **shipped** command. `X_CLI_UNKNOWN_COMMAND` would say "you typed something that does not
|
|
617
|
+
exist", which is false and sends an agent hunting a typo. `cmd-planned.test.ts` enforces both
|
|
618
|
+
halves: every row is reachable through the parser, and no `fix` points at another planned command.
|
|
619
|
+
|
|
620
|
+
`PLANNED_SUBCOMMANDS` is the same promise one level down, and `x db studio` is its only entry.
|
|
621
|
+
A subcommand stays in its command's `subcommands` list — the parser reaches it, `x help db` lists
|
|
622
|
+
it — and the owning `run` does `throw plannedSubcommand('db', 'studio')`. Dropping it from the list
|
|
623
|
+
instead would answer `X_CLI_UNKNOWN_SUBCOMMAND`, which is the same lie the table above closes.
|
|
624
|
+
|
|
625
|
+
## `guards/` is how an app makes its own convention a build error
|
|
626
|
+
|
|
627
|
+
Axiom 3 says a convention that is not a build error does not exist, and until 1.2.0 the framework
|
|
628
|
+
gave an app no way to create one: `VERIFY_STEP_NAMES` is a closed literal list with no extension
|
|
629
|
+
point. A file in `guards/` closes it.
|
|
630
|
+
|
|
631
|
+
| File | Job |
|
|
632
|
+
|---|---|
|
|
633
|
+
| `guards.ts` | what a guard IS, how the directory is read, and what a guard is held to |
|
|
634
|
+
| `templates/guard.ts` | `x g guard <name>` — the emitted rule, its pure half and its test |
|
|
635
|
+
| `cmd-verify.ts` | one line in the `boundaries` step: `guardFindings(ctx.root)` |
|
|
636
|
+
|
|
637
|
+
**It rides on `boundaries`, and it is not an eighteenth step.** The `HostCheck` contract already
|
|
638
|
+
says the shape — *a host adds findings to a step; it can never add, remove, reorder or skip one* —
|
|
639
|
+
so "green" keeps meaning exactly what it meant, whatever an app writes. `boundaries` is the step
|
|
640
|
+
whose host slot already carries "rules this repo makes about itself that the framework cannot
|
|
641
|
+
know" (the monorepo's tier table arrives through it), and it runs third, before any suite, so a
|
|
642
|
+
convention failure comes back in seconds. `guardFindings` is *typed* as a `HostCheck` and is
|
|
643
|
+
composed by the step rather than registered as one: the slot is `Partial<Record<VerifyStepName,
|
|
644
|
+
HostCheck>>`, one function per step, so an app registering there would evict the framework's own
|
|
645
|
+
tier check — and `verifyCommand.run` passes no `hostChecks` at all, which is why an app-supplied
|
|
646
|
+
check could not have reached the gate through that field in the first place.
|
|
647
|
+
|
|
648
|
+
**Discovered, never registered.** `guards/*.ts`, minus `*.test.ts`, sorted. Nothing imports a
|
|
649
|
+
guard, nothing lists one, and there is no `defineGuard` to call — a guard that has to announce
|
|
650
|
+
itself is a guard an app can forget to announce, which is the coupling axiom 8's extension model
|
|
651
|
+
rejects. A `*.test.ts` beside a guard is its test: importing it would run a suite inside the gate.
|
|
652
|
+
|
|
653
|
+
**A guard returns `Finding[]`, so it inherits everything.** `--json`, the step table, the summary
|
|
654
|
+
counts and the exit code are all projections of what it returns (axiom 2); a guard that printed or
|
|
655
|
+
chose an exit code would be a second gate. It never throws for a normal result — a throw is
|
|
656
|
+
`X_GUARD_FAILED`, reported as a finding rather than taking the run down.
|
|
657
|
+
|
|
658
|
+
**And what it returns is held to the error contract.** `findingProblem` demands an
|
|
659
|
+
`X_SCREAMING_SNAKE` code, a non-empty cause, and a `fix:` that passes `fixProblem` — the *same*
|
|
660
|
+
rule `x verify`'s `errors` step applies to every shipped `fix:` in this repo. It runs on the
|
|
661
|
+
returned value, which is the half a static scan cannot reach: a `fix` assembled at run time has no
|
|
662
|
+
literal to read. Three codes, one per way a guard can fail to be one — `X_GUARD_INVALID` (no
|
|
663
|
+
usable export), `X_GUARD_FAILED` (it threw), `X_GUARD_FINDING_INVALID` (what it returned is not a
|
|
664
|
+
finding). Anything else about a guard is the app's business: no size ceiling, no budget, no rule
|
|
665
|
+
about what it may check.
|
|
666
|
+
|
|
667
|
+
**The validator may never be the thing that crashes.** `findingProblem` names an offending value
|
|
668
|
+
through `shown()` and not `JSON.stringify` — which refuses a BigInt — and every candidate is read
|
|
669
|
+
inside a `try`, because reading one can throw on its own (a getter that raises, a proxy that
|
|
670
|
+
refuses). A guard returning `[1n]` is `X_GUARD_FINDING_INVALID`, per candidate, so one unreadable
|
|
671
|
+
entry costs its own line and not the real findings beside it. The mechanism whose job is producing
|
|
672
|
+
structured failures handing back a stack trace is the one outcome it exists to prevent.
|
|
673
|
+
|
|
674
|
+
`x g guard <name>` writes `guards/<name>.ts` and its test, and nothing else — no index, no
|
|
675
|
+
registry row, no manifest entry. The emitted rule is the class of failure a guard exists for: a
|
|
676
|
+
migration that adds a `NOT NULL` column with no `DEFAULT` applies cleanly to an empty local
|
|
677
|
+
database and fails on the first production table that already holds rows. The `drift` step reads
|
|
678
|
+
those same files and asks a different question, and a test suite runs against a database the
|
|
679
|
+
statement has never met — which is exactly when an app needs a rule of its own. Its code is
|
|
680
|
+
DERIVED from the guard's name (`guardCode`), never written as a literal: an `X_*` literal in
|
|
681
|
+
framework source is a framework code and `error-catalog.test.ts` requires it to be registered.
|
|
682
|
+
|
|
683
|
+
That rule is held to a real bar, because it is the worked example every app starts from and a
|
|
684
|
+
demonstration that is wrong on realistic input teaches the wrong shape. Block comments are
|
|
685
|
+
stripped before line comments and both before statements are split, so a commented-out
|
|
686
|
+
`ALTER TABLE` is a note and not a finding that blocks `x verify` over nothing; and `DEFAULT NULL`
|
|
687
|
+
counts as **no** default, because it is one in syntax and none in effect — every existing row still
|
|
688
|
+
takes NULL and still violates `NOT NULL`. Both cases are in the emitted test, which is what proves
|
|
689
|
+
an app's copy still works, and both run through the real seam in `guards.test.ts`.
|
|
690
|
+
|
|
691
|
+
It is in `FIXTURE_GENERATORS` like the other two, and it is the only generated file that imports
|
|
692
|
+
`@ultimat3/cli` for its types — so the scaffold gate compiling it is what proves a scaffolded app
|
|
693
|
+
can write one at all. The root `tsconfig.json` `x new` scaffolds has no `include`, so `guards/` is
|
|
694
|
+
typechecked there by default; an app whose tsconfig names an explicit `include` list has to add
|
|
695
|
+
`guards/**/*` to it, or its guards compile nowhere.
|
|
696
|
+
|
|
697
|
+
## Two generators that scaffold something other than a primitive
|
|
698
|
+
|
|
699
|
+
`x g island <name> [--at <dir>]` writes a **client entry point**, not a component: the filename is
|
|
700
|
+
how the bundler discovers it and `mount` is how the hydration runtime calls it, so those two are
|
|
701
|
+
what `templates/island.test.ts` pins and everything else in the file is example code. `--at` takes
|
|
702
|
+
the directory directly rather than deriving one, because the caller that cannot guess is
|
|
703
|
+
`X_ISLAND_INVALID` — its cause already holds the exact path a page's `src` resolved to, so its
|
|
704
|
+
`fix:` hands that path straight back.
|
|
705
|
+
|
|
706
|
+
`x g admin:page <name> --permission <perm> [--at <dir>]` writes an ordinary TSX component and **no
|
|
707
|
+
`defineRoute` call**, deliberately. `@ultimat3/admin`'s `pages:` is the one thing that puts a page in the route
|
|
708
|
+
table and `guardedPage()` is the one thing that decides it; a generator that emitted a route
|
|
709
|
+
declaration would hand back the unguarded second way in that seam exists to close. The emitted test
|
|
710
|
+
asserts the absence. `--permission` defaults to `<name>:read` rather than to nothing, because an
|
|
711
|
+
empty permission list is `X_ADMIN_PAGE_UNGUARDED` at declaration time. `--at` is the same flag
|
|
712
|
+
`x g island` takes and for the same reason — an app's admin is wherever its `defineAdmin` is, which
|
|
713
|
+
no generator can derive, and the hardcoded `apps/admin/src/pages` sent every other layout (the
|
|
714
|
+
demo's is `apps/admin/app/admin`) to `git mv` after every run.
|
|
715
|
+
|
|
716
|
+
Both are in `FIXTURE_GENERATORS`, so both are compiled by the scaffold gate.
|
|
717
|
+
|
|
718
|
+
Implementing one means deleting its row and adding a real `cmd-<name>.ts` — the summary's
|
|
719
|
+
`(planned)` suffix disappears with it, and `x help` follows automatically.
|
|
720
|
+
|
|
721
|
+
Adding a command: write `cmd-<name>.ts` exporting a `CliCommand`, register it in `registry.ts`,
|
|
722
|
+
add its message keys to `messages.ts`. Help and parsing derive from the spec automatically. A
|
|
723
|
+
command's `run` must be `async`: a synchronous throw escapes every caller that awaits the promise
|
|
724
|
+
the signature promises, `dispatch`'s own error path included.
|