@ultimat3/cli 18.0.0 → 19.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +105 -11
- package/README.md +24 -0
- package/package.json +29 -29
- package/src/app-mcp.ts +152 -0
- package/src/app-root.ts +10 -1
- package/src/app-runtime.ts +39 -0
- package/src/bin.ts +18 -0
- package/src/budgets.ts +1 -1
- package/src/cdp-browser.ts +100 -0
- package/src/cdp-connection.ts +211 -0
- package/src/cdp-e2e-page.ts +209 -0
- package/src/cdp-errors.ts +56 -0
- package/src/cdp-launch.ts +130 -0
- package/src/cmd-dev-fixture.ts +147 -0
- package/src/cmd-dev.ts +41 -3
- package/src/compile-externals.ts +11 -4
- package/src/dev-live-feed.ts +61 -0
- package/src/dev-roles.ts +20 -0
- package/src/dev-sync.ts +5 -1
- package/src/e2e-driver.ts +35 -17
- package/src/e2e-page.ts +15 -3
- package/src/error-codes.ts +11 -0
- package/src/index.ts +27 -0
- package/src/local-cli.ts +71 -0
- package/src/mcp-errors.ts +9 -0
- package/src/measurement-actor.ts +26 -0
- package/src/messages.ts +5 -0
- package/src/prerender.ts +42 -5
- package/src/pwa-artifacts.ts +44 -2
- package/src/serve.ts +38 -4
- package/src/source-files.ts +14 -2
- package/src/static-report.ts +46 -3
- package/src/sw-artifacts.ts +162 -0
- package/src/sw-routes.ts +53 -0
- package/src/templates/entity.ts +7 -0
- package/src/templates/scaffold-app.ts +58 -7
- package/src/templates/scaffold-repo.ts +4 -1
- package/src/ts-scan.ts +8 -111
package/CLAUDE.md
CHANGED
|
@@ -4,7 +4,7 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
4
4
|
|
|
5
5
|
| Rule | Detail |
|
|
6
6
|
|---|---|
|
|
7
|
-
| Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only |
|
|
7
|
+
| Entry | `src/bin.ts` (`#!/usr/bin/env bun`) — argv, stdout, exit code only. Before `dispatch`, `local-cli.ts` decides whether THIS process is the app's own CLI: a global `x` inside an app re-executes `node_modules/@ultimat3/cli/src/bin.ts` when that is a different realpath, because a second module instance of `@ultimat3/entity` is an empty registry (a zero-entity manifest, green, 2026-09-05). Same realpath (a workspace symlink), no app, a compiled binary, or `ULTIMATE_KEEP_GLOBAL_CLI` set: no hand-over |
|
|
8
8
|
| stdout | `write-line.ts`'s `writeLine` — synchronous fd 1, never `process.stdout.write`, which truncates at the 64KB pipe buffer when `process.exit` follows. Exported, because `create-ultimate`'s entry point needs the same one |
|
|
9
9
|
| 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
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 |
|
|
@@ -329,26 +329,120 @@ to know about everything — so the join is here, and it is the same rule
|
|
|
329
329
|
| `e2e-evaluate.ts` | the closure→string crossing, which is the only lossy edge in the adapter |
|
|
330
330
|
| `e2e-errors.ts` | one constructor per refusal |
|
|
331
331
|
| `e2e-dom-fixture.ts` | a document small enough to hold in a test and real enough to RUN the expressions above |
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
`e2e`
|
|
332
|
+
| `cdp-browser.ts` | the two doors: `openE2eBrowserIfAvailable()` (undefined when there is no browser) and `openE2eBrowser()` (refuses by name), and the close that undoes both halves |
|
|
333
|
+
| `cdp-launch.ts` | which Chrome, and starting it — the candidate list, the flags, and the endpoint read off its stderr |
|
|
334
|
+
| `cdp-connection.ts` | CDP over Bun's own `WebSocket`: request framing, reply correlation by `id`, one-shot event waiters, the per-call deadline |
|
|
335
|
+
| `cdp-e2e-page.ts` | `E2eBrowserPage`'s five methods over an attached, flattened session |
|
|
336
|
+
| `cdp-errors.ts` | one constructor per way the browser half refuses |
|
|
337
|
+
|
|
338
|
+
**Absent by default, and that is a requirement rather than a state.** Nothing here runs until
|
|
339
|
+
`installE2eDriver` is called, so `hasE2eDriver()` still answers `false` and the gate's `e2e` step
|
|
340
|
+
still refuses instead of passing over a browser it does not have. This paragraph also said "CI has
|
|
341
|
+
no Chrome" until 2026-08-27, and that is false and was the reason issue #390's fourth requirement
|
|
342
|
+
— a real browser check — was recorded as out of reach: GitHub-hosted `ubuntu-latest` ships one at
|
|
343
|
+
`/usr/bin/google-chrome`, preinstalled, with no download step and no new dependency.
|
|
344
|
+
|
|
345
|
+
**The browser is RAW CDP over Bun's own `WebSocket`, and carries no dependency.**
|
|
346
|
+
`packages/scraping/src/cdp-port.ts` declares a ~25-method port because `ScrapePage` is a full
|
|
347
|
+
scraping surface and its intended implementation is `puppeteer-core`. `E2eBrowserPage` is FIVE
|
|
348
|
+
methods, and CDP's wire format is one JSON object with an `id` — so the whole thing an e2e driver
|
|
349
|
+
needs is four small modules, which is why `x test e2e` needs nothing installed that `bun install`
|
|
350
|
+
did not already put there. `e2e/cdp-browser.e2e.test.ts` drives a real Chrome against a real
|
|
351
|
+
`Bun.serve` and asserts all five methods; `openE2eBrowserIfAvailable()` answering `undefined` is
|
|
352
|
+
what makes it a SKIP on a laptop without one rather than a red step.
|
|
353
|
+
|
|
354
|
+
**The load EVENT is the completion signal, never `Page.navigate`'s reply.** Measured on Chrome 150:
|
|
355
|
+
a navigation that swaps the render process — `about:blank` → `http://localhost:<port>/`, the most
|
|
356
|
+
ordinary one there is — loads the page, hits the server and answers a later `Runtime.evaluate` from
|
|
357
|
+
the new document, and the navigate frame **never comes back at all**. A driver that awaited the
|
|
358
|
+
reply waited out its full deadline on every first navigation. So `cdpConnect().once()` registers a
|
|
359
|
+
`Page.loadEventFired` waiter BEFORE the send, and the reply is raced against it — still read, but
|
|
360
|
+
only for `errorText`, which is the one place a refused navigation is named.
|
|
361
|
+
|
|
362
|
+
**A CDP call is deadlined and a close settles every call in flight.** Without that, a suite whose
|
|
363
|
+
browser died waits out one full deadline per call and reports a timeout, where the true fault is a
|
|
364
|
+
dead browser. The four codes are four repairs, which is why they are not one:
|
|
365
|
+
`X_CDP_BROWSER_MISSING` (install one), `X_CDP_LAUNCH_FAILED` (read the browser's own stderr, which
|
|
366
|
+
the cause carries), `X_CDP_CALL_FAILED` (look at the page), `X_CDP_TIMEOUT` (raise the deadline).
|
|
336
367
|
|
|
337
368
|
**`evaluate` is the edge that cannot be lossless.** `PageLike.evaluate` takes a closure and every
|
|
338
369
|
browser port in this framework takes a string, so what crosses is `Function.prototype.toString()`
|
|
339
370
|
and nothing else. A zero-parameter closure naming only page globals is supported; a native or bound
|
|
340
371
|
function, a declared parameter and a method shorthand are refused STATICALLY, before a byte leaves;
|
|
341
372
|
a binding the page does not have comes back named, from the page's own `ReferenceError`. Measured on
|
|
342
|
-
Bun 1.4.0 and load-bearing: **Bun's transpiler folds `wanted === 3` to `!0` before `toString()` ever
|
|
373
|
+
Bun 1.3.14 and 1.4.0 alike — re-measured on both when the repo moved back to the 1.3 series, because a version-stamped claim that names one runtime is unread evidence on the other — and load-bearing: **Bun's transpiler folds `wanted === 3` to `!0` before `toString()` ever
|
|
343
374
|
runs**, so a captured PRIMITIVE can vanish from the source and never fail at all, while a captured
|
|
344
375
|
reference always survives as its name. No static rule in this process can see the difference — which
|
|
345
376
|
is why the refusal is raised from the page's answer rather than from a scan of the source.
|
|
346
377
|
|
|
347
|
-
**
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
378
|
+
**One of `E2eFixtures`' four members still refuses, and it is the one that is not a port gap.**
|
|
379
|
+
`update()` needs a second build served under a new immutable build id, which is a fact about the
|
|
380
|
+
SERVER, and no page port has ever been able to speak for one. `offline()`/`online()` FORWARD — to
|
|
381
|
+
`E2eBrowserPage.offline`, which `cdp-e2e-page.ts` implements as
|
|
382
|
+
`Network.emulateNetworkConditions` and `@ultimat3/scraping` implements through
|
|
383
|
+
`CdpPageLike.setOfflineMode`. They refused until 2026-08-27 on a reason the tree contradicted on
|
|
384
|
+
the day it was written. A fixture that silently no-opped would make the assertion after it read as
|
|
385
|
+
proof — `offline()` followed by "the fallback rendered" is the app's ONLINE page passing an offline
|
|
386
|
+
test — so an `E2eBrowserPage` that declares no `offline` still gets the refusal, now naming the
|
|
387
|
+
method the double is missing rather than a capability the framework does not have.
|
|
388
|
+
|
|
389
|
+
## The service worker is emitted here, because the emitter needs facts only a build has
|
|
390
|
+
|
|
391
|
+
`@ultimat3/pwa` shipped `generateServiceWorker`, `buildPrecacheManifest`, `offlineFallbackSource`,
|
|
392
|
+
`backgroundSyncSource` and `pushSource` since it existed, and every one had **zero callers** outside
|
|
393
|
+
its own package. So `pwa.offline`, `pwa.backgroundSync`, `pwa.push` and every route's own `offline:`
|
|
394
|
+
were declarations with no build behind them, and no Ultimate app worked offline however its config
|
|
395
|
+
was written (#390). `sw-artifacts.ts` is the caller.
|
|
396
|
+
|
|
397
|
+
**Why here and not beside the manifest.** `loadPwaArtifacts(root)` needs a root and a config file;
|
|
398
|
+
the worker needs the ROUTE TABLE and the ISLAND BUNDLE as well — facts only a booted app and a
|
|
399
|
+
finished build have. Splitting them keeps `loadPwaArtifacts` callable before either exists, which
|
|
400
|
+
`x doctor` and the icon writer rely on. The route table is `describeRoutes()`, the one projection
|
|
401
|
+
`x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a route added to the app
|
|
402
|
+
cannot be missing from the precache manifest.
|
|
403
|
+
|
|
404
|
+
| Surface | What it does with the worker |
|
|
405
|
+
|---|---|
|
|
406
|
+
| `cmd-dev.ts` | mounts `/sw.js` and `/x-sw-register.js`; built ONCE at boot and deliberately not rebuilt on the watcher tick — a worker that changes under a page it already controls is the update path, and re-emitting one per keystroke exercises it on every save |
|
|
407
|
+
| `serve.ts` | the same two routes in the container, from the same call |
|
|
408
|
+
| `prerender.ts` | writes both as FILES into the export — a static host runs no route table, so a `<script src="/x-sw-register.js">` in every document is a 404 unless the bytes are in the artifact |
|
|
409
|
+
|
|
410
|
+
**Registration is an EXTERNAL script, never inline**, and that is a CSP fact rather than a
|
|
411
|
+
preference: `startWeb` computes a `script-src` sha256 per inline script, so an unhashed one is
|
|
412
|
+
blocked in the container while passing report-only under `x dev` — which is how the hydration
|
|
413
|
+
runtime shipped broken once already.
|
|
414
|
+
|
|
415
|
+
**`sw.js` is served `no-store` with `Service-Worker-Allowed: /`.** A cached `sw.js` is a worker that
|
|
416
|
+
cannot be replaced: the browser re-fetches it to decide whether an update exists, and an
|
|
417
|
+
intermediary answering the old bytes pins every client to the deploy that shipped them. Without the
|
|
418
|
+
header the browser refuses to let a worker served from `/` control `/` — the failure `assertScope`
|
|
419
|
+
cannot see, because the scope a REGISTRATION asks for has to be allowed by the script's own response
|
|
420
|
+
and not only by its path.
|
|
421
|
+
|
|
422
|
+
**`api/` and `shared/` never cross.** An API response is a JSON document whose freshness is the
|
|
423
|
+
app's business, and precaching one serves a stale answer to a client that had a network; `shared/`
|
|
424
|
+
is not a URL at all. The filter is a `flatMap` rather than `filter().map()` because the predicate
|
|
425
|
+
does not narrow `surface` for the map that follows it, and a cast would hide the day a fifth surface
|
|
426
|
+
arrives.
|
|
427
|
+
|
|
428
|
+
**`pwa.push` is read and still wires nothing, and it says so.** `generateServiceWorker` emits a push
|
|
429
|
+
handler only when a VAPID key comes with the capability, there is no `pwa.vapid` config key, and it
|
|
430
|
+
drops the handler in SILENCE otherwise. `pushWarning` is this module's own finding, reported through
|
|
431
|
+
`x build --json`'s `serviceWorkerWarnings` — `jobs.driver`'s shape one package over, refused the same way.
|
|
432
|
+
|
|
433
|
+
**The browser check is what let any of this ship.** #390's fourth requirement was *"a real browser
|
|
434
|
+
check that the emitted worker installs, activates and serves the fallback offline. Until it exists,
|
|
435
|
+
do not ship the worker"* — a bad `sw.js` is sticky in a way a manifest is not.
|
|
436
|
+
`e2e/service-worker.e2e.test.ts` registers the emitted file in a real Chrome, waits for it to take
|
|
437
|
+
control, takes the network away, and asserts that a runtime route with nothing cached renders the
|
|
438
|
+
offline document.
|
|
439
|
+
|
|
440
|
+
**And it found the driver bug first.** `E2eFixtures.offline()` did not take the SERVICE WORKER
|
|
441
|
+
offline: a worker fetches on its own CDP target, the condition was only ever set on the page's, and
|
|
442
|
+
a `networkFirst` route the cache had never seen still answered from the network. So an offline
|
|
443
|
+
assertion made on a PWA tested nothing. `cdp-e2e-page.ts` now auto-attaches worker targets and
|
|
444
|
+
carries the condition onto each, including one that attaches AFTER `offline(true)` — the ordinary
|
|
445
|
+
case for a PWA.
|
|
352
446
|
|
|
353
447
|
## The `errors` step enforces the error contract
|
|
354
448
|
|
package/README.md
CHANGED
|
@@ -34,6 +34,25 @@ Everything in [CLI reference](../../wiki/CLI-Reference.md)'s planned table is al
|
|
|
34
34
|
and exits `X_NOT_IMPLEMENTED` with a `fix:` naming the closest shipped command — "not built yet"
|
|
35
35
|
and "not a command" are different facts.
|
|
36
36
|
|
|
37
|
+
## Which `x` runs
|
|
38
|
+
|
|
39
|
+
**The app's own.** `x` is a workspace dependency, and `bunx x` / the `package.json` scripts resolve
|
|
40
|
+
`node_modules/.bin/x`. A **globally** installed `x` — `bun link` of a checkout, `bun add -g` — is
|
|
41
|
+
a second copy of every `@ultimat3/*` package, and a second copy of `@ultimat3/entity` is a second,
|
|
42
|
+
**empty** registry: the app's entities register into the instance under its `node_modules`, and
|
|
43
|
+
only the CLI under that same `node_modules` can see them. Measured 2026-09-05, in an app run with a
|
|
44
|
+
linked checkout's `x`: `x entities list` answered `0 entities`, `x policy list` answered
|
|
45
|
+
`0 permission(s), 0 role(s)`, and `x manifest` wrote a manifest with **zero entities and zero
|
|
46
|
+
actions** — exit 0, green — which `x db gen` then read as "drop every table".
|
|
47
|
+
|
|
48
|
+
So a global `x` inside an app **hands over** to `node_modules/@ultimat3/cli/src/bin.ts` when that
|
|
49
|
+
is a different file (`local-cli.ts`), prints one line on stderr saying so, and exits with the
|
|
50
|
+
child's code; fd 1 is the child's alone, so a `--json` consumer sees one document. A workspace
|
|
51
|
+
symlink resolves to the same file and is not handed over (both tracked apps, and every scaffold
|
|
52
|
+
CI installs), and a compiled `x` keeps itself — its own path is not one `realpath` can resolve.
|
|
53
|
+
`ULTIMATE_KEEP_GLOBAL_CLI=1` keeps the CLI that was invoked, for the one deliberate case: running a
|
|
54
|
+
checkout's `x` against an app pinned to an older release to see what the next one would say.
|
|
55
|
+
|
|
37
56
|
## The output contract
|
|
38
57
|
|
|
39
58
|
Every command returns one `CommandResult`; the human renderer and the JSON renderer are
|
|
@@ -95,6 +114,11 @@ is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUAR
|
|
|
95
114
|
| `cmd-*.ts` | one command group each |
|
|
96
115
|
| `templates/` | scaffolding as typed string modules, not copied fixtures |
|
|
97
116
|
| `app-load.ts` | import an app's modules so the framework registries hold it |
|
|
117
|
+
| `app-mcp.ts` | the app's own MCP endpoint: `apps/<app>/mcp.ts` exports `mcp`, and both boots mount `POST config.ai.mcp.path` through this one call |
|
|
118
|
+
| `app-runtime.ts` | the app's `RuntimeOverrides`: `apps/<app>/runtime.ts` exports `runtime`, read by `x dev` and by `runRole` when its caller passed none |
|
|
119
|
+
| `local-cli.ts` | which `x` runs: a global CLI inside an app hands over to the app's own, because a second module instance is an empty registry |
|
|
120
|
+
| `measurement-actor.ts` | the actor a weigh-and-discard render runs as — every permission, never served |
|
|
121
|
+
| `dev-live-feed.ts` | what feeds the sync node this process booted: the in-process row observer under the embedded database, the WAL decoder with a real one, nothing without the role — `live=` on the ready line |
|
|
98
122
|
| `app-manifest.ts` | `x.manifest.json`, projected by `@ultimat3/manifest` |
|
|
99
123
|
| `app-openapi.ts` | `openapi.json`, projected by `@ultimat3/action` |
|
|
100
124
|
| `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "19.1.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,34 +37,34 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/auth": "
|
|
44
|
-
"@ultimat3/cache": "
|
|
45
|
-
"@ultimat3/core": "
|
|
46
|
-
"@ultimat3/db": "
|
|
47
|
-
"@ultimat3/entity": "
|
|
48
|
-
"@ultimat3/flags": "
|
|
49
|
-
"@ultimat3/http": "
|
|
50
|
-
"@ultimat3/i18n": "
|
|
51
|
-
"@ultimat3/jobs": "
|
|
52
|
-
"@ultimat3/mail": "
|
|
53
|
-
"@ultimat3/manifest": "
|
|
54
|
-
"@ultimat3/mcp": "
|
|
55
|
-
"@ultimat3/money": "
|
|
56
|
-
"@ultimat3/notify": "
|
|
57
|
-
"@ultimat3/policy": "
|
|
58
|
-
"@ultimat3/pwa": "
|
|
59
|
-
"@ultimat3/query": "
|
|
60
|
-
"@ultimat3/realtime": "
|
|
61
|
-
"@ultimat3/render": "
|
|
62
|
-
"@ultimat3/schema": "
|
|
63
|
-
"@ultimat3/scraping": "
|
|
64
|
-
"@ultimat3/seo": "
|
|
65
|
-
"@ultimat3/storage": "
|
|
66
|
-
"@ultimat3/testing": "
|
|
67
|
-
"@ultimat3/time": "
|
|
40
|
+
"@ultimat3/action": "19.1.0",
|
|
41
|
+
"@ultimat3/admin": "19.1.0",
|
|
42
|
+
"@ultimat3/ai": "19.1.0",
|
|
43
|
+
"@ultimat3/auth": "19.1.0",
|
|
44
|
+
"@ultimat3/cache": "19.1.0",
|
|
45
|
+
"@ultimat3/core": "19.1.0",
|
|
46
|
+
"@ultimat3/db": "19.1.0",
|
|
47
|
+
"@ultimat3/entity": "19.1.0",
|
|
48
|
+
"@ultimat3/flags": "19.1.0",
|
|
49
|
+
"@ultimat3/http": "19.1.0",
|
|
50
|
+
"@ultimat3/i18n": "19.1.0",
|
|
51
|
+
"@ultimat3/jobs": "19.1.0",
|
|
52
|
+
"@ultimat3/mail": "19.1.0",
|
|
53
|
+
"@ultimat3/manifest": "19.1.0",
|
|
54
|
+
"@ultimat3/mcp": "19.1.0",
|
|
55
|
+
"@ultimat3/money": "19.1.0",
|
|
56
|
+
"@ultimat3/notify": "19.1.0",
|
|
57
|
+
"@ultimat3/policy": "19.1.0",
|
|
58
|
+
"@ultimat3/pwa": "19.1.0",
|
|
59
|
+
"@ultimat3/query": "19.1.0",
|
|
60
|
+
"@ultimat3/realtime": "19.1.0",
|
|
61
|
+
"@ultimat3/render": "19.1.0",
|
|
62
|
+
"@ultimat3/schema": "19.1.0",
|
|
63
|
+
"@ultimat3/scraping": "19.1.0",
|
|
64
|
+
"@ultimat3/seo": "19.1.0",
|
|
65
|
+
"@ultimat3/storage": "19.1.0",
|
|
66
|
+
"@ultimat3/testing": "19.1.0",
|
|
67
|
+
"@ultimat3/time": "19.1.0",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-mcp.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// The app's own MCP endpoint, mounted by the web role. `defineAppMcp` built `mcp.route` — a
|
|
2
|
+
// `POST` handler with token auth and per-class rate limits — and `app.config.ts` declared
|
|
3
|
+
// `ai: { mcp: { expose: true, path: '/mcp' } }` by DEFAULT, and nothing between the two served it:
|
|
4
|
+
// neither `x dev` nor `runRole` mounted the route, so `POST /mcp` answered `X_ROUTE_NOT_FOUND` in
|
|
5
|
+
// every app ever scaffolded (measured 2026-09-05). The contract is one file: `apps/<app>/mcp.ts`
|
|
6
|
+
// exports `mcp`, an `AppMcp`; this module finds it, and both boots mount what it carries.
|
|
7
|
+
|
|
8
|
+
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
// why: Bun exposes no path-join primitive; the config file and each candidate are joined to root.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { logger } from '@ultimat3/core';
|
|
13
|
+
import type { Route } from '@ultimat3/http';
|
|
14
|
+
import { type AppMcp, McpAppUnmountedError } from '@ultimat3/mcp';
|
|
15
|
+
import { APP_CONFIG_EXPORT } from './app-auth';
|
|
16
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
17
|
+
|
|
18
|
+
/** The one file an app writes, per app directory. */
|
|
19
|
+
export const APP_MCP_GLOB = 'apps/*/mcp.ts';
|
|
20
|
+
/** The export that file makes — an `AppMcp`, the value `defineAppMcp` returns. */
|
|
21
|
+
export const APP_MCP_EXPORT = 'mcp';
|
|
22
|
+
/** What the boot line and `/_x` call the route. */
|
|
23
|
+
export const APP_MCP_ROUTE_NAME = 'mcp';
|
|
24
|
+
|
|
25
|
+
export interface AppMcpMount {
|
|
26
|
+
/** `[]` when `expose` is false, when nothing exports `mcp`, or when the export has no route. */
|
|
27
|
+
readonly routes: readonly Route[];
|
|
28
|
+
/** `POST <path>` when mounted, else `null` — the boot line prints it. */
|
|
29
|
+
readonly path: string | null;
|
|
30
|
+
/** Set exactly when `expose` is true and `routes` is empty: the reason, as an instruction. */
|
|
31
|
+
readonly warning: McpAppUnmountedError | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ExposeDeclaration {
|
|
35
|
+
readonly expose: boolean;
|
|
36
|
+
readonly path: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
40
|
+
typeof value === 'object' && value !== null;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `config.ai.mcp`, off the app's own resolved config — the exported `config` is what
|
|
44
|
+
* `defineConfig` returned, so both keys are present and defaulted. Read the same way
|
|
45
|
+
* `loadSignInPath` reads `auth.signInPath`: the config file is imported, never re-parsed.
|
|
46
|
+
* An app with no config file has nothing exposed and nothing to warn about.
|
|
47
|
+
*/
|
|
48
|
+
async function exposeDeclaration(root: string): Promise<ExposeDeclaration | undefined> {
|
|
49
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
50
|
+
if (!(await Bun.file(configPath).exists())) return undefined;
|
|
51
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
52
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
53
|
+
if (!isRecord(config) || !isRecord(config['ai']) || !isRecord(config['ai']['mcp'])) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const mcp = config['ai']['mcp'];
|
|
57
|
+
const path = mcp['path'];
|
|
58
|
+
return {
|
|
59
|
+
expose: mcp['expose'] === true,
|
|
60
|
+
path: typeof path === 'string' && path.startsWith('/') ? path : '/mcp',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isAppMcp = (value: unknown): value is AppMcp =>
|
|
65
|
+
isRecord(value) && 'server' in value && 'tools' in value && 'route' in value;
|
|
66
|
+
|
|
67
|
+
/** Every `apps/<app>/mcp.ts`, app-root-relative and sorted, so two apps answer in one order. */
|
|
68
|
+
async function candidates(root: string): Promise<readonly string[]> {
|
|
69
|
+
// A root with no `apps/` is an app with no MCP file, never a boot failure — the scan's ENOENT
|
|
70
|
+
// is answered as "none", and the warning below says which file to write.
|
|
71
|
+
if (!existsSync(join(root, 'apps'))) return [];
|
|
72
|
+
const files: string[] = [];
|
|
73
|
+
for await (const file of new Bun.Glob(APP_MCP_GLOB).scan({ cwd: root })) files.push(file);
|
|
74
|
+
return files.sort();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The route to mount, or the reason there is none. Pure over the filesystem it is pointed at;
|
|
79
|
+
* `mountAppMcp` below is the one place the warning becomes a log line.
|
|
80
|
+
*
|
|
81
|
+
* `meta.auth: 'public'` and `enforcedBy: 'handler'` — the http pipeline must not pre-judge:
|
|
82
|
+
* `mcp.route.handle` is the one evaluation, and it reads `Authorization: Bearer` through the
|
|
83
|
+
* `resolveToken` the app gave `defineAppMcp`, then decides per tool through the same policy every
|
|
84
|
+
* other surface evaluates. A pipeline `auth: 'required'` would demand a session cookie an agent
|
|
85
|
+
* does not have and answer 401 before the token was ever read.
|
|
86
|
+
*/
|
|
87
|
+
export async function appMcpMount(root: string): Promise<AppMcpMount> {
|
|
88
|
+
const declared = await exposeDeclaration(root);
|
|
89
|
+
if (declared === undefined || !declared.expose)
|
|
90
|
+
return { routes: [], path: null, warning: undefined };
|
|
91
|
+
const files = await candidates(root);
|
|
92
|
+
const fallbackFile = 'apps/web/mcp.ts';
|
|
93
|
+
if (files.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
routes: [],
|
|
96
|
+
path: null,
|
|
97
|
+
warning: new McpAppUnmountedError({
|
|
98
|
+
reason: 'missing',
|
|
99
|
+
path: declared.path,
|
|
100
|
+
file: fallbackFile,
|
|
101
|
+
}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
const module = (await import(join(root, file))) as Record<string, unknown>;
|
|
106
|
+
const exported = module[APP_MCP_EXPORT];
|
|
107
|
+
if (!isAppMcp(exported)) continue;
|
|
108
|
+
const route = exported.route;
|
|
109
|
+
if (route === undefined) {
|
|
110
|
+
return {
|
|
111
|
+
routes: [],
|
|
112
|
+
path: null,
|
|
113
|
+
warning: new McpAppUnmountedError({ reason: 'no-route', path: declared.path, file }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
routes: [
|
|
118
|
+
{
|
|
119
|
+
method: 'POST',
|
|
120
|
+
path: declared.path,
|
|
121
|
+
handler: (request) => route.handle(request.raw),
|
|
122
|
+
meta: { name: APP_MCP_ROUTE_NAME, auth: 'public', enforcedBy: 'handler' },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
path: declared.path,
|
|
126
|
+
warning: undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
routes: [],
|
|
131
|
+
path: null,
|
|
132
|
+
warning: new McpAppUnmountedError({
|
|
133
|
+
reason: 'missing',
|
|
134
|
+
path: declared.path,
|
|
135
|
+
file: files[0] ?? fallbackFile,
|
|
136
|
+
}),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The boot's call: the routes to spread into the table, with the warning already logged ONCE and
|
|
142
|
+
* the mount announced. Both `x dev` and `runRole` go through here, so a developer's terminal and a
|
|
143
|
+
* container's log say the same thing about the same endpoint.
|
|
144
|
+
*/
|
|
145
|
+
export async function mountAppMcp(root: string): Promise<AppMcpMount> {
|
|
146
|
+
const mount = await appMcpMount(root);
|
|
147
|
+
if (mount.warning !== undefined) {
|
|
148
|
+
logger.warn(`${mount.warning.code}: ${mount.warning.cause} — fix: ${mount.warning.fix}`);
|
|
149
|
+
}
|
|
150
|
+
if (mount.path !== null) logger.info('app mcp mounted', { method: 'POST', path: mount.path });
|
|
151
|
+
return mount;
|
|
152
|
+
}
|
package/src/app-root.ts
CHANGED
|
@@ -12,12 +12,21 @@ export const MANIFEST_FILE = 'x.manifest.json';
|
|
|
12
12
|
* through 2026-08-27 while `x test` spent `bun test --isolate` — a flag Bun introduced in
|
|
13
13
|
* **1.3.13** — so a user on a Bun this file declared supported got an unknown-flag failure out of
|
|
14
14
|
* the gate's dominant step, with `x doctor` reporting the runtime as fine. `--parallel` arrived in
|
|
15
|
-
* the same release and is emitted now.
|
|
15
|
+
* the same release and is emitted now, so the floor may never fall below that patch.
|
|
16
16
|
*
|
|
17
17
|
* `1.4.0` rather than `1.3.13` because a floor is a claim about a runtime somebody TESTED: CI pins
|
|
18
18
|
* `1.4.x`, both images build on `oven/bun:1.4-*`, and the per-worker database rests on
|
|
19
19
|
* `BUN_TEST_WORKER_ID`'s numbering, probed on 1.4.0 and on nothing older. `scripts/bun-pin.test.ts`
|
|
20
20
|
* holds this to the same series as every other pin.
|
|
21
|
+
*
|
|
22
|
+
* **Lowering it to 1.3.14 was tried on 2026-08-27 and refused**, and the argument for trying was
|
|
23
|
+
* sound — `--isolate` and `--parallel` are 1.3.13 features, no package here calls a 1.4-only API
|
|
24
|
+
* (`bun run typecheck` is clean against `@types/bun@1.3.14`), and `>=1.4.0` therefore bars Bun 1.3
|
|
25
|
+
* users for a capability the framework does not use. What refused it is a Bun 1.3.14 defect, not
|
|
26
|
+
* the paperwork: a service shutdown against a destroyed database never resolves there
|
|
27
|
+
* (`queue.stop()`, reproduced by `dev-runtime.live.test.ts`), so an app on a runtime this line
|
|
28
|
+
* declared supported would hang on graceful shutdown the moment its database went away. The full
|
|
29
|
+
* measurement is in `.github/actions/setup/action.yml`; read it before lowering this.
|
|
21
30
|
*/
|
|
22
31
|
export const REQUIRED_BUN = '1.4.0';
|
|
23
32
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The app's `RuntimeOverrides`, found rather than handed over. `runRole({ runtime })` has taken
|
|
2
|
+
// them since the seam existed, and the scaffolded `apps/web/server.ts` passes none — so an app's
|
|
3
|
+
// own middleware reached no process the framework boots, and `x dev` had no parameter to reach at
|
|
4
|
+
// all (measured 2026-09-05: `x dev` passed only the read-replica override). The contract is one
|
|
5
|
+
// file: `apps/<app>/runtime.ts` exports `runtime`, a `RuntimeOverrides`; `x dev` reads it, and
|
|
6
|
+
// `runRole` reads it when its caller passed nothing, so the two boots compose the same chain.
|
|
7
|
+
|
|
8
|
+
// why: a directory's existence — `Bun.file().exists()` answers for files, and `apps/` is a directory.
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
// why: Bun exposes no path-join primitive; each candidate is joined to the app root.
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import type { RuntimeOverrides } from './runtime-overrides';
|
|
13
|
+
|
|
14
|
+
/** The one file an app writes, per app directory. */
|
|
15
|
+
export const APP_RUNTIME_GLOB = 'apps/*/runtime.ts';
|
|
16
|
+
/** The export that file makes — a `RuntimeOverrides`. */
|
|
17
|
+
export const APP_RUNTIME_EXPORT = 'runtime';
|
|
18
|
+
|
|
19
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
20
|
+
typeof value === 'object' && value !== null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The first `apps/<app>/runtime.ts` exporting `runtime`, or `undefined` when no app declares one.
|
|
24
|
+
* Sorted, so two apps answer in one order; the object is handed on as declared, because every key
|
|
25
|
+
* of `RuntimeOverrides` already means "replace the resolved default" wherever a boot reads it.
|
|
26
|
+
*/
|
|
27
|
+
export async function loadAppRuntime(root: string): Promise<RuntimeOverrides | undefined> {
|
|
28
|
+
// A root with no `apps/` — a bare test fixture, a directory that does not exist — is no app
|
|
29
|
+
// declaring an override, never a boot failure: the scan's ENOENT is answered as "none".
|
|
30
|
+
if (!existsSync(join(root, 'apps'))) return undefined;
|
|
31
|
+
const files: string[] = [];
|
|
32
|
+
for await (const file of new Bun.Glob(APP_RUNTIME_GLOB).scan({ cwd: root })) files.push(file);
|
|
33
|
+
for (const file of files.sort()) {
|
|
34
|
+
const module = (await import(join(root, file))) as Record<string, unknown>;
|
|
35
|
+
const exported = module[APP_RUNTIME_EXPORT];
|
|
36
|
+
if (isRecord(exported)) return exported as RuntimeOverrides;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
package/src/bin.ts
CHANGED
|
@@ -3,12 +3,30 @@
|
|
|
3
3
|
// dispatch.ts, so the whole CLI is testable without spawning a process.
|
|
4
4
|
|
|
5
5
|
import { dispatch } from './dispatch';
|
|
6
|
+
import { resolveLocalCli } from './local-cli';
|
|
6
7
|
// The writes themselves are `write-line.ts`: `create-ultimate`'s entry point needs the identical
|
|
7
8
|
// one, and a second copy of a note about pipe truncation is a second copy that drifts. Two sinks,
|
|
8
9
|
// because fd 1 is not always this process's to write on — `x mcp serve --transport stdio` hands it
|
|
9
10
|
// to the protocol, and `dispatch` addresses that result to the second.
|
|
10
11
|
import { writeErrorLine, writeLine } from './write-line';
|
|
11
12
|
|
|
13
|
+
// An app's own CLI sees the app's entity registry; a global one does not. Hand over before
|
|
14
|
+
// deciding anything — see local-cli.ts for the zero-entity manifest this prevents. On fd 2, so a
|
|
15
|
+
// `--json` consumer reading fd 1 sees exactly the child's one document. `process.execPath` is the
|
|
16
|
+
// Bun that is already running, never a `PATH` lookup: the child's `import.meta.path` resolves to
|
|
17
|
+
// the app's file, so its own `resolveLocalCli` answers "same file" and the chain stops at one hop.
|
|
18
|
+
const local = resolveLocalCli({ cwd: process.cwd(), selfPath: import.meta.path, env: Bun.env });
|
|
19
|
+
if (local !== undefined) {
|
|
20
|
+
writeErrorLine(`x: using the app's own @ultimat3/cli at ${local}`);
|
|
21
|
+
const child = Bun.spawn([process.execPath, local, ...Bun.argv.slice(2)], {
|
|
22
|
+
stdin: 'inherit',
|
|
23
|
+
stdout: 'inherit',
|
|
24
|
+
stderr: 'inherit',
|
|
25
|
+
env: Bun.env,
|
|
26
|
+
});
|
|
27
|
+
process.exit(await child.exited);
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
const code = await dispatch({
|
|
13
31
|
argv: Bun.argv.slice(2),
|
|
14
32
|
cwd: process.cwd(),
|
package/src/budgets.ts
CHANGED
|
@@ -111,7 +111,7 @@ export function checkBudgets(
|
|
|
111
111
|
if (js !== null && measured.jsBytes > js) {
|
|
112
112
|
findings.push({
|
|
113
113
|
code: 'X_BUDGET_EXCEEDED',
|
|
114
|
-
cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
|
|
114
|
+
cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}`,
|
|
115
115
|
fix: `x routes --json to see the chain, then move the heavy import behind hydrate: 'interaction'`,
|
|
116
116
|
docs: ERROR_DOCS_URL,
|
|
117
117
|
at: route.url,
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// One responsibility: compose the three halves — find a browser, connect to it, attach a page —
|
|
2
|
+
// into the one object `installE2eDriver({ page })` takes, plus the way to shut it down.
|
|
3
|
+
//
|
|
4
|
+
// **Absent is a SKIP, never a failure, and that is a requirement rather than a state.** A CI box
|
|
5
|
+
// with no Chrome must not turn the `e2e` step red for a reason unrelated to the change, which is
|
|
6
|
+
// the rule `packages/cli/CLAUDE.md` already states about `x shot`, `x pr` and `x ci`.
|
|
7
|
+
// `openE2eBrowserIfAvailable` is that door; `openE2eBrowser` refuses by name for a caller that has
|
|
8
|
+
// already decided a browser is required.
|
|
9
|
+
|
|
10
|
+
import { finiteCount } from '@ultimat3/core';
|
|
11
|
+
import type { CdpConnection } from './cdp-connection';
|
|
12
|
+
import { cdpConnect } from './cdp-connection';
|
|
13
|
+
import { cdpE2ePage } from './cdp-e2e-page';
|
|
14
|
+
import { CdpBrowserMissingError } from './cdp-errors';
|
|
15
|
+
import type { LaunchedBrowser } from './cdp-launch';
|
|
16
|
+
import { CHROME_CANDIDATES, findChrome, launchChrome } from './cdp-launch';
|
|
17
|
+
import type { E2eBrowserPage } from './e2e-page';
|
|
18
|
+
|
|
19
|
+
/** How long a launch, a connect or a single CDP call may take. One number, three deadlines. */
|
|
20
|
+
export const DEFAULT_CDP_TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export interface E2eBrowser {
|
|
23
|
+
readonly page: E2eBrowserPage;
|
|
24
|
+
/** Idempotent, and it closes both halves: the CDP socket, then the process and its profile. */
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface OpenE2eBrowserOptions {
|
|
29
|
+
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
30
|
+
readonly timeoutMs?: number | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Screened HERE, before a browser exists, and not where it lands. It becomes three deadlines — the
|
|
35
|
+
* launch, the handshake and every CDP call — and `Number(process.env.E2E_TIMEOUT ?? '')` is `NaN`
|
|
36
|
+
* for an unset variable and is not nullish, so `??` keeps it: a `setTimeout` given `NaN` fires at
|
|
37
|
+
* 1ms in this Bun, which makes every call report `X_CDP_TIMEOUT` against a browser that was
|
|
38
|
+
* answering. A misdiagnosis reported as a test failure is worse than the failure.
|
|
39
|
+
*/
|
|
40
|
+
const budget = (options: OpenE2eBrowserOptions): number =>
|
|
41
|
+
finiteCount('openE2eBrowser', 'timeoutMs', options.timeoutMs ?? DEFAULT_CDP_TIMEOUT_MS);
|
|
42
|
+
|
|
43
|
+
const compose = (
|
|
44
|
+
launched: LaunchedBrowser,
|
|
45
|
+
connection: CdpConnection,
|
|
46
|
+
page: E2eBrowserPage,
|
|
47
|
+
): E2eBrowser => ({
|
|
48
|
+
page,
|
|
49
|
+
close(): void {
|
|
50
|
+
// The socket first: closing the process out from under an open connection makes every
|
|
51
|
+
// in-flight call report "the browser closed the CDP connection", which is true and useless.
|
|
52
|
+
connection.close();
|
|
53
|
+
launched.close();
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Launch a browser and attach one page to it. Refuses with `X_CDP_BROWSER_MISSING` when there is
|
|
59
|
+
* nothing to launch — the caller that wants a skip asks `openE2eBrowserIfAvailable` instead.
|
|
60
|
+
*/
|
|
61
|
+
export async function openE2eBrowser(options: OpenE2eBrowserOptions = {}): Promise<E2eBrowser> {
|
|
62
|
+
const timeoutMs = budget(options);
|
|
63
|
+
const executable = await findChrome(options.env ?? process.env);
|
|
64
|
+
if (executable === undefined) throw new CdpBrowserMissingError({ tried: CHROME_CANDIDATES });
|
|
65
|
+
return openLaunched(executable, timeoutMs);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `undefined` when this machine has no browser. Every other failure still throws. */
|
|
69
|
+
export async function openE2eBrowserIfAvailable(
|
|
70
|
+
options: OpenE2eBrowserOptions = {},
|
|
71
|
+
): Promise<E2eBrowser | undefined> {
|
|
72
|
+
const timeoutMs = budget(options);
|
|
73
|
+
const executable = await findChrome(options.env ?? process.env);
|
|
74
|
+
if (executable === undefined) return undefined;
|
|
75
|
+
return openLaunched(executable, timeoutMs);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The half both doors share. Each step undoes the ones before it on the way out: a Chrome that
|
|
80
|
+
* launched and then refused the CDP handshake would otherwise be left running, holding its profile
|
|
81
|
+
* directory, for the rest of the test process — one leaked browser per failing suite.
|
|
82
|
+
*/
|
|
83
|
+
async function openLaunched(executable: string, timeoutMs: number): Promise<E2eBrowser> {
|
|
84
|
+
const launched = await launchChrome({ executable, timeoutMs });
|
|
85
|
+
let connection: CdpConnection;
|
|
86
|
+
try {
|
|
87
|
+
connection = await cdpConnect({ endpoint: launched.endpoint, timeoutMs });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
launched.close();
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const page = await cdpE2ePage({ connection, loadTimeoutMs: timeoutMs });
|
|
94
|
+
return compose(launched, connection, page);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
connection.close();
|
|
97
|
+
launched.close();
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|