@ultimat3/cli 6.0.0 → 8.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 +65 -5
- package/README.md +8 -3
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/app-boundaries.ts +55 -5
- package/src/bin.ts +6 -3
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +37 -3
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +359 -0
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +382 -0
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +47 -6
- package/src/dev-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +21 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +38 -1
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +76 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/registry.ts +8 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +360 -0
- package/src/static-report.ts +219 -0
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +4 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +130 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +15 -2
- package/src/templates/scaffold-app.ts +13 -78
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +46 -7
- package/src/templates/scaffold-docs.ts +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +37 -6
- package/src/test-select.ts +4 -3
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/workspace-graph.ts +241 -0
- package/src/write-line.ts +23 -5
package/CLAUDE.md
CHANGED
|
@@ -6,10 +6,12 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
6
6
|
|---|---|
|
|
7
7
|
| Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only |
|
|
8
8
|
| stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
|
|
9
|
+
| stderr | `write-line.ts`'s `writeErrorLine` — the same loop on fd 2, for a line that is not the command's answer. A `CommandResult` declaring `stream: 'stderr'` is routed there by `dispatch.ts`'s `sinkFor`, and `x mcp serve --transport stdio` is the one case: its fd 1 carries JSON-RPC frames, so the `✓ mcp stdio serving 13 tools` line rendered after the loop was a malformed frame. Neither renderer carries `stream`, exactly like `hold` |
|
|
10
|
+
| Boot logs under `--json` | `dispatch.ts` calls core's `setLogStream('stderr')` when `args.json` is set, once, for all thirty commands. `x db migrate --json` printed the boot logger's `ultimate migrate applied` and then the command's own object, so `json.load` raised on the second document. A server's stdout stays its log stream; this is the CLI process only |
|
|
9
11
|
| 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
12
|
| 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
13
|
| 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
|
-
| 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
|
|
14
|
+
| 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>`. Both forms answer now: `--help` is read off the flag loop and `readSubcommand` is SKIPPED when it is set, so `x db --help`, `x mcp --help` and `x pr --help` print usage instead of exiting 1 with this same refusal — which is what they did on every command taking a subcommand until 2026-08 (`parse.test.ts` pins it across the shipped registry) |
|
|
13
15
|
| 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
16
|
| 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
17
|
| 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 19 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
|
|
@@ -67,6 +69,17 @@ and the `tsc -b` that proves it took. Private packages are exempt (a generated a
|
|
|
67
69
|
private), and a root that declares **no** `references` array is not judged at all: project
|
|
68
70
|
references are opt-in, and a scaffolded app builds through `extends` + `include`.
|
|
69
71
|
|
|
72
|
+
`workspace-graph.ts` is `package-shape`'s fifth rule: **every cross-workspace import is declared
|
|
73
|
+
in the importing workspace's own manifest**. Without it a scaffolded repo's dependency graph exists
|
|
74
|
+
only inside `tsc` — imports resolve through the root `tsconfig.json` `paths`, so affected-package
|
|
75
|
+
detection, `bun --filter` ordering and "what breaks if I change this" all read manifests and all
|
|
76
|
+
answer too small a set (issue #239, found in a real app where a change reaching five packages
|
|
77
|
+
reported one). `X_WORKSPACE_DEP_UNDECLARED` names the manifest and the exact line to add. Shipped
|
|
78
|
+
source only: a test file's import is not judged, because `packages/*` here declares no
|
|
79
|
+
`devDependencies` by design and the root's hoist is what resolves them. A manifest the scan cannot
|
|
80
|
+
read is its own finding rather than a silent skip — a skipped workspace is a hiding place for the
|
|
81
|
+
very edge the rule is looking for.
|
|
82
|
+
|
|
70
83
|
`app-agents-md.ts` is why the `manifest` step declares no `applies` at all. The drift half needs
|
|
71
84
|
a committed `x.manifest.json` to compare against, but `AGENTS.md` is required of every repo the
|
|
72
85
|
gate runs in — so the step always has a question to answer, and gating both halves on the file
|
|
@@ -139,6 +152,38 @@ emits a file `defineCatalogs` rejects at the app's first boot. `merge: 'json'` u
|
|
|
139
152
|
(`json-merge.ts`) for the same reason: `x new` and `x g resource` both contribute under `app`, and
|
|
140
153
|
a shallow spread keeps one of them.
|
|
141
154
|
|
|
155
|
+
## Three commands that reach outside the process, and none of them is a gate step
|
|
156
|
+
|
|
157
|
+
`x shot`, `x pr` and `x ci` exist because of the one line in the root `CLAUDE.md` that shapes this
|
|
158
|
+
whole package: **the primary developer is an AI agent.** An agent cannot open a browser, cannot look
|
|
159
|
+
at a running dev server and cannot read the GitHub web UI. It can read a file, and it can run a
|
|
160
|
+
command that prints. These three turn each of those into a file and a print.
|
|
161
|
+
|
|
162
|
+
They are also the only three commands that need something the process does not have — a browser, a
|
|
163
|
+
network, a GitHub token — which is why **none of them is a step of `x verify`**, and why that is not
|
|
164
|
+
an oversight to be corrected later. A gate that needs a browser goes red for reasons unrelated to the
|
|
165
|
+
change, and CI does not install one.
|
|
166
|
+
|
|
167
|
+
| | Reaches for | Never |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
|
|
170
|
+
| `x pr review\|resolve\|reply` | `gh api graphql`, through the injected `Runner` | `gh pr view --comments`, which shows *issue* comments and not the line-anchored threads that carry the findings |
|
|
171
|
+
| `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
|
|
172
|
+
|
|
173
|
+
**`verdict.json` names its own blind spots, and that is the design.** `x shot` reports what it could
|
|
174
|
+
not observe alongside what it did. A capture tool that silently omits what it cannot see is worse
|
|
175
|
+
than one that says so, because the omission reads as a clean result.
|
|
176
|
+
|
|
177
|
+
**`x shot` reuses a running `x dev` rather than booting a second one.** Embedded Postgres is
|
|
178
|
+
single-writer, so a second boot is `X_DEV_ALREADY_RUNNING` and no picture is ever taken. A reused
|
|
179
|
+
server's `stop()` deliberately does not clear the other process's lock.
|
|
180
|
+
|
|
181
|
+
**`gh` is invoked through `ctx.runner`, never `Bun.spawn` directly** — that is what lets every test
|
|
182
|
+
supply a reply table and assert the exact argv with no network and no `gh` installed. `GhOptions.fix`
|
|
183
|
+
is a **required** field, so shelling out to GitHub without stating a remedy is a type error rather
|
|
184
|
+
than a review comment. A GraphQL response is untrusted input and is parsed against a schema, never
|
|
185
|
+
cast: a `null` where an id was expected would otherwise become a mutation against `undefined`.
|
|
186
|
+
|
|
142
187
|
## The `errors` step enforces the error contract
|
|
143
188
|
|
|
144
189
|
| File | Job |
|
|
@@ -790,6 +835,18 @@ refuses). A guard returning `[1n]` is `X_GUARD_FINDING_INVALID`, per candidate,
|
|
|
790
835
|
entry costs its own line and not the real findings beside it. The mechanism whose job is producing
|
|
791
836
|
structured failures handing back a stack trace is the one outcome it exists to prevent.
|
|
792
837
|
|
|
838
|
+
**`x new` ships four guards, `As of 2026-08-22`.** The scaffolded `AGENTS.md` states nine
|
|
839
|
+
non-negotiables, and five of them used to be prose — each proven green on `x verify`: a hardcoded
|
|
840
|
+
JSX string beside a `t()` call, `color: #ff0000` in a stylesheet whose own scaffolded header called
|
|
841
|
+
it "a lint failure", `toLocaleDateString('en-US')` with no `timeZone`, `t.number` money, and a bare
|
|
842
|
+
`throw new Error` in a repo. Four of the five are now guards the scaffold writes —
|
|
843
|
+
`guard-raw-colour`, `guard-unzoned-date`, `guard-bare-error`, `guard-untranslated-string` — so the
|
|
844
|
+
rule is a build error the day the app is created rather than a sentence an agent may skip. The
|
|
845
|
+
fifth, money-as-float, has **no static signature**; the scaffolded `AGENTS.md` row now points at the
|
|
846
|
+
`MoneyInput` type error that already fires, because shipping a guard that cannot work is worse than
|
|
847
|
+
naming the mechanism that does. Their codes are app codes derived from the guard name, so none of
|
|
848
|
+
them appears in `wiki/Error-Codes.md` or the manifest.
|
|
849
|
+
|
|
793
850
|
`x g guard <name>` writes `guards/<name>.ts` and its test, and nothing else — no index, no
|
|
794
851
|
registry row, no manifest entry. The emitted rule is the class of failure a guard exists for: a
|
|
795
852
|
migration that adds a `NOT NULL` column with no `DEFAULT` applies cleanly to an empty local
|
|
@@ -816,10 +873,13 @@ typechecked there by default; an app whose tsconfig names an explicit `include`
|
|
|
816
873
|
## Two generators that scaffold something other than a primitive
|
|
817
874
|
|
|
818
875
|
`x g island <name> [--at <dir>]` writes a **client entry point**, not a component: the filename is
|
|
819
|
-
how the bundler discovers it and `mount` is how the hydration runtime calls it, so
|
|
820
|
-
what `templates/island.test.ts` pins
|
|
821
|
-
the
|
|
822
|
-
|
|
876
|
+
how the bundler discovers it and `mount` is how the hydration runtime calls it, so the filename,
|
|
877
|
+
the `mount` export and that `mount` RENDERS are what `templates/island.test.ts` pins — it builds
|
|
878
|
+
the emitted entry with `buildIslands` and drives it with `mountIsland`, so a template that
|
|
879
|
+
typechecks and does not mount is a failing test. It runs the mutation too, rather than describing
|
|
880
|
+
it: the same island with `{count()}` replaced by `{0}` must fail the assertion the live one passes.
|
|
881
|
+
`--at` takes the directory directly rather than deriving one, because the caller that cannot guess
|
|
882
|
+
is `X_ISLAND_INVALID` — its cause already holds the exact path a page's `src` resolved to, so its
|
|
823
883
|
`fix:` hands that path straight back.
|
|
824
884
|
|
|
825
885
|
`x g admin:page <name> --permission <perm> [--at <dir>]` writes an ordinary TSX component and **no
|
package/README.md
CHANGED
|
@@ -53,13 +53,18 @@ x verify --json
|
|
|
53
53
|
## `x verify` steps
|
|
54
54
|
|
|
55
55
|
`typecheck lint boundaries filesize package-shape errors unit contract live job e2e eval drift
|
|
56
|
-
contract-diff budgets manifest roadmap`
|
|
56
|
+
contract-diff budgets seo i18n manifest roadmap`
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
Nineteen, in cost order, defined once as `VERIFY_STEP_NAMES` (`verify-step.ts`) — the summary
|
|
59
59
|
count above is projected from that list, and the framework repo's own gate (`bun run verify`)
|
|
60
60
|
runs exactly it. A step with nothing to check here reports as skipped, never as
|
|
61
61
|
passed. Never bails early: an agent fixing three things needs all three findings from one run.
|
|
62
|
-
|
|
62
|
+
|
|
63
|
+
`--only <step>` runs one step, for an iteration loop — it prints `NOT A GATE RUN` in the human
|
|
64
|
+
summary **and** in `--json` (`data.notAGateRun`), and it writes no floor file. **The gate is this
|
|
65
|
+
command with no flag**, which is what "one command means shippable" means. There is no `--skip`:
|
|
66
|
+
a knob that removes a step from a run that still calls itself the gate is the one thing this
|
|
67
|
+
command must not offer. The exit code is non-zero if any step fails.
|
|
63
68
|
|
|
64
69
|
A committed `x.verify.json` is the floor, `As of 2026-08`: it names the steps this repo has already
|
|
65
70
|
proved it can run, and a step it names that reports nothing is `X_VERIFY_SUITE_VANISHED` rather
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,29 +37,30 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/cache": "
|
|
44
|
-
"@ultimat3/core": "
|
|
45
|
-
"@ultimat3/db": "
|
|
46
|
-
"@ultimat3/entity": "
|
|
47
|
-
"@ultimat3/http": "
|
|
48
|
-
"@ultimat3/i18n": "
|
|
49
|
-
"@ultimat3/jobs": "
|
|
50
|
-
"@ultimat3/mail": "
|
|
51
|
-
"@ultimat3/manifest": "
|
|
52
|
-
"@ultimat3/mcp": "
|
|
53
|
-
"@ultimat3/policy": "
|
|
54
|
-
"@ultimat3/pwa": "
|
|
55
|
-
"@ultimat3/query": "
|
|
56
|
-
"@ultimat3/realtime": "
|
|
57
|
-
"@ultimat3/render": "
|
|
58
|
-
"@ultimat3/schema": "
|
|
59
|
-
"@ultimat3/
|
|
60
|
-
"@ultimat3/
|
|
61
|
-
"@ultimat3/
|
|
62
|
-
"@ultimat3/
|
|
40
|
+
"@ultimat3/action": "8.0.0",
|
|
41
|
+
"@ultimat3/admin": "8.0.0",
|
|
42
|
+
"@ultimat3/ai": "8.0.0",
|
|
43
|
+
"@ultimat3/cache": "8.0.0",
|
|
44
|
+
"@ultimat3/core": "8.0.0",
|
|
45
|
+
"@ultimat3/db": "8.0.0",
|
|
46
|
+
"@ultimat3/entity": "8.0.0",
|
|
47
|
+
"@ultimat3/http": "8.0.0",
|
|
48
|
+
"@ultimat3/i18n": "8.0.0",
|
|
49
|
+
"@ultimat3/jobs": "8.0.0",
|
|
50
|
+
"@ultimat3/mail": "8.0.0",
|
|
51
|
+
"@ultimat3/manifest": "8.0.0",
|
|
52
|
+
"@ultimat3/mcp": "8.0.0",
|
|
53
|
+
"@ultimat3/policy": "8.0.0",
|
|
54
|
+
"@ultimat3/pwa": "8.0.0",
|
|
55
|
+
"@ultimat3/query": "8.0.0",
|
|
56
|
+
"@ultimat3/realtime": "8.0.0",
|
|
57
|
+
"@ultimat3/render": "8.0.0",
|
|
58
|
+
"@ultimat3/schema": "8.0.0",
|
|
59
|
+
"@ultimat3/scraping": "8.0.0",
|
|
60
|
+
"@ultimat3/seo": "8.0.0",
|
|
61
|
+
"@ultimat3/storage": "8.0.0",
|
|
62
|
+
"@ultimat3/testing": "8.0.0",
|
|
63
|
+
"@ultimat3/time": "8.0.0",
|
|
63
64
|
"babel-preset-solid": "^1.9.15"
|
|
64
65
|
}
|
|
65
66
|
}
|
package/src/affected.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// What a diff touches: the changed-file list read out of git, and the workspaces that list forces
|
|
2
|
+
// a re-test of — closed TRANSITIVELY over the workspace graph, because A → B → C means an edit in
|
|
3
|
+
// C breaks A and a single pass over an unordered list only ever reaches B.
|
|
4
|
+
//
|
|
5
|
+
// The diff defaults to a REF, never the working tree. Several agents share one checkout here (root
|
|
6
|
+
// `CLAUDE.md`, the "Note": no worktrees, "run them as a team in this same checkout"), so a
|
|
7
|
+
// working-tree diff returns every other agent's uncommitted work and the "affected" set silently
|
|
8
|
+
// widens to nearly the whole monorepo — the command stops narrowing anything and nobody can tell.
|
|
9
|
+
// A ref diff is stable under that concurrency; `--dirty` opts back in, which is right for one
|
|
10
|
+
// developer iterating alone and wrong as a default here.
|
|
11
|
+
|
|
12
|
+
// `join`/`relative` are `node:`-only by necessity: Bun exposes no path primitive, and a workspace
|
|
13
|
+
// directory is checkout-relative while a caller's scan yields paths relative to its own root.
|
|
14
|
+
import { join, relative } from 'node:path';
|
|
15
|
+
import { singleLine, UltimateError } from '@ultimat3/core';
|
|
16
|
+
import { docsFor } from './error-codes';
|
|
17
|
+
import { BadFlagError } from './errors';
|
|
18
|
+
import type { ExecResult, Runner } from './exec';
|
|
19
|
+
import { execOutput } from './exec';
|
|
20
|
+
import type { JsonValue } from './output';
|
|
21
|
+
import type { ParsedArgs } from './parse';
|
|
22
|
+
import { flagBool, flagString } from './parse';
|
|
23
|
+
import type { WorkspaceNode } from './workspace-graph';
|
|
24
|
+
import { readWorkspaceGraph } from './workspace-graph';
|
|
25
|
+
|
|
26
|
+
/** The branch a change is measured against when `--base` says nothing. */
|
|
27
|
+
export const DEFAULT_BASE = 'main';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Root files that belong to no workspace and change what every workspace compiles to: a compiler
|
|
31
|
+
* option, a lint rule, the root manifest, the resolved dependency tree, the test preload, the app
|
|
32
|
+
* config. A "scoped" run that skipped them reports green over packages the edit just broke.
|
|
33
|
+
*
|
|
34
|
+
* Matched on the WHOLE path — `packages/cli/package.json` is the cli workspace's own file and
|
|
35
|
+
* reaches only cli's dependents, `package.json` at the root reaches everything.
|
|
36
|
+
*/
|
|
37
|
+
export const ROOT_WIDE_FILES: readonly string[] = [
|
|
38
|
+
'app.config.ts',
|
|
39
|
+
'biome.json',
|
|
40
|
+
'bun.lock',
|
|
41
|
+
'bunfig.toml',
|
|
42
|
+
'package.json',
|
|
43
|
+
'tsconfig.json',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A file with no compilation unit behind it. A doc or a plan re-checks nothing, so it maps to no
|
|
48
|
+
* workspace at all rather than to the one it happens to sit inside — `packages/cli/README.md` is
|
|
49
|
+
* not a reason to run `packages/cli`'s tests.
|
|
50
|
+
*/
|
|
51
|
+
const isDoc = (path: string): boolean => path.endsWith('.md');
|
|
52
|
+
|
|
53
|
+
const owns = (node: WorkspaceNode, path: string): boolean =>
|
|
54
|
+
path === node.dir || path.startsWith(`${node.dir}/`);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The workspace a path belongs to, longest directory first: nested workspaces exist (a scaffolded
|
|
58
|
+
* app's `apps/web` inside its own root), and the shorter prefix would swallow the inner one.
|
|
59
|
+
*/
|
|
60
|
+
export function owningWorkspace(
|
|
61
|
+
graph: readonly WorkspaceNode[],
|
|
62
|
+
path: string,
|
|
63
|
+
): WorkspaceNode | undefined {
|
|
64
|
+
let best: WorkspaceNode | undefined;
|
|
65
|
+
for (const node of graph) {
|
|
66
|
+
if (!owns(node, path)) continue;
|
|
67
|
+
if (best === undefined || node.dir.length > best.dir.length) best = node;
|
|
68
|
+
}
|
|
69
|
+
return best;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every workspace that depends on one of `seeds`, however many edges away.
|
|
74
|
+
*
|
|
75
|
+
* A queue with a growing cursor, not one pass over `seeds`: with `A → B → C`, one pass answers
|
|
76
|
+
* `{ C, B }` and leaves A untested while the change that broke it is in the diff — a green
|
|
77
|
+
* checkmark on a broken repo, which is the whole reason this command exists rather than each agent
|
|
78
|
+
* inventing its own scoping. `reached` doubles as the cycle guard.
|
|
79
|
+
*/
|
|
80
|
+
function withDependents(
|
|
81
|
+
graph: readonly WorkspaceNode[],
|
|
82
|
+
seeds: ReadonlySet<string>,
|
|
83
|
+
): ReadonlySet<string> {
|
|
84
|
+
const dependents = new Map<string, string[]>();
|
|
85
|
+
for (const node of graph) {
|
|
86
|
+
for (const dependency of node.dependencies) {
|
|
87
|
+
const known = dependents.get(dependency);
|
|
88
|
+
if (known === undefined) dependents.set(dependency, [node.name]);
|
|
89
|
+
else known.push(node.name);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const reached = new Set(seeds);
|
|
93
|
+
const queue = [...reached];
|
|
94
|
+
for (let cursor = 0; cursor < queue.length; cursor += 1) {
|
|
95
|
+
const name = queue[cursor];
|
|
96
|
+
if (name === undefined) continue;
|
|
97
|
+
for (const dependent of dependents.get(name) ?? []) {
|
|
98
|
+
if (reached.has(dependent)) continue;
|
|
99
|
+
reached.add(dependent);
|
|
100
|
+
queue.push(dependent);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return reached;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface AffectedPlan {
|
|
107
|
+
/** Every path the diff reported, verbatim and in git's order. */
|
|
108
|
+
readonly changed: readonly string[];
|
|
109
|
+
/** The subset with no compilation unit behind it, named so an empty answer explains itself. */
|
|
110
|
+
readonly ignored: readonly string[];
|
|
111
|
+
/** The root files that forced every workspace in, empty when none did. */
|
|
112
|
+
readonly rootWide: readonly string[];
|
|
113
|
+
readonly workspaces: readonly WorkspaceNode[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const byName = (a: WorkspaceNode, b: WorkspaceNode): number => (a.name > b.name ? 1 : -1);
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Pure: the graph and the file list in, the plan out. No git, no disk — so the transitive rule is
|
|
120
|
+
* testable without a checkout whose state would decide the verdict.
|
|
121
|
+
*/
|
|
122
|
+
export function planAffected(
|
|
123
|
+
graph: readonly WorkspaceNode[],
|
|
124
|
+
changed: readonly string[],
|
|
125
|
+
): AffectedPlan {
|
|
126
|
+
const ignored = changed.filter(isDoc);
|
|
127
|
+
const considered = changed.filter((path) => !isDoc(path));
|
|
128
|
+
const rootWide = considered.filter((path) => ROOT_WIDE_FILES.includes(path));
|
|
129
|
+
if (rootWide.length > 0) {
|
|
130
|
+
return { changed, ignored, rootWide, workspaces: [...graph].sort(byName) };
|
|
131
|
+
}
|
|
132
|
+
const seeds = new Set<string>();
|
|
133
|
+
for (const path of considered) {
|
|
134
|
+
const node = owningWorkspace(graph, path);
|
|
135
|
+
if (node !== undefined) seeds.add(node.name);
|
|
136
|
+
}
|
|
137
|
+
const reached = withDependents(graph, seeds);
|
|
138
|
+
return {
|
|
139
|
+
changed,
|
|
140
|
+
ignored,
|
|
141
|
+
rootWide,
|
|
142
|
+
workspaces: graph.filter((node) => reached.has(node.name)).sort(byName),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* How the calling command spells this scoping, so a `fix:` re-runs the invocation that actually
|
|
148
|
+
* failed. `x test --base main` is refused by `x test` itself — without `--affected` the flag
|
|
149
|
+
* narrows nothing — so a fix line that dropped it would reproduce its own failure, verbatim.
|
|
150
|
+
*/
|
|
151
|
+
const invocationOf = (command: string): string =>
|
|
152
|
+
command === 'affected' ? 'x affected' : `x ${command} --affected`;
|
|
153
|
+
|
|
154
|
+
export interface AffectedSelection {
|
|
155
|
+
readonly base: string;
|
|
156
|
+
readonly dirty: boolean;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One reader for `--base` and `--dirty`, shared by `x affected` and `x test --affected`: two
|
|
161
|
+
* readers would be two answers to "what is this diff measured against", and the second command's
|
|
162
|
+
* scoping is only trustworthy if it is the first command's.
|
|
163
|
+
*/
|
|
164
|
+
export function readAffectedSelection(args: ParsedArgs, command: string): AffectedSelection {
|
|
165
|
+
const base = flagString(args, 'base') ?? DEFAULT_BASE;
|
|
166
|
+
if (base.trim().length === 0) {
|
|
167
|
+
throw new BadFlagError({
|
|
168
|
+
flag: 'base',
|
|
169
|
+
command,
|
|
170
|
+
reason: 'needs a git ref and got an empty value',
|
|
171
|
+
fix: `${invocationOf(command)} --base ${DEFAULT_BASE} --json`,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return { base, dirty: flagBool(args, 'dirty') };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const git = (runner: Runner, cwd: string, args: readonly string[]): Promise<ExecResult> =>
|
|
178
|
+
runner(['git', ...args], { cwd });
|
|
179
|
+
|
|
180
|
+
/** NUL-delimited, so a path holding a space, a quote or a newline survives the read intact. */
|
|
181
|
+
const paths = (stdout: string): readonly string[] =>
|
|
182
|
+
stdout.split('\0').filter((path) => path.length > 0);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The checkout's own root, which is what every path git prints is relative to — so it is also the
|
|
186
|
+
* root the workspace graph has to be read from, or a `packages/cli/...` path would be matched
|
|
187
|
+
* against dirs resolved somewhere else.
|
|
188
|
+
*/
|
|
189
|
+
export async function gitRoot(runner: Runner, cwd: string, command: string): Promise<string> {
|
|
190
|
+
const result = await git(runner, cwd, ['rev-parse', '--show-toplevel']);
|
|
191
|
+
if (result.ok) return result.stdout.trim();
|
|
192
|
+
throw new UltimateError({
|
|
193
|
+
code: 'X_CLI_UNEXPECTED',
|
|
194
|
+
cause: `x ${command} reads its diff from git and "git rev-parse --show-toplevel" exited ${result.code} in ${cwd}: ${singleLine(execOutput(result))}`,
|
|
195
|
+
fix: `run x ${command} from inside a git checkout — confirm with: git rev-parse --show-toplevel`,
|
|
196
|
+
docs: docsFor('X_CLI_UNEXPECTED'),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface ChangedFilesOptions {
|
|
201
|
+
readonly cwd: string;
|
|
202
|
+
readonly command: string;
|
|
203
|
+
readonly selection: AffectedSelection;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `<base>...HEAD` — three dots, so the answer is "what this branch changed since it forked",
|
|
208
|
+
* never "how this branch differs from a base that has moved on underneath it". A two-dot diff
|
|
209
|
+
* reports someone else's merged commits as this branch's work.
|
|
210
|
+
*
|
|
211
|
+
* `--dirty` unions the working tree on top: tracked edits against HEAD, plus untracked files that
|
|
212
|
+
* are not ignored. Both halves are needed — a brand-new file is invisible to `git diff`.
|
|
213
|
+
*/
|
|
214
|
+
export async function changedFiles(
|
|
215
|
+
runner: Runner,
|
|
216
|
+
options: ChangedFilesOptions,
|
|
217
|
+
): Promise<readonly string[]> {
|
|
218
|
+
const { base, dirty } = options.selection;
|
|
219
|
+
const resolved = await git(runner, options.cwd, [
|
|
220
|
+
'rev-parse',
|
|
221
|
+
'--verify',
|
|
222
|
+
'--quiet',
|
|
223
|
+
`${base}^{commit}`,
|
|
224
|
+
]);
|
|
225
|
+
if (!resolved.ok) {
|
|
226
|
+
throw new BadFlagError({
|
|
227
|
+
flag: 'base',
|
|
228
|
+
command: options.command,
|
|
229
|
+
reason: `git resolves no commit named "${base}" in this checkout`,
|
|
230
|
+
fix: `git fetch --no-tags origin ${base}:${base}, then re-run: ${invocationOf(options.command)} --base ${base} --json`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const runs = [
|
|
234
|
+
await git(runner, options.cwd, ['diff', '--name-only', '-z', `${base}...HEAD`]),
|
|
235
|
+
...(dirty
|
|
236
|
+
? [
|
|
237
|
+
await git(runner, options.cwd, ['diff', '--name-only', '-z', 'HEAD']),
|
|
238
|
+
await git(runner, options.cwd, ['ls-files', '-z', '--others', '--exclude-standard']),
|
|
239
|
+
]
|
|
240
|
+
: []),
|
|
241
|
+
];
|
|
242
|
+
const failed = runs.find((run) => !run.ok);
|
|
243
|
+
if (failed !== undefined) {
|
|
244
|
+
throw new UltimateError({
|
|
245
|
+
code: 'X_CLI_UNEXPECTED',
|
|
246
|
+
cause: `"${failed.command.join(' ')}" exited ${failed.code} in ${options.cwd}: ${singleLine(execOutput(failed))}`,
|
|
247
|
+
fix: `run it yourself to see why: ${failed.command.join(' ')}`,
|
|
248
|
+
docs: docsFor('X_CLI_UNEXPECTED'),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
return [...new Set(runs.flatMap((run) => paths(run.stdout)))].sort();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* One resolved answer, in the two shapes its two callers need: the `plan` (`x affected` reports
|
|
256
|
+
* it) and the `prefixes` (`x test --affected` selects with them). `plan.workspaces` holds dirs
|
|
257
|
+
* relative to the CHECKOUT; `prefixes` is the same set relative to the directory the caller scans,
|
|
258
|
+
* which is what its own paths are relative to.
|
|
259
|
+
*/
|
|
260
|
+
export interface AffectedScope {
|
|
261
|
+
readonly selection: AffectedSelection;
|
|
262
|
+
/** The checkout root git reported every path against. */
|
|
263
|
+
readonly root: string;
|
|
264
|
+
readonly plan: AffectedPlan;
|
|
265
|
+
readonly prefixes: readonly string[];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* An empty prefix means the scan root IS an affected workspace, so every path it yields is in
|
|
270
|
+
* scope; a prefix starting with `..` is a workspace outside that root, which has no file there to
|
|
271
|
+
* select and must not be allowed to collapse into a match-everything empty string.
|
|
272
|
+
*/
|
|
273
|
+
const scopePrefixes = (root: string, cwd: string, dirs: readonly string[]): readonly string[] =>
|
|
274
|
+
dirs
|
|
275
|
+
.map((dir) => relative(cwd, join(root, dir)).split('\\').join('/'))
|
|
276
|
+
.filter((path) => !path.startsWith('..'));
|
|
277
|
+
|
|
278
|
+
export const inScope = (path: string, prefixes: readonly string[]): boolean =>
|
|
279
|
+
prefixes.some((prefix) => prefix === '' || path === prefix || path.startsWith(`${prefix}/`));
|
|
280
|
+
|
|
281
|
+
export interface AffectedScopeOptions {
|
|
282
|
+
readonly runner: Runner;
|
|
283
|
+
/** The directory the caller scans, which the prefixes come back relative to. */
|
|
284
|
+
readonly cwd: string;
|
|
285
|
+
readonly args: ParsedArgs;
|
|
286
|
+
readonly command: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The whole answer, resolved once: the diff, the graph, the closure and the prefixes. `x affected`
|
|
291
|
+
* and `x test --affected` both come through here, so the second can never scope a run differently
|
|
292
|
+
* from what the first reports — an invented scoping that misses a transitive dependent is a green
|
|
293
|
+
* checkmark on a broken repo, and two implementations is how one of them gets it wrong.
|
|
294
|
+
*/
|
|
295
|
+
export async function affectedScope(options: AffectedScopeOptions): Promise<AffectedScope> {
|
|
296
|
+
const selection = readAffectedSelection(options.args, options.command);
|
|
297
|
+
const root = await gitRoot(options.runner, options.cwd, options.command);
|
|
298
|
+
const plan = planAffected(
|
|
299
|
+
await readWorkspaceGraph(root),
|
|
300
|
+
await changedFiles(options.runner, { cwd: root, command: options.command, selection }),
|
|
301
|
+
);
|
|
302
|
+
return {
|
|
303
|
+
selection,
|
|
304
|
+
root,
|
|
305
|
+
plan,
|
|
306
|
+
prefixes: scopePrefixes(
|
|
307
|
+
root,
|
|
308
|
+
options.cwd,
|
|
309
|
+
plan.workspaces.map((workspace) => workspace.dir),
|
|
310
|
+
),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The scope as `--json` carries it, from whichever command narrowed by it. */
|
|
315
|
+
export const affectedScopeJson = (scope: AffectedScope): JsonValue => ({
|
|
316
|
+
base: scope.selection.base,
|
|
317
|
+
dirty: scope.selection.dirty,
|
|
318
|
+
changed: scope.plan.changed.length,
|
|
319
|
+
workspaces: scope.plan.workspaces.map((workspace) => workspace.dir),
|
|
320
|
+
});
|
package/src/app-boundaries.ts
CHANGED
|
@@ -15,8 +15,9 @@ import { join as joinPath } from 'node:path';
|
|
|
15
15
|
// The POSIX variants resolve specifiers against import-graph keys, which are POSIX on every host.
|
|
16
16
|
import { dirname, join, normalize, relative } from 'node:path/posix';
|
|
17
17
|
import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
|
|
18
|
-
import { checkSurfaceBoundary, importGraph } from '@ultimat3/render';
|
|
18
|
+
import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
|
|
19
19
|
import type { Finding } from './output';
|
|
20
|
+
import { quoteArg } from './shell-quote';
|
|
20
21
|
|
|
21
22
|
export const BOUNDARY_CODES = [
|
|
22
23
|
'X_BOUNDARY_SITE_TO_APP',
|
|
@@ -132,8 +133,57 @@ const surfaceFindings = (graph: ImportGraph): readonly Finding[] =>
|
|
|
132
133
|
};
|
|
133
134
|
});
|
|
134
135
|
|
|
135
|
-
/**
|
|
136
|
-
|
|
136
|
+
/**
|
|
137
|
+
* The surface names, from `@ultimat3/render`'s own list rather than a copy: a resource can never
|
|
138
|
+
* be called one, because the directory carrying that name is the surface itself.
|
|
139
|
+
*/
|
|
140
|
+
const SURFACE_NAMES: ReadonlySet<string> = new Set<string>(SURFACES);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `apps/web/app/posts/service.ts` → `posts`: the primitive a generator would be told to make.
|
|
144
|
+
*
|
|
145
|
+
* `undefined` at a surface ROOT, where the directory above the file is the surface. The old
|
|
146
|
+
* answer there was the surface's own name, so `X_BOUNDARY_ROUTE_TO_DB` on `apps/web/site/page.tsx`
|
|
147
|
+
* said `x g query site` — a runnable line that generates seven files and a `sites` table for a
|
|
148
|
+
* landing page whose only problem is one import. A fix that does the wrong thing successfully is
|
|
149
|
+
* worse than one that refuses, so the caller names the file and asks for a name instead.
|
|
150
|
+
*/
|
|
151
|
+
function subjectOf(path: string): string | undefined {
|
|
152
|
+
const parent = path.split('/').at(-2);
|
|
153
|
+
return parent === undefined || SURFACE_NAMES.has(parent) ? undefined : parent;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Control characters, `\u00xx`-escaped. The path is a value read off the repository being scanned,
|
|
158
|
+
* and it rides in a `#` comment: a directory holding a NEWLINE ends that comment, so everything
|
|
159
|
+
* after it is a second command in a line whose whole purpose is to be pasted into a shell.
|
|
160
|
+
* Escaped rather than deleted, because the comment still has to name the file the reader owns.
|
|
161
|
+
*/
|
|
162
|
+
const commentSafe = (path: string): string =>
|
|
163
|
+
[...path]
|
|
164
|
+
.map((char) => {
|
|
165
|
+
const code = char.codePointAt(0) ?? 0;
|
|
166
|
+
return code < 0x20 || code === 0x7f ? `\\u${code.toString(16).padStart(4, '0')}` : char;
|
|
167
|
+
})
|
|
168
|
+
.join('');
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `x g query posts` where the path names a resource, `x g query <name>` where it names a surface.
|
|
172
|
+
* The placeholder form is the shape `MissingPositionalError` already hands out (`x g route
|
|
173
|
+
* <name>`) and the one the `errors` step leaves unjudged in that slot — an open positional, where
|
|
174
|
+
* a reader substituting a word makes the line run. The path rides in the `#` comment because a
|
|
175
|
+
* `fix:` is copied on its own, and `Finding.at` is a field an agent pasting one line never sees.
|
|
176
|
+
*
|
|
177
|
+
* BOTH halves come from the scanned path, so both are hostile: the subject is an ARGUMENT and goes
|
|
178
|
+
* through `quoteArg` — a directory named `posts; id` emitted a fix that ran `id` — and the comment
|
|
179
|
+
* goes through `commentSafe`. `<name>` alone stays literal: it is a placeholder a reader replaces,
|
|
180
|
+
* and `'<name>'` would be pasted as a resource actually called that.
|
|
181
|
+
*/
|
|
182
|
+
const generate = (kind: 'query' | 'action', path: string, then: string): string => {
|
|
183
|
+
const subject = subjectOf(path);
|
|
184
|
+
const named = subject === undefined ? '<name>' : quoteArg(subject);
|
|
185
|
+
return `x g ${kind} ${named} # ${then} ${commentSafe(path)}`;
|
|
186
|
+
};
|
|
137
187
|
|
|
138
188
|
/**
|
|
139
189
|
* Both fixes are one runnable line, with the rest of the instruction behind a `#` — a fix a
|
|
@@ -147,7 +197,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
|
|
|
147
197
|
findings.push({
|
|
148
198
|
code: 'X_BOUNDARY_ROUTE_TO_DB',
|
|
149
199
|
cause: `route imports the database ("${specifier}") — routes call actions and queries`,
|
|
150
|
-
fix:
|
|
200
|
+
fix: generate('query', file.path, 'then call it from'),
|
|
151
201
|
docs: docs('X_BOUNDARY_ROUTE_TO_DB'),
|
|
152
202
|
at: file.path,
|
|
153
203
|
});
|
|
@@ -156,7 +206,7 @@ function layerFindings(scanned: readonly ScannedFile[]): readonly Finding[] {
|
|
|
156
206
|
findings.push({
|
|
157
207
|
code: 'X_BOUNDARY_SERVICE_TO_HTTP',
|
|
158
208
|
cause: `service imports HTTP ("${specifier}") — a service that knows about requests cannot be reused by a job`,
|
|
159
|
-
fix:
|
|
209
|
+
fix: generate('action', file.path, 'read the request there and pass plain values to'),
|
|
160
210
|
docs: docs('X_BOUNDARY_SERVICE_TO_HTTP'),
|
|
161
211
|
at: file.path,
|
|
162
212
|
});
|
package/src/bin.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// dispatch.ts, so the whole CLI is testable without spawning a process.
|
|
4
4
|
|
|
5
5
|
import { dispatch } from './dispatch';
|
|
6
|
-
// The
|
|
7
|
-
// and a second copy of a note about pipe truncation is a second copy that drifts.
|
|
8
|
-
|
|
6
|
+
// The writes themselves are `write-line.ts`: `create-ultimate`'s entry point needs the identical
|
|
7
|
+
// one, and a second copy of a note about pipe truncation is a second copy that drifts. Two sinks,
|
|
8
|
+
// because fd 1 is not always this process's to write on — `x mcp serve --transport stdio` hands it
|
|
9
|
+
// to the protocol, and `dispatch` addresses that result to the second.
|
|
10
|
+
import { writeErrorLine, writeLine } from './write-line';
|
|
9
11
|
|
|
10
12
|
const code = await dispatch({
|
|
11
13
|
argv: Bun.argv.slice(2),
|
|
@@ -13,6 +15,7 @@ const code = await dispatch({
|
|
|
13
15
|
env: Bun.env,
|
|
14
16
|
bunVersion: Bun.version,
|
|
15
17
|
write: writeLine,
|
|
18
|
+
writeError: writeErrorLine,
|
|
16
19
|
});
|
|
17
20
|
|
|
18
21
|
process.exit(code);
|