Share the core between CLI and web, notify on stale installs, and make web a workspace#27
Merged
Merged
Conversation
The CLI, the web API and the browser screen each answered the same three
questions about a provider account for themselves. That was not a tidiness
problem; the copies had already diverged into a wrong answer that shipped.
For an account holding BOTH an API key and a session directory:
web API refused to save it
onboard refused to create it
the launch silently dropped the key and used the session, no warning
the doctor reported "no ANTHROPIC_API_KEY; this provider allows that"
about a config that visibly contains an API key
CLI listing rendered only the session line — the stored key was invisible
Four behaviours, one rule, no owner. `core/account.ts` now owns it:
`credentialSource` (which models `conflict` as a real state rather than
normalising it away), `validateAccount`, `CONFLICT_REASON` as one shared
sentence, and `accountsUsedBy` — the reverse index that existed in three copies.
`formatWindow` joins core/format.ts, replacing `pctUsed` and `pct`, which I had
written twice on the same day.
Consequences, not extras: the doctor gains a `credential-conflict` check, the
CLI listing calls the problem out before describing the account, and the browser
says the same sentence. Verified on one config across all three surfaces.
WHY NOT A UI PORT, since that was the question. Driven ports (ConfigStorePort,
ProcessPort, ProviderUsagePort) are interfaces the app defines and adapters
implement — that side was already right. CLI and web are DRIVING adapters: they
call in, and the port on that side is the application's use-case API itself. A
`ports/ui.ts` both implemented would be inverted, and would force terminal text,
JSON and DOM into one rendering vocabulary they do not share. What was missing
was the use-case layer, which had been smeared across config-root.ts and
web/api.ts. Each adapter still owns its own words; none owns the decision.
The browser imports core/ directly and Vite bundles it, which is sound because
core/ is pure — no I/O, no node builtins — and is already inlined into dist/ui.js
for the same reason. `credentialSource` understands the redacted `hasKey: boolean`
shape the API sends to the browser, so the browser can ask the shared classifier
instead of keeping a fourth private copy.
749 tests, 119.8 kB packed. Launch path untouched: core/account.ts is reached by
the config and doctor roots, both lazy.
Signed-off-by: jellologic <[email protected]>
`bunx swisscode` serves a STALE CACHED VERSION. Measured against the live registry: with 0.4.0 published, `npx swisscode` resolved 0.4.0 while plain `bunx swisscode` kept running a cached 0.3.0 — which cannot read a config 0.4.0 has migrated, so it reports `provider undefined`. The config survives (an older build refuses to write over a newer file) but the tool looks broken. Global `npm i -g` installs have the same staleness with no cache to blame. THE LAUNCH PATH CANNOT CHECK FOR UPDATES, and that is not a limitation to work around — `test/architecture.test.ts` forbids `fetch` and every socket module there, and `proc.replace` is `execve`, so there is no "after launch" to do background work in either. A detached spawn would put a network call in the launch's causal chain for a notice, which is the trade this project exists to refuse. So the check is split. `config`/`doctor`/the web UI refresh a cached "latest published version" at most once a day as a side errand; a launch reads that one small file and prints a single line if it is behind. No network in the launch, direct or spawned. The honest consequence, documented rather than hidden: someone who only ever launches never refreshes the cache and never sees the notice. - `core/version.ts` — comparison that answers NO for everything uncertain (unparseable, prerelease, missing). This interrupts a launch, so the bar is "we are sure". A missed notice costs nothing; a wrong one trains people to ignore the next. - `adapters/store/fs-version-store.ts` — the cache, plus `installedVersion()` read from the manifest rather than baked in by the build, because a build-time constant is exactly the thing that goes stale and this value's only job is to be compared against the registry. - `adapters/net/latest-version.ts` — the abbreviated packument npm's own installer requests. Never throws; every failure is null. - `config upgrade` — prints what it is about to run before running it, and infers the install method from the module path, checking ephemeral runners FIRST because npx and bun caches both live inside paths containing "node_modules". An unrecognised location says so instead of guessing a command that might do something unexpected to someone's machine. `--cc-version`, deliberately NOT `--version`: `--version` belongs to the agent and has always printed its version, so scripts depend on it. Taking a flag we do not own for our own convenience would be the trap a drop-in launcher must not set. The refresh runs with a 1500ms timeout rather than the usual 3000ms: the command's own output is already printed by then, so the budget really buys how long the shell prompt is held by a request nobody asked for. 770 tests, 122.5 kB packed. Launch path still under its module ceiling. Signed-off-by: jellologic <[email protected]>
…e one
Both UIs are React, so it is easy to assume they already share what matters.
They did not. The Ink picker called `core/catalog`'s `filterModels` and `rank`
and `core/format`'s helpers; the browser picker reimplemented filtering inline
and skipped the rest. The copy was not merely duplicated, it was WORSE:
filtering core.filterModels vs inline copy, no free-only filter
sorting core.rank (benchmark) vs none — whatever order the registry
happened to return
a free model "free" vs "$0.00/M"
a 1M-context model "1M" vs "1000K"
The "$0.00" case is the one worth pausing on: that file's own header says "a
catalog with no prices shows no prices, rather than '$0.00', which would read as
free" — and eleven lines down it rendered exactly that. The rule was written
once and enforced in one of the two places it applies.
Root cause was a hand-written type. `web/src/api.ts` mirrored `NormalizedModel`
as a narrower `CatalogModel` — id, name, context, pricing, tools — so the
browser could not call `rank`, which sorts on `benchmarks`, a field the server
was already sending and the type simply hid. Same for `CatalogCapabilities`,
whose mirror had silently lost `requiresAuth`. Both are now type-only imports
from `src/ports/`: they erase, cost the bundle nothing, and cannot drift.
Verified in a browser against the live OpenRouter catalog: ranked by benchmark
rather than registry order, "1.1M context" not "1100K", a working free-only
filter (14 of 342), and free models rendering as "free".
What stays local to each UI is presentation, which is the right seam: the
browser keeps `priceLabel`, because `formatPrice` correctly returns "free" and
appending "/M in" to that gives "free/M in", which is not a thing anyone says.
770 tests. Bundle unchanged at 69.6 kB — core is pure and tree-shakes.
Signed-off-by: jellologic <[email protected]>
… working `web/` had a tsconfig and a vite config but no manifest, so its build tooling — vite, panda, preact, @types/react-dom — sat in the root manifest advertising itself as a dependency of a launcher that never loads any of it. It is now `@swisscode/web`, private, owning its own devDeps. Nothing about the published artifact changes: `files` decides that, not the layout. Still 75 files, 122.8 kB packed. ONE LOCKFILE, AND IT HAS TO BE package-lock.json. Not preference: `bun install` does not read it and resolves fresh — I diffed the resulting trees and found 5 transitive packages disagreeing (lightningcss, postcss, picomatch, @babel/code-frame) while every declared dependency matched. Small, but that is how "works on my machine" starts. And the choice is forced anyway: `bun publish` has no OIDC/provenance equivalent, so npm stays in the release path permanently. `bun.lock` is gitignored so the drift cannot be committed. Bun loses nothing by this. It remains a first-class runtime — `bun run test` passes 770/770 against an npm-installed tree, `bunx swisscode` works, and swisscode runs under bun. It is simply not the installer. Two things this shook out, both fixed rather than worked around: - `npm run --workspace web typecheck` BREAKS UNDER BUN. Bun rewrites `npm run` to `bun run` inside scripts and spells the same idea `--filter`, so the script failed with "Script not found web". The fix is to not need workspace machinery for what is one tsc invocation: `tsc --noEmit -p web/tsconfig.json` works identically everywhere. No package-manager-specific flags in `scripts`. - build.js hard-coded `../node_modules/vite/bin/vite.js`. npm and bun do not agree on where workspace deps land, and either can nest on a version conflict, so it now looks in both `web/node_modules` and the root — the "works on my machine" this project runs two runtimes to avoid. Verified end to end under both: npm install, npm ci, bun install, build, full suite, and the packed artifact. Signed-off-by: jellologic <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four related changes, all downstream of one question: do the CLI and the web UI actually share what they should? They did not, and looking properly turned up two shipped bugs.
1. The account rules had no owner
An account holding both an API key and a session directory produced four different behaviours:
core/account.tsnow owns it:credentialSource(which modelsconflictas a real state rather than normalising it away),validateAccount, one sharedCONFLICT_REASONsentence, andaccountsUsedBy— the reverse index that existed in three copies.formatWindowjoinscore/format.ts, replacing two helpers written on the same day.Consequences, not extras: the doctor gains a
credential-conflictcheck, the CLI calls the problem out before describing the account, and the browser says the same sentence. Verified on one config across all three surfaces.Why not a UI port, since that was the original question: driven ports (
ConfigStorePort,ProcessPort,ProviderUsagePort) are interfaces the app defines and adapters implement — that side was already right. CLI and web are driving adapters; they call in, and the port on that side is the application's use-case API itself. Aports/ui.tsboth implemented would be inverted, forcing terminal text, JSON and DOM into one rendering vocabulary. What was missing was the use-case layer, smeared acrossconfig-root.tsandweb/api.ts.2. Nobody knew they were running an old swisscode
bunx swisscodeserves a stale cached version — measured against the live registry: with 0.4.0 published,npxresolved 0.4.0 while plainbunxkept running a cached 0.3.0, which cannot read a v3 config and reportsprovider undefined.The launch path may not check for updates:
test/architecture.test.tsforbidsfetchand every socket module there, andproc.replaceisexecve, so there is no "after launch" either. A detached spawn would put a network call in the launch's causal chain for a notice.So it is split.
config/doctor/web refresh a cached "latest version" at most once a day as a side errand; a launch reads that one file and prints a single line. Documented consequence: someone who only ever launches never refreshes the cache and never sees the notice.Adds
swisscode config upgradeand--cc-version— deliberately not--version, which belongs to the agent and which scripts depend on.3. The browser model picker was the worse one
Both UIs are React, so it was easy to assume they shared what matters:
core.filterModelscore.rank(benchmark)free$0.00/M1M1000KThat file's own header says "a catalog with no prices shows no prices, rather than '$0.00', which would read as free" — and eleven lines down it rendered exactly that.
Root cause was a hand-written type:
web/src/api.tsmirroredNormalizedModelnarrowly, so the browser could not callrank, which sorts onbenchmarks— a field the server was already sending and the type simply hid. Same forCatalogCapabilities, whose mirror had lostrequiresAuth. Both are type-only imports now. Verified in a browser against the live OpenRouter catalog.4.
web/is a workspaceIt had a tsconfig and a vite config but no manifest, so vite, panda, preact and
@types/react-domsat in the root manifest advertising themselves as dependencies of a launcher that never loads them.One lockfile, and it has to be
package-lock.json.bun installdoes not read it and resolves fresh — 5 transitive packages disagreed in a measured diff — andbun publishhas no OIDC/provenance, so npm is permanently in the release path.bun.lockis gitignored. Bun remains a first-class runtime:bun run testpasses 770/770 against an npm-installed tree.Two things this shook out, fixed rather than worked around:
npm run --workspace web xbreaks under bun (which rewritesnpm runtobun runand spells it--filter), andbuild.jshard-coded a hoisting layout the two installers do not agree on.Verification
770 tests, 122.8 kB packed (ceiling 150). Verified under both npm and bun: install,
npm ci, build, full suite, packed artifact. The browser work was driven through a real browser against a live catalog. No change to the published artifact's shape.