@ultimat3/cli 19.2.0 → 19.3.1
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 +108 -6
- package/package.json +29 -29
- package/src/app-boundaries.ts +11 -2
- package/src/app-load.ts +5 -1
- package/src/budgets.ts +17 -6
- package/src/cmd-dev.ts +29 -39
- package/src/cmd-doctor.ts +61 -23
- package/src/cmd-generate.ts +5 -2
- package/src/cmd-i18n.ts +10 -3
- package/src/cmd-jobs.ts +56 -10
- package/src/cmd-test.ts +15 -10
- package/src/db-seed.ts +2 -1
- package/src/dev-queue.ts +16 -2
- package/src/dev-reload.ts +46 -0
- package/src/dev-runtime.ts +4 -1
- package/src/dev-sync.ts +11 -3
- package/src/dev-watch-tree.ts +226 -0
- package/src/dev-watch.ts +59 -37
- package/src/doctor-offline.ts +122 -0
- package/src/error-catalog.ts +4 -5
- package/src/fix-command.ts +40 -1
- package/src/fix-path.ts +10 -11
- package/src/flag-number.ts +15 -0
- package/src/generate-kinds.ts +54 -4
- package/src/generate-write.ts +25 -2
- package/src/gitignore.ts +145 -0
- package/src/hold.ts +50 -17
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -1
- package/src/island-states-load.ts +2 -1
- package/src/jobs-driver.ts +4 -1
- package/src/mcp-host.ts +18 -9
- package/src/parse.ts +17 -0
- package/src/path-segments.ts +14 -0
- package/src/prerender.ts +46 -20
- package/src/retry-memo.ts +37 -0
- package/src/serve.ts +5 -1
- package/src/source-files.ts +3 -1
- package/src/sw-artifacts.ts +71 -7
- package/src/templates/admin-page.ts +49 -1
- package/src/templates/scaffold-container.ts +12 -0
- package/src/templates/scaffold-repo.ts +7 -2
- package/src/test-passes.ts +79 -0
- package/src/test-shards.ts +110 -36
- package/src/verify-checks.ts +6 -6
- package/src/verify-step.ts +4 -4
- package/src/verify-tests.ts +14 -2
package/CLAUDE.md
CHANGED
|
@@ -8,12 +8,13 @@ Tier 5. May import tiers 0–4. Nothing imports this except `create-ultimate`.
|
|
|
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 |
|
|
11
|
-
| Test execution | `test-shards.ts`'s `testArgs` — ONE `bun test --parallel=N
|
|
11
|
+
| Test execution | `test-shards.ts`'s `testArgs` — ONE `bun test --parallel=N` per PASS (`test-passes.ts`; a selection mixing `live`/`e2e` files with the rest is two passes, the serial one at `--parallel=1`), never N processes this repo packs itself. It did pack them, largest-first greedy over file SIZE, and the packer was deleted for buying **nothing**: four interleaved runs each on the 1296-file unit corpus gave 58.2/60.0/65.0/66.5s hand-packed against 54.5/57.8/61.7/64.5s under `--parallel=8`, within noise, because both are work-bound — 436.7s of file time is a 54.6s floor on 8 workers and the slowest single file is 20.5s. A greedy pack of 1296 small items lands near-optimal by accident. `--timings` is refused on the same evidence (#342). `--parallel` implies `--isolate`, so the per-file module registry is unchanged, and the per-worker database is too: `@ultimat3/testing`'s `workerId` already read `BUN_TEST_WORKER_ID`, which Bun sets 1..N. `ULTIMATE_TEST_WORKER` is set only for a single-shard `x test --worker I` rerun, which is one process |
|
|
12
12
|
| 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 |
|
|
13
13
|
| 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 |
|
|
14
14
|
| 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 |
|
|
15
15
|
| 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) |
|
|
16
16
|
| 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 |
|
|
17
|
+
| Passthrough | `CommandSpec.passthrough`, **declared**. `ParsedArgs.passthrough` says "handed to the underlying tool verbatim" and had no reader anywhere, so `x test unit -- --coverage --bail` parsed both flags and dropped both — a run that measured nothing reporting what a coverage run reports. `x test` forwards them to `bun test` (before the file list) and prints them back in its reproduce line; every other command refuses a non-empty `--` tail with `X_CLI_BAD_FLAG` rather than dropping it. `help`/`version` short-circuit ahead of the command and are exempt |
|
|
17
18
|
| 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 |
|
|
18
19
|
| 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 20 steps failed', { ok: true })` answered `ok: true` and `exitCodeFor` exited **0** on it — a green CI over a red command (`command.test.ts`) |
|
|
19
20
|
| I/O | only `dispatch.ts` renders or exits; commands return `CommandResult` |
|
|
@@ -81,7 +82,7 @@ source only: a test file's import is not judged, because `packages/*` here decla
|
|
|
81
82
|
read is its own finding rather than a silent skip — a skipped workspace is a hiding place for the
|
|
82
83
|
very edge the rule is looking for.
|
|
83
84
|
|
|
84
|
-
`app-permissions.ts` is the `policy` step
|
|
85
|
+
`app-permissions.ts` is the `policy` step. Two references in the whole
|
|
85
86
|
framework are bare strings nothing checks — `RoleDef.grants` and `RouteGuard.permission` — while
|
|
86
87
|
`can()` calls `assertPermission` and throws `X_PERMISSION_UNKNOWN` on the first request that
|
|
87
88
|
reaches the route. So `x new` shipped an app that granted `dashboard:read`, required it on
|
|
@@ -108,9 +109,19 @@ halves are `undefined`/empty with no replica configured, and an EMBEDDED binding
|
|
|
108
109
|
PGlite has no standby. Not `@ultimat3/http`'s pipeline, which would make the HTTP tier know what a
|
|
109
110
|
database is; the boot is the only tier that may know about a request and a pool.
|
|
110
111
|
|
|
112
|
+
`flag-number.ts`'s `portPairAfter` is the one suggestion for a caller that binds a PAIR, and
|
|
113
|
+
`neighbouringPort` is for a caller that binds one port. `x dev` takes `PORT` **and** `PORT + 1`, so
|
|
114
|
+
the neighbour of a taken web port is the sync port — both `x doctor`'s `X_PORT_IN_USE` and
|
|
115
|
+
`dev-sync.ts`'s handed back the port the finding had just named as taken, each under a test that
|
|
116
|
+
said it ended the failure. It answers downward at the top of the range for the reason
|
|
117
|
+
`neighbouringPort` does, and one further: the suggestion's own neighbour has to exist, or
|
|
118
|
+
`syncPortFor` refuses it with `X_PORT_INVALID`.
|
|
119
|
+
|
|
111
120
|
`port-probe.ts` is the one `portFree`, because two commands ask it and must not disagree:
|
|
112
|
-
`x doctor` reports it as a finding for BOTH ports `x dev` binds — the web port and the
|
|
113
|
-
|
|
121
|
+
`x doctor` reports it as a finding for BOTH ports `x dev` binds — the web port and the sync port,
|
|
122
|
+
which it derives with `syncPortFor` (the boot's own rule: a `--port` at the top of the range has no
|
|
123
|
+
sync port at all, so the probe reports `X_PORT_INVALID` instead of probing the port BELOW it, which
|
|
124
|
+
is what `neighbouringPort` answered there) — each labelled with the role that wants it — and `startSync` asks it after a failed
|
|
114
125
|
`listenSyncNode` so a taken neighbour is `X_PORT_IN_USE` rather than `X_CLI_UNEXPECTED` over
|
|
115
126
|
`Bun.serve`'s own English rendered into a `cause:`. It is ASKED, never read off the caught value,
|
|
116
127
|
which is what `scripts/catch-render.ts` refuses; anything else the listener failed on is re-thrown
|
|
@@ -119,6 +130,36 @@ untouched. `x doctor` also probes `DATABASE_URL` with a real `select 1` through
|
|
|
119
130
|
credentials, which is the case an operator most needs told about — and reports `X_DB_UNAVAILABLE`
|
|
120
131
|
with that package's own two-branch fix. An EMBEDDED binding is not probed: that lock is `x dev`'s.
|
|
121
132
|
|
|
133
|
+
`doctor-offline.ts` is `x doctor`'s offline-fallback check, and it is the shape of a diagnostic
|
|
134
|
+
that could not be closed. The probe was the literal `apps/web/app/offline.tsx` — a filename
|
|
135
|
+
`assertRouteFilename` REFUSES (`X_ROUTE_FILE_INVALID`: the directory is the URL, so a page is
|
|
136
|
+
`page.tsx`) — while `x new` scaffolds `apps/web/site/offline/page.tsx` and the finding's own `fix:`
|
|
137
|
+
writes `apps/web/app/offline/page.tsx`. Three different paths, so every app the framework has ever
|
|
138
|
+
produced reported `X_PWA_NO_OFFLINE_FALLBACK` from its first `x doctor`, running the fix changed
|
|
139
|
+
nothing, and the app that HAD the route was told it did not. It now reads the DECLARED
|
|
140
|
+
`pwa.offline.fallback` through `loadPwaArtifacts` and matches it against `describeRoutes()` —
|
|
141
|
+
**`site/` only**, `As of 2026-09`. It accepted `app/` too, on the argument that both surfaces answer
|
|
142
|
+
the same URL; true, and not the question. `SURFACE_SPECS` allows `app/` exactly `stream | ssr`, only
|
|
143
|
+
a `static` route is prerendered, and `serviceWorkerArtifacts` precaches a rendered DOCUMENT
|
|
144
|
+
(`documents.get(fallback)`) — so an `app/` fallback has nothing to precache and the offline
|
|
145
|
+
navigation reaches the network it exists to survive without. The check and its own `fix:` disagreed
|
|
146
|
+
about one code. Not closed by this: a `site/` route declaring `render: 'ssr'` is not prerendered
|
|
147
|
+
either, and `NavigableRoute` carries no render mode. An app whose modules will not import is not
|
|
148
|
+
judged at all, which is
|
|
149
|
+
`appEntities`' rule (`schema-drift.ts`) one registry over. The fix is `x g route <name>
|
|
150
|
+
--surface site` only where the fallback is one path segment the generator can really produce: a
|
|
151
|
+
nested path slugifies to a DIFFERENT url, so there it is the config edit instead — a `fix:` that
|
|
152
|
+
runs and leaves the finding where it was is the defect this file exists to end. **`site`, not
|
|
153
|
+
`app`**: the document that answers a lost network has to render with no network, no session and no
|
|
154
|
+
database, which `app/` (`ssr | stream`) cannot promise, and it is the line `@ultimat3/pwa`'s own
|
|
155
|
+
`X_PWA_NO_OFFLINE_FALLBACK` hands out for the same code — two fixes for one code are two answers.
|
|
156
|
+
|
|
157
|
+
`X_ENV_MISSING`'s fix is the file write, for the same reason. It was `x new --force`, which cannot
|
|
158
|
+
run where the reader is standing: `x new` takes a `<name>` positional (`X_CLI_BAD_FLAG` without
|
|
159
|
+
one) and with one it scaffolds a SECOND app beside the broken one. `cp .env.example
|
|
160
|
+
.env.development` — the committed projection of `envSchema`, which is what `x env example` writes
|
|
161
|
+
and `X_ENV_EXAMPLE_DRIFT` keeps honest.
|
|
162
|
+
|
|
122
163
|
`i18n-index.ts` is the one writer of an app's `packages/i18n/src/index.ts`, shared by `x g` and
|
|
123
164
|
`x i18n add|sync`. A catalog on disk and a SELECTABLE locale were two different sets: `x i18n add
|
|
124
165
|
fr` wrote the file, exited 0, and left `x verify --only i18n` red with `X_CATALOG_UNREGISTERED`
|
|
@@ -406,7 +447,26 @@ cannot be missing from the precache manifest.
|
|
|
406
447
|
|---|---|
|
|
407
448
|
| `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 |
|
|
408
449
|
| `serve.ts` | the same two routes in the container, from the same call |
|
|
409
|
-
| `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 |
|
|
450
|
+
| `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. In TWO halves, `As of 2026-09-06`: `serviceWorkerHead(pwa)` before the render loop, because every document has to name the script, and `serviceWorkerArtifacts` AFTER it, because a precache revision is the content hash of a document that does not exist yet |
|
|
451
|
+
|
|
452
|
+
**A precache revision is the DOCUMENT's content hash, and until 2026-09-06 it was the build id.**
|
|
453
|
+
`pwaRoutes` projected four of `PwaRoute`'s eight fields, so every route entry read
|
|
454
|
+
`{"url":"/","revision":"<buildId>","bytes":0}`: two deploys of a byte-identical site re-fetched
|
|
455
|
+
every precached page, which is the one thing `packages/pwa/src/precache.ts`' own header says must
|
|
456
|
+
never happen, and `DEFAULT_PRECACHE_WARN_BYTES` was a 5 MB budget over a total that could not count
|
|
457
|
+
one byte of HTML. The cause was ORDER — `prerender.ts` emitted the worker before the render loop —
|
|
458
|
+
so the emission moved after it and `ServiceWorkerInput.documents` carries `renderStatic`'s own
|
|
459
|
+
`hash` and byte count per rendered path. `x dev` and `serve.ts` pass none and keep the build id:
|
|
460
|
+
neither has rendered a page at boot, and inventing a hash for bytes that do not exist is a revision
|
|
461
|
+
that never changes when the page does. **The offline document is fed through its own pair**, because
|
|
462
|
+
`buildPrecacheManifest` adds it ITSELF as `reason: 'fallback'` ahead of every route and `add()`
|
|
463
|
+
keeps the first entry per url — so the route entry of the same url is shadowed and only
|
|
464
|
+
`offlineFallbackRevision` / `offlineFallbackBytes` (`ServiceWorkerConfig`, `@ultimat3/pwa`) can
|
|
465
|
+
decide it. That pair was declared and fed by nobody for one commit; `serviceWorkerArtifacts` reads
|
|
466
|
+
the fallback path out of the same `documents` map, so the one page an offline navigation depends on
|
|
467
|
+
is content-addressed like every other. A fallback this pass did not render — no route serves it,
|
|
468
|
+
which `x doctor` reports as `X_PWA_NO_OFFLINE_FALLBACK` — is absent from the map and keeps the
|
|
469
|
+
build id.
|
|
410
470
|
|
|
411
471
|
**Registration is an EXTERNAL script, never inline**, and that is a CSP fact rather than a
|
|
412
472
|
preference: `startWeb` computes a `script-src` sha256 per inline script, so an unhashed one is
|
|
@@ -996,7 +1056,11 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
996
1056
|
| `statement-loop.ts` | one verdict → the finding, the panel fact, the overlay notice and the log line |
|
|
997
1057
|
| `dev-policy.ts` | which actors to ask about, and which capability each policy gates |
|
|
998
1058
|
| `cmd-dev.ts` | boot order, mounting `/_x`, installing the span exporter, the file watcher |
|
|
999
|
-
| `dev-watch.ts` | which
|
|
1059
|
+
| `dev-watch.ts` | which paths under the app root `x dev` may not watch: the app's own `.gitignore` (`gitignore.ts`) plus a floor of five directory names an ignore file cannot be relied on to carry — `.git`, `.x`, `node_modules`, `.personal`, `.claude` — every one of them dotted or an install, matched as a path SEGMENT at any depth |
|
|
1060
|
+
| `dev-watch-tree.ts` | the watch SET: one `watch(dir, { recursive: false })` per ADMITTED directory, a new directory picked up on `rename`, a removed one giving its descriptor back, and the 30ms trailing debounce |
|
|
1061
|
+
| `dev-reload.ts` | one rebuild at a time — a tick arriving mid-build coalesces into ONE trailing rebuild, for the newest file |
|
|
1062
|
+
| `gitignore.ts` | what git ignores, as data: parse, the ancestor chain up to the repository, and last-match-wins |
|
|
1063
|
+
| `path-segments.ts` | `hasPathSegment` — a directory of that exact name, never a substring of one |
|
|
1000
1064
|
| `style-bundle.ts` / `style-routes.ts` | a surface's CSS as one content-hashed file under `/styles/`, served `immutable` — `island-bundle.ts` / `island-routes.ts`' shape one asset over. It was an inline `<style>` until 2026-09-06: 156,738 bytes, identical on every page, inside a `private, no-store` document. The URL is the hash alone, no surface in the name: a surface is not a property of the bytes, and an app whose only CSS is its global layer would otherwise write three identical files into its static export and three entries into a precache manifest that has a budget |
|
|
1001
1065
|
| `mcp-host.ts` | the `DevCapabilities` half of `@ultimat3/mcp`'s `DevHost` — db, tests, logs, verify |
|
|
1002
1066
|
| `mcp-db-target.ts` | which database the host is pointed at: whether it is a branch, and whether it is production |
|
|
@@ -1005,6 +1069,44 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
1005
1069
|
| `mcp-test-output.ts` | reading `bun test`'s own summary back into a `TestRun` |
|
|
1006
1070
|
| `cmd-mcp.ts` | `x mcp serve`: the two transports, and the local developer's caller |
|
|
1007
1071
|
|
|
1072
|
+
**The watch set is a REGISTRATION decision, not a filter, `As of 2026-09-06`.** `watch(root, {
|
|
1073
|
+
recursive: true })` takes one inotify descriptor per directory in the tree — measured on `x dev`
|
|
1074
|
+
against `examples/dummy`: **110 descriptors, 39 of them (35%) under `.x/` and `node_modules/`**, and
|
|
1075
|
+
on a monorepo root **1901, of which 1490 (78%) were `.git/` and `node_modules/`**, where one
|
|
1076
|
+
`git status` delivered 5 `.git/index` events into the JS callback. `isIgnoredPath` answered
|
|
1077
|
+
correctly every time and answered too late: the kernel queue entry, the callback and the slot out of
|
|
1078
|
+
`max_user_watches` (8192 on many distributions) were already spent. Bun 1.4.0's `fs.watch` has no
|
|
1079
|
+
ignore option, so `dev-watch-tree.ts` walks the root itself and registers one non-recursive watcher
|
|
1080
|
+
per admitted directory, pruning at descent.
|
|
1081
|
+
|
|
1082
|
+
**And the ignore set is the app's own `.gitignore`.** Seven hand-listed directory names were wrong
|
|
1083
|
+
in both directions. Nothing read the ignore file, so `touch tsconfig.tsbuildinfo` — the file every
|
|
1084
|
+
`bun run typecheck` rewrites, named by `x new`'s own scaffolded `.gitignore` — logged
|
|
1085
|
+
`reloaded tsconfig.tsbuildinfo in 113ms`, a full `appManifest()` plus `buildIslands()`; on the
|
|
1086
|
+
framework root 54 git-ignored directories were unfiltered. And `dist` and `coverage` were matched at
|
|
1087
|
+
ANY depth, so an app's own `/dist` or `/coverage` ROUTE never reloaded — silently, which the file's
|
|
1088
|
+
own header calls worse than a spurious rebuild. `gitignore.ts` reads it with git's own anchoring
|
|
1089
|
+
(unanchored at any depth, a leading `/` or an inner slash where it is written, `!` re-including,
|
|
1090
|
+
a trailing `/` directory-only) and walks ANCESTOR ignore files up to the directory holding `.git` —
|
|
1091
|
+
`examples/dummy` carries none of its own and every rule about it lives in the repository root's. The
|
|
1092
|
+
file is re-read, and the whole watch set re-walked, on any write naming `.gitignore`; nothing ever
|
|
1093
|
+
spawns `git check-ignore`. `x new`'s scaffold writes `/dist/` and `/coverage/` **root-anchored** for
|
|
1094
|
+
this reason, plus `packages/*/dist/`. It is `fix-path.ts`'s parser, lifted: two readers of one file
|
|
1095
|
+
are two answers to what an app committed.
|
|
1096
|
+
|
|
1097
|
+
**A watcher event carries no filename when the WATCHED directory itself moves.** Bun's recursive
|
|
1098
|
+
watcher delivers `undefined` — not `null` — on `mv myapp myapp2`, a re-clone or a volume remount,
|
|
1099
|
+
and `isIgnoredPath(undefined)` threw a `TypeError` inside an fs callback, outside any `try`, with no
|
|
1100
|
+
`uncaughtException` handler: `x dev` died with a stack trace. The listener is total over
|
|
1101
|
+
`string | Buffer | null | undefined` and logs `dev.watch.unnamed_event` once.
|
|
1102
|
+
|
|
1103
|
+
**The reload has an in-flight guard, and it is the state that needed it.** A 45ms drip — a slow
|
|
1104
|
+
`git checkout`, a formatter walking files, `x db gen` — measured **40 reloads for 40 files**, each
|
|
1105
|
+
launching `Promise.all([appManifest, buildIslands])` while the previous still ran and assigning
|
|
1106
|
+
`state.manifest` / `state.islands` in COMPLETION order, so a slower earlier tick could land on top
|
|
1107
|
+
of a newer one. `coalesceReloads` (`dev-reload.ts`) keeps the LAST tick that arrived during a
|
|
1108
|
+
rebuild and starts exactly one more.
|
|
1109
|
+
|
|
1008
1110
|
`api-routes.ts` is the app's own API surface, composed **once** and mounted by both `cmd-dev.ts`
|
|
1009
1111
|
and `serve.ts`: `listActions().map(toRoute)` from `@ultimat3/action` plus
|
|
1010
1112
|
`listQueries().map(toQueryRoute)` from `@ultimat3/query`. Two lists is how `query.client()`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "19.
|
|
3
|
+
"version": "19.3.1",
|
|
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": "19.
|
|
41
|
-
"@ultimat3/admin": "19.
|
|
42
|
-
"@ultimat3/ai": "19.
|
|
43
|
-
"@ultimat3/auth": "19.
|
|
44
|
-
"@ultimat3/cache": "19.
|
|
45
|
-
"@ultimat3/core": "19.
|
|
46
|
-
"@ultimat3/db": "19.
|
|
47
|
-
"@ultimat3/entity": "19.
|
|
48
|
-
"@ultimat3/flags": "19.
|
|
49
|
-
"@ultimat3/http": "19.
|
|
50
|
-
"@ultimat3/i18n": "19.
|
|
51
|
-
"@ultimat3/jobs": "19.
|
|
52
|
-
"@ultimat3/mail": "19.
|
|
53
|
-
"@ultimat3/manifest": "19.
|
|
54
|
-
"@ultimat3/mcp": "19.
|
|
55
|
-
"@ultimat3/money": "19.
|
|
56
|
-
"@ultimat3/notify": "19.
|
|
57
|
-
"@ultimat3/policy": "19.
|
|
58
|
-
"@ultimat3/pwa": "19.
|
|
59
|
-
"@ultimat3/query": "19.
|
|
60
|
-
"@ultimat3/realtime": "19.
|
|
61
|
-
"@ultimat3/render": "19.
|
|
62
|
-
"@ultimat3/schema": "19.
|
|
63
|
-
"@ultimat3/scraping": "19.
|
|
64
|
-
"@ultimat3/seo": "19.
|
|
65
|
-
"@ultimat3/storage": "19.
|
|
66
|
-
"@ultimat3/testing": "19.
|
|
67
|
-
"@ultimat3/time": "19.
|
|
40
|
+
"@ultimat3/action": "19.3.1",
|
|
41
|
+
"@ultimat3/admin": "19.3.1",
|
|
42
|
+
"@ultimat3/ai": "19.3.1",
|
|
43
|
+
"@ultimat3/auth": "19.3.1",
|
|
44
|
+
"@ultimat3/cache": "19.3.1",
|
|
45
|
+
"@ultimat3/core": "19.3.1",
|
|
46
|
+
"@ultimat3/db": "19.3.1",
|
|
47
|
+
"@ultimat3/entity": "19.3.1",
|
|
48
|
+
"@ultimat3/flags": "19.3.1",
|
|
49
|
+
"@ultimat3/http": "19.3.1",
|
|
50
|
+
"@ultimat3/i18n": "19.3.1",
|
|
51
|
+
"@ultimat3/jobs": "19.3.1",
|
|
52
|
+
"@ultimat3/mail": "19.3.1",
|
|
53
|
+
"@ultimat3/manifest": "19.3.1",
|
|
54
|
+
"@ultimat3/mcp": "19.3.1",
|
|
55
|
+
"@ultimat3/money": "19.3.1",
|
|
56
|
+
"@ultimat3/notify": "19.3.1",
|
|
57
|
+
"@ultimat3/policy": "19.3.1",
|
|
58
|
+
"@ultimat3/pwa": "19.3.1",
|
|
59
|
+
"@ultimat3/query": "19.3.1",
|
|
60
|
+
"@ultimat3/realtime": "19.3.1",
|
|
61
|
+
"@ultimat3/render": "19.3.1",
|
|
62
|
+
"@ultimat3/schema": "19.3.1",
|
|
63
|
+
"@ultimat3/scraping": "19.3.1",
|
|
64
|
+
"@ultimat3/seo": "19.3.1",
|
|
65
|
+
"@ultimat3/storage": "19.3.1",
|
|
66
|
+
"@ultimat3/testing": "19.3.1",
|
|
67
|
+
"@ultimat3/time": "19.3.1",
|
|
68
68
|
"babel-preset-solid": "^1.9.15"
|
|
69
69
|
}
|
|
70
70
|
}
|
package/src/app-boundaries.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
|
18
18
|
import type { BoundaryRule, ImportGraph } from '@ultimat3/render';
|
|
19
19
|
import { checkSurfaceBoundary, importGraph, SURFACES } from '@ultimat3/render';
|
|
20
20
|
import type { Finding } from './output';
|
|
21
|
+
import { hasPathSegment } from './path-segments';
|
|
21
22
|
import { quoteArg } from './shell-quote';
|
|
22
23
|
|
|
23
24
|
export const BOUNDARY_CODES = [
|
|
@@ -56,8 +57,16 @@ const isDbSpecifier = (specifier: string): boolean =>
|
|
|
56
57
|
specifier.endsWith('/db') ||
|
|
57
58
|
/^@[^/]+\/db$/.test(specifier) ||
|
|
58
59
|
specifier === 'drizzle-orm';
|
|
60
|
+
/**
|
|
61
|
+
* `node:http` and `node:https` are HTTP too, and the anchor could not reach them: `http` had to
|
|
62
|
+
* follow a `/` or start the string, and a `node:` specifier has a colon there. So the one spelling
|
|
63
|
+
* that needs no dependency at all — a service reaching straight for the runtime's own client — was
|
|
64
|
+
* the one spelling this rule could not see. Matched on the boundary character rather than by
|
|
65
|
+
* listing every prefix, and still anchored at both ends: `node:http2-fake` and
|
|
66
|
+
* `@ultimat3/https-client` are names, not HTTP.
|
|
67
|
+
*/
|
|
59
68
|
const isHttpSpecifier = (specifier: string): boolean =>
|
|
60
|
-
specifier === '@ultimat3/http' || /(
|
|
69
|
+
specifier === '@ultimat3/http' || /(^|[/:])https?($|\/)/.test(specifier);
|
|
61
70
|
|
|
62
71
|
/** The transpiler rejects a shebang, and an app's `bin/` entry points legitimately have one. */
|
|
63
72
|
export const stripShebang = (source: string): string =>
|
|
@@ -236,7 +245,7 @@ export async function readAppSources(root: string): Promise<readonly SourceFile[
|
|
|
236
245
|
for (const pattern of APP_GLOBS) {
|
|
237
246
|
const glob = new Bun.Glob(pattern);
|
|
238
247
|
for await (const path of glob.scan({ cwd: root, absolute: false })) {
|
|
239
|
-
if (path
|
|
248
|
+
if (hasPathSegment(path, 'node_modules') || path.includes('.test.')) continue;
|
|
240
249
|
const posix = path.split('\\').join('/');
|
|
241
250
|
files.push({ path: posix, source: await Bun.file(joinPath(root, posix)).text() });
|
|
242
251
|
}
|
package/src/app-load.ts
CHANGED
|
@@ -21,6 +21,7 @@ import '@ultimat3/render/server';
|
|
|
21
21
|
import { collectDeclaredCodes } from './error-contract';
|
|
22
22
|
import type { Finding } from './output';
|
|
23
23
|
import { findingFrom } from './output';
|
|
24
|
+
import { hasPathSegment } from './path-segments';
|
|
24
25
|
|
|
25
26
|
/** Every place an app keeps code the framework has to see. */
|
|
26
27
|
const APP_GLOBS = [
|
|
@@ -95,7 +96,10 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
95
96
|
|
|
96
97
|
for (const pattern of APP_GLOBS) {
|
|
97
98
|
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
98
|
-
|
|
99
|
+
// A SEGMENT, never a substring: an app checked out under
|
|
100
|
+
// `~/dev/node_modules-experiments/myapp` answered `includes('node_modules')` for every
|
|
101
|
+
// file it holds, so this loop imported none of them and the app registered nothing.
|
|
102
|
+
if (hasPathSegment(absolute, 'node_modules') || absolute.includes('.test.')) continue;
|
|
99
103
|
const file = relative(root, absolute).split(sep).join('/');
|
|
100
104
|
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file) || STATES_FILE.test(file)) {
|
|
101
105
|
continue;
|
package/src/budgets.ts
CHANGED
|
@@ -227,9 +227,24 @@ export interface MeasuredJs {
|
|
|
227
227
|
export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
|
|
228
228
|
let jsBytes = 0;
|
|
229
229
|
const entries: MeasuredEntry[] = [];
|
|
230
|
+
// Deduped ONCE, across both readers below, and the unit is the FETCH: a browser downloads a URL
|
|
231
|
+
// once however many times the document names it, so `budget.js` — a byte budget — counts it
|
|
232
|
+
// once. Two instances of one island are two wrappers and one chunk; so are a `<script src>`
|
|
233
|
+
// repeated by a page and its layout, and a src that is also an island entry. Only the island
|
|
234
|
+
// half was deduped, so a document naming one script twice was charged twice and could fail a
|
|
235
|
+
// budget it clears.
|
|
236
|
+
//
|
|
237
|
+
// EXECUTION is a different count and this is deliberately not it. A repeated classic
|
|
238
|
+
// `<script src>` runs once per element (a module runs once per document, off the module map), so
|
|
239
|
+
// the layout-plus-page case above really does execute twice — for zero extra bytes. That is a
|
|
240
|
+
// CPU cost, and this gate is a bound on what the browser downloads and parses. An INLINE script
|
|
241
|
+
// is not in this set for the same reason: two identical inline bodies are two copies of the
|
|
242
|
+
// bytes in the document, so both are charged.
|
|
243
|
+
const fetched = new Set<string>();
|
|
230
244
|
const weigh = async (url: string): Promise<void> => {
|
|
231
245
|
// Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
|
|
232
|
-
if (!url.startsWith('/')) return;
|
|
246
|
+
if (!url.startsWith('/') || fetched.has(url)) return;
|
|
247
|
+
fetched.add(url);
|
|
233
248
|
const file = Bun.file(join(out, url.slice(1)));
|
|
234
249
|
const bytes = (await file.exists()) ? file.size : 0;
|
|
235
250
|
entries.push({ url, bytes });
|
|
@@ -246,13 +261,9 @@ export async function measureDocumentJs(html: string, out: string): Promise<Meas
|
|
|
246
261
|
}
|
|
247
262
|
await weigh(src);
|
|
248
263
|
}
|
|
249
|
-
// Deduped: two instances of one island are two wrappers and one chunk, and a browser that
|
|
250
|
-
// imports the same module twice fetches and executes it once.
|
|
251
|
-
const booted = new Set<string>();
|
|
252
264
|
for (const match of html.matchAll(ENTRY_ATTR)) {
|
|
253
265
|
const url = match.groups?.['url'];
|
|
254
|
-
if (url === undefined
|
|
255
|
-
booted.add(url);
|
|
266
|
+
if (url === undefined) continue;
|
|
256
267
|
await weigh(url);
|
|
257
268
|
}
|
|
258
269
|
return { jsBytes, entries };
|
package/src/cmd-dev.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// alongside it — mounted, never re-implemented — so an agent can introspect the running app.
|
|
5
5
|
// No Docker, no env setup: an unset variable means the embedded default.
|
|
6
6
|
|
|
7
|
-
import { watch } from 'node:fs';
|
|
8
7
|
import { join } from 'node:path';
|
|
9
8
|
import { devShellStyle } from '@ultimat3/admin/dev';
|
|
10
9
|
import type { Role } from '@ultimat3/core';
|
|
@@ -29,6 +28,7 @@ import { devDashboardRoutes, devPanels } from './dev-dashboard';
|
|
|
29
28
|
import { liveFeedLabel } from './dev-live-feed';
|
|
30
29
|
import { clearLock, preflight, writeLock } from './dev-lock';
|
|
31
30
|
import { createStatementLedger } from './dev-n-plus-one';
|
|
31
|
+
import { coalesceReloads } from './dev-reload';
|
|
32
32
|
import { appRoutes } from './dev-render';
|
|
33
33
|
import { replicaOverrides } from './dev-replica';
|
|
34
34
|
import type { RunningRoles } from './dev-roles';
|
|
@@ -39,7 +39,7 @@ import type { DevServices } from './dev-services';
|
|
|
39
39
|
import { describeServices, reportedUrls, resolveServices } from './dev-services';
|
|
40
40
|
import { storageRoutes } from './dev-storage';
|
|
41
41
|
import { createTraceRecorder } from './dev-traces';
|
|
42
|
-
import {
|
|
42
|
+
import { watchTree } from './dev-watch-tree';
|
|
43
43
|
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
44
44
|
import { holdUntilShutdown } from './hold';
|
|
45
45
|
import type { IslandBundle } from './island-bundle';
|
|
@@ -96,26 +96,6 @@ interface DevState {
|
|
|
96
96
|
islands: IslandBundle;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
/**
|
|
100
|
-
* Debounced: a save that touches five files is one reload, not five. What counts as a save at all
|
|
101
|
-
* is `dev-watch.ts` — a reload is a full `appManifest()` plus a `buildIslands()` over every island,
|
|
102
|
-
* so a write this cannot rule out is the most expensive no-op the dev loop has.
|
|
103
|
-
*/
|
|
104
|
-
function watchApp(root: string, onChange: (file: string) => void): () => void {
|
|
105
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
106
|
-
let last = '';
|
|
107
|
-
const watcher = watch(root, { recursive: true }, (_event, filename) => {
|
|
108
|
-
if (filename === null || isIgnoredPath(filename)) return;
|
|
109
|
-
last = filename;
|
|
110
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
111
|
-
timer = setTimeout(() => onChange(last), 30);
|
|
112
|
-
});
|
|
113
|
-
return () => {
|
|
114
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
115
|
-
watcher.close();
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
99
|
export interface StartDevOptions {
|
|
120
100
|
readonly root: string;
|
|
121
101
|
readonly port: number;
|
|
@@ -294,22 +274,32 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
294
274
|
...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
|
|
295
275
|
});
|
|
296
276
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
277
|
+
// One rebuild at a time, and the last save wins: a tick arriving mid-build coalesces into ONE
|
|
278
|
+
// trailing rebuild instead of racing the one in flight for `state.manifest` and `state.islands`.
|
|
279
|
+
const rebuild = coalesceReloads(
|
|
280
|
+
async (file) => {
|
|
281
|
+
const started = performance.now();
|
|
282
|
+
const [{ manifest }, islands] = await Promise.all([
|
|
283
|
+
appManifest(options.root),
|
|
284
|
+
buildIslands(options.root),
|
|
285
|
+
]);
|
|
286
|
+
state.manifest = manifest;
|
|
287
|
+
state.islands = islands;
|
|
288
|
+
state.reloads += 1;
|
|
289
|
+
state.reloadFinding = undefined;
|
|
290
|
+
options.onReload?.(file, Math.round(performance.now() - started));
|
|
291
|
+
},
|
|
292
|
+
// Same rule as a module that will not import: a save the manifest cannot be rebuilt from is
|
|
293
|
+
// a finding on `/_x`, never an unhandled rejection that takes the dev server down.
|
|
294
|
+
(error: unknown, file: string) => {
|
|
295
|
+
state.reloadFinding = { ...findingFrom(error), at: file };
|
|
296
|
+
},
|
|
297
|
+
);
|
|
298
|
+
// Watched one directory at a time, so an ignored one costs no descriptor at all — `dev-watch.ts`
|
|
299
|
+
// decides which, from the app's own `.gitignore`. A recursive watch on the root registered one
|
|
300
|
+
// inotify descriptor per directory in the tree, `.git/`, `node_modules/` and the `.x/` this
|
|
301
|
+
// process writes to included, and filtered the events afterwards.
|
|
302
|
+
const watcher = watchTree({ root: options.root, onChange: rebuild });
|
|
313
303
|
|
|
314
304
|
server = {
|
|
315
305
|
url: running.url ?? `http://localhost:${options.port}`,
|
|
@@ -333,7 +323,7 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
333
323
|
runtime,
|
|
334
324
|
panels,
|
|
335
325
|
async stop() {
|
|
336
|
-
|
|
326
|
+
watcher.close();
|
|
337
327
|
await running.stop();
|
|
338
328
|
await runtime.stop();
|
|
339
329
|
// Released after the roles: a span opened by an in-flight request still has an exporter to
|
package/src/cmd-doctor.ts
CHANGED
|
@@ -4,16 +4,25 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
ENV_EXAMPLE_PATH,
|
|
9
|
+
ERROR_DOCS_URL,
|
|
10
|
+
tryResolveEnvironment,
|
|
11
|
+
usesDevCursorSecret,
|
|
12
|
+
} from '@ultimat3/core';
|
|
8
13
|
import { checkDb, createPostgresClient } from '@ultimat3/db';
|
|
9
14
|
import { STORAGE_SIGNING_SECRET_KEY, usesDevStorageSecret } from '@ultimat3/storage';
|
|
10
15
|
import { findAppRoot, REQUIRED_BUN, versionAtLeast } from './app-root';
|
|
11
16
|
import type { CliCommand, CommandContext } from './command';
|
|
12
17
|
import { checkMigrationSnapshots } from './db-snapshot';
|
|
13
|
-
import {
|
|
18
|
+
import { syncPortFor } from './dev-sync';
|
|
19
|
+
import type { OfflineFallbackFact } from './doctor-offline';
|
|
20
|
+
import { offlineFallbackFinding, offlineFallbackProbe } from './doctor-offline';
|
|
21
|
+
import { intFlagOr, PORT_RANGE, portPairAfter } from './flag-number';
|
|
14
22
|
import { ICON_SOURCE } from './icon-assets';
|
|
15
23
|
import { msg } from './messages';
|
|
16
24
|
import type { CommandResult, Finding } from './output';
|
|
25
|
+
import { findingFrom } from './output';
|
|
17
26
|
import type { ParsedArgs } from './parse';
|
|
18
27
|
import { portFree } from './port-probe';
|
|
19
28
|
import { checkMigrationDrift } from './schema-drift';
|
|
@@ -59,6 +68,12 @@ export interface DoctorProbe {
|
|
|
59
68
|
* separate remedies — one is "generate a migration", the other is "this migration is incomplete".
|
|
60
69
|
*/
|
|
61
70
|
snapshots(): Promise<readonly Finding[]>;
|
|
71
|
+
/**
|
|
72
|
+
* What the app declared as its offline fallback, and which routes it really serves. A FACT and
|
|
73
|
+
* not a finding, because deciding is `offlineFallbackFinding`'s and this seam's whole job is
|
|
74
|
+
* reaching the disk — the same split `drift()` makes one question over.
|
|
75
|
+
*/
|
|
76
|
+
offlineFallback(): Promise<OfflineFallbackFact>;
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
const finding = (code: string, cause: string, fix: string, at?: string): Finding =>
|
|
@@ -66,7 +81,8 @@ const finding = (code: string, cause: string, fix: string, at?: string): Finding
|
|
|
66
81
|
? { code, cause, fix, docs: ERROR_DOCS_URL }
|
|
67
82
|
: { code, cause, fix, docs: ERROR_DOCS_URL, at };
|
|
68
83
|
|
|
69
|
-
|
|
84
|
+
/** The file `x doctor` reports missing, and the one the reader creates. */
|
|
85
|
+
export const ENV_DEVELOPMENT = '.env.development';
|
|
70
86
|
|
|
71
87
|
/** The port `x dev` binds by default, so the probe answers about the port the developer will use. */
|
|
72
88
|
const DEFAULT_DOCTOR_PORT = 3000;
|
|
@@ -80,12 +96,30 @@ const DEFAULT_DOCTOR_PORT = 3000;
|
|
|
80
96
|
* derives `PORT = .port - 1` from it, so it is part of the contract `x dev` runs by.
|
|
81
97
|
*
|
|
82
98
|
* The suggested port moves BOTH: `x dev --port N` occupies N and N+1, so a free N beside a taken
|
|
83
|
-
* N+1 is still not a runnable command.
|
|
99
|
+
* N+1 is still not a runnable command. It did not move both until 2026-09 — the line was
|
|
100
|
+
* `neighbouringPort(probe.port)`, which for the sync finding IS the port the finding is about, and
|
|
101
|
+
* a docblock claiming otherwise is how it survived. `portPairAfter` is the one reader of that rule
|
|
102
|
+
* and `dev-sync.ts`'s own refusal shares it.
|
|
103
|
+
*
|
|
104
|
+
* The sync port is `syncPortFor`, never `neighbouringPort` again: that helper answers 65534 for a
|
|
105
|
+
* web port of 65535 — BELOW the web port, and a port `x dev` never binds — where the boot refuses
|
|
106
|
+
* the run outright with `X_PORT_INVALID`. A probe that reports on a socket the command would never
|
|
107
|
+
* open is answering a question nobody asked, so the boot's own rule decides, and its refusal is
|
|
108
|
+
* reported instead of a probe. Alone, and ahead of the web port: there is no runnable `x dev` at
|
|
109
|
+
* this port whatever the other one answers, and a second finding about it is noise over the cause.
|
|
84
110
|
*/
|
|
85
111
|
async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
|
|
112
|
+
let syncPort: number;
|
|
113
|
+
try {
|
|
114
|
+
syncPort = syncPortFor(probe.port);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
// The boot's own error, carried whole — `findingFrom` reads its code, cause and fix off the
|
|
117
|
+
// value rather than rendering it, which is what `scripts/catch-render.ts` requires.
|
|
118
|
+
return [findingFrom(error)];
|
|
119
|
+
}
|
|
86
120
|
const wanted = [
|
|
87
121
|
{ port: probe.port, role: 'web' },
|
|
88
|
-
{ port:
|
|
122
|
+
{ port: syncPort, role: 'sync' },
|
|
89
123
|
] as const;
|
|
90
124
|
const findings: Finding[] = [];
|
|
91
125
|
for (const entry of wanted) {
|
|
@@ -94,10 +128,7 @@ async function portFindings(probe: DoctorProbe): Promise<readonly Finding[]> {
|
|
|
94
128
|
finding(
|
|
95
129
|
'X_PORT_IN_USE',
|
|
96
130
|
`port ${entry.port} is already listening, and \`x dev --port ${probe.port}\` binds it for the ${entry.role} role`,
|
|
97
|
-
|
|
98
|
-
// `x dev` is guaranteed to accept (`X_CLI_BAD_FLAG` otherwise), which is what
|
|
99
|
-
// `cmd-doctor.test.ts` pins by parsing this line with `x dev`'s own flag reader.
|
|
100
|
-
`x dev --port ${neighbouringPort(probe.port)}`,
|
|
131
|
+
`x dev --port ${portPairAfter(probe.port)}`,
|
|
101
132
|
),
|
|
102
133
|
);
|
|
103
134
|
}
|
|
@@ -125,13 +156,20 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
125
156
|
);
|
|
126
157
|
return findings;
|
|
127
158
|
}
|
|
128
|
-
if (!probe.exists(
|
|
159
|
+
if (!probe.exists(ENV_DEVELOPMENT)) {
|
|
129
160
|
findings.push(
|
|
130
161
|
finding(
|
|
131
162
|
'X_ENV_MISSING',
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
163
|
+
`${ENV_DEVELOPMENT} is missing, so committed defaults cannot be read`,
|
|
164
|
+
// The file write, named — `X_PWA_ICON_MISSING`'s shape below, and for its reason. This
|
|
165
|
+
// said `x new --force`, which cannot run where the reader is standing: `x new` takes a
|
|
166
|
+
// <name> positional (reproduced: `x new --force --json` inside an app answers
|
|
167
|
+
// `X_CLI_BAD_FLAG`), and with one it scaffolds a SECOND app beside the broken one.
|
|
168
|
+
// `.env.example` is the committed projection of `envSchema`, so the copy lands every
|
|
169
|
+
// declared key with its default and blank secrets; `x env example` writes it where an app
|
|
170
|
+
// has none, and `X_ENV_EXAMPLE_DRIFT` is what reports that.
|
|
171
|
+
`cp ${ENV_EXAMPLE_PATH} ${ENV_DEVELOPMENT}`,
|
|
172
|
+
ENV_DEVELOPMENT,
|
|
135
173
|
),
|
|
136
174
|
);
|
|
137
175
|
}
|
|
@@ -183,16 +221,12 @@ export async function runDoctor(probe: DoctorProbe): Promise<readonly Finding[]>
|
|
|
183
221
|
),
|
|
184
222
|
);
|
|
185
223
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
OFFLINE_FALLBACK,
|
|
193
|
-
),
|
|
194
|
-
);
|
|
195
|
-
}
|
|
224
|
+
// The DECLARED fallback against the ROUTE TABLE, never a filename: this probed
|
|
225
|
+
// `apps/web/app/offline.tsx`, which is not a route file at all (`assertRouteFilename` refuses
|
|
226
|
+
// it), so no app could clear the finding and its own `fix:` did not either. `doctor-offline.ts`
|
|
227
|
+
// holds the rule and the reasons.
|
|
228
|
+
const offline = offlineFallbackFinding(await probe.offlineFallback());
|
|
229
|
+
if (offline !== undefined) findings.push(offline);
|
|
196
230
|
const database = await probe.database();
|
|
197
231
|
if (database !== null) findings.push(database);
|
|
198
232
|
findings.push(...(await probe.drift()));
|
|
@@ -264,6 +298,10 @@ export function probeFor(cwd: string, bunVersion: string, port: number): DoctorP
|
|
|
264
298
|
database: () => probeDatabase(process.env['DATABASE_URL']),
|
|
265
299
|
drift: async () => (root === undefined ? [] : checkMigrationDrift(root)),
|
|
266
300
|
snapshots: async () => (root === undefined ? [] : checkMigrationSnapshots(root)),
|
|
301
|
+
// `routes: undefined` outside an app is "not judged", which is what the caller already is:
|
|
302
|
+
// `runDoctor` returns on `X_NOT_IN_APP` before this can be asked.
|
|
303
|
+
offlineFallback: async () =>
|
|
304
|
+
root === undefined ? { fallback: null, routes: undefined } : offlineFallbackProbe(root),
|
|
267
305
|
};
|
|
268
306
|
}
|
|
269
307
|
|