From 117e75b9fa01fa63afe43246b8ec26f96fefa3ad Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:18:50 -0400 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20give=20the=20account=20rules=20?= =?UTF-8?q?one=20owner=20=E2=80=94=20core/account.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 <31935831+jellologic@users.noreply.github.com> --- src/adapters/web/api.ts | 15 +++-- src/composition/config-root.ts | 47 +++++++++------ src/composition/doctor-root.ts | 18 ++++++ src/core/account.ts | 102 +++++++++++++++++++++++++++++++++ src/core/format.ts | 21 +++++++ test/adapters/web.test.ts | 16 ++++++ test/core/account.test.ts | 90 +++++++++++++++++++++++++++++ web/src/routes/Accounts.tsx | 71 +++++++++++++---------- 8 files changed, 324 insertions(+), 56 deletions(-) create mode 100644 src/core/account.ts create mode 100644 test/core/account.test.ts diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts index f4e98a0..22ab685 100644 --- a/src/adapters/web/api.ts +++ b/src/adapters/web/api.ts @@ -12,6 +12,7 @@ import { bindPath, bindingEntries, unbindPath } from '../../core/binding.ts' import { validateProfileName } from '../../core/migrate.ts' import { toCustomProvider, validateCustomProvider } from '../../core/provider-def.ts' import { TIERS } from '../../core/tiers.ts' +import { accountsUsedBy, validateAccount } from '../../core/account.ts' import { COMPAT_ENV, CREDENTIAL_ENVS } from '../agents/claude-code/env.ts' import type { AgentProfile, @@ -210,11 +211,11 @@ export function parseAccount( else delete account.apiKeyFromEnv } // The two modes are MUTUALLY EXCLUSIVE, and the conflict is refused rather - // than resolved by precedence: "which credential did this actually use" must - // never have a subtle answer. - if (account.configDir && (account.apiKey || account.apiKeyFromEnv)) { - return 'an account uses either a key or an existing login directory, never both' - } + // than resolved by precedence. The RULE lives in core/account.ts so the + // launch path and the doctor reach the same verdict this endpoint does — + // they used to disagree, and the doctor called a conflicting account healthy. + const invalid = validateAccount(account) + if (invalid) return invalid return account } @@ -444,9 +445,7 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { // Profiles referencing it are REPORTED, never silently repaired: only the // user knows which account should pay instead. - const affected = Object.entries(loaded.state.profiles ?? {}) - .filter(([, pr]) => (pr.accounts ?? []).includes(name)) - .map(([n]) => n) + const affected = accountsUsedBy(loaded.state.profiles, name) const accounts = { ...loaded.state.providerAccounts } delete accounts[name] diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index 6ceb89f..58b5429 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -35,6 +35,8 @@ import { describeIdentity, readSessionIdentity } from '../adapters/claude-sessio import { accountLogin } from '../adapters/claude-session/onboard.ts' import { swapCredential } from '../adapters/claude-session/swap.ts' import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' +import { CONFLICT_REASON, accountsUsedBy, credentialSource } from '../core/account.ts' +import { formatWindow } from '../core/format.ts' import type { LaunchDeps } from './launch-root.ts' import type { LoadResult, Profile, State } from '../ports/config-store.ts' import type { ProcessPort } from '../ports/process.ts' @@ -429,10 +431,22 @@ function listProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { return 0 } +/** + * Where a key comes from, phrased for a terminal. + * + * The CLASSIFICATION is core's (`credentialSource`); only the wording is this + * file's. That split is the point of the split: the web says the same things in + * its own vocabulary from the same decision. + */ function credentialOrigin(account: { apiKey?: string; apiKeyFromEnv?: string }): string { - if (account.apiKeyFromEnv) return `read from $${account.apiKeyFromEnv} at launch` - if (account.apiKey) return 'stored in config.json (0600)' - return 'none' + switch (credentialSource(account)) { + case 'key-from-env': + return `read from $${account.apiKeyFromEnv} at launch` + case 'key': + return 'stored in config.json (0600)' + default: + return 'none' + } } type NamedProfileOptions = { @@ -859,8 +873,8 @@ async function refreshUsage({ continue } out(` remaining ${m.usage.remaining}% (the tighter of the two windows)`) - out(` 5-hour ${pctUsed(m.usage.fiveHour)}`) - out(` 7-day ${pctUsed(m.usage.sevenDay)}`) + out(` 5-hour ${formatWindow(m.usage.fiveHour, { resets: true })}`) + out(` 7-day ${formatWindow(m.usage.sevenDay, { resets: true })}`) } const remaining = remainingMap(measurements) @@ -981,16 +995,6 @@ function swapAccount({ return 0 } -/** "37% used, resets 15:10 today" — a window, phrased for a person. */ -function pctUsed(w: { utilization: number | null; resetsAt: string | null }): string { - if (w.utilization === null) return '— not published' - const resets = w.resetsAt ? new Date(w.resetsAt) : null - return ( - `${w.utilization}% used` + - (resets && !Number.isNaN(resets.getTime()) ? `, resets ${resets.toLocaleString()}` : '') - ) -} - function accountsCommand({ deps, args, @@ -1053,13 +1057,20 @@ function listAccounts({ deps, out }: { deps: LaunchDeps; out: Emit }): number { // `!` — read off Object.keys of this very object. const a = state.providerAccounts[name]! const provider = deps.registry.byId(a.provider) - const usedBy = Object.entries(state.profiles ?? {}) - .filter(([, p]) => (p.accounts ?? []).includes(name)) - .map(([n]) => n) + const usedBy = accountsUsedBy(state.profiles, name) out(` ${name}${a.label ? ` (${a.label})` : ''}`) out(` provider ${a.provider}${provider ? '' : ' — not in this build'}`) if (a.baseUrl) out(` baseUrl ${a.baseUrl}`) + // A conflicting account is called out BEFORE anything else about it. The + // previous version simply rendered the session branch, so a stored key sat + // in config.json invisible to the one command whose job is saying what an + // account is. + if (credentialSource(a) === 'conflict') { + out(` PROBLEM ${CONFLICT_REASON}`) + out(' the launch will use the session directory and ignore the key.') + out(` Remove one: \`swisscode config ${name}\` or edit the config.`) + } if (a.configDir) { // A session account has no key to describe, so describe the LOGIN // instead. The email is the thing the user recognises — "which of my diff --git a/src/composition/doctor-root.ts b/src/composition/doctor-root.ts index 9b116a4..a679803 100644 --- a/src/composition/doctor-root.ts +++ b/src/composition/doctor-root.ts @@ -41,6 +41,7 @@ import { sessionDirLooksInitialised, } from '../adapters/claude-session/identity.ts' import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' +import { CONFLICT_REASON, credentialSource } from '../core/account.ts' import type { MeasureOptions } from '../adapters/usage/measure.ts' import type { LaunchDeps } from './launch-root.ts' import { createOllamaIntrospect, interpretOllamaContext } from '../adapters/doctor/ollama.ts' @@ -185,6 +186,23 @@ export async function runDoctor({ ) } + // An account holding BOTH a key and a session directory. + // + // This check exists because its absence was a wrong answer, not a missing + // one. The web API refuses to save such an account and `accounts login` + // refuses to create one, but a hand-edited config reached the launch path, + // which silently preferred the session and dropped the key — while the + // `credential` check above cheerfully reported "no ANTHROPIC_API_KEY; this + // provider allows that" about a config that visibly contains one. The rule + // now has a single owner in core/account.ts and this consults it. + if (profile && credentialSource(profile) === 'conflict') { + checks.push( + makeCheck('credential-conflict', 'credential', WARN, CONFLICT_REASON, { + fix: `the launch uses the session directory and ignores the key — remove one from account "${profile.accountName}"`, + }), + ) + } + // A session directory nobody has logged into. // // THE FAILURE THIS CATCHES IS EXPENSIVE BECAUSE IT IS LATE. Resolution diff --git a/src/core/account.ts b/src/core/account.ts new file mode 100644 index 0000000..a469dbe --- /dev/null +++ b/src/core/account.ts @@ -0,0 +1,102 @@ +// What is true about a provider account, decided once. +// +// WHY THIS EXISTS. Three surfaces ask the same questions about an account — the +// CLI (`config accounts`), the web API, and the browser screen — and before this +// module each answered them itself. That is not a tidiness complaint; the copies +// had already diverged into a wrong answer: +// +// web API refused to save an account holding a key AND a session dir +// onboard refused to create one +// the launch silently dropped the key and set the session dir, no warning +// the doctor reported "no ANTHROPIC_API_KEY; this provider allows that" +// for a config that visibly contains an API key +// +// Four behaviours, one rule, no owner. The rule lives here now, and the launch +// path and doctor consult the same function the web enforces. +// +// EVERY FUNCTION TAKES THE NARROWEST STRUCTURAL SHAPE IT NEEDS rather than +// `State` or `ProviderAccount`. That is deliberate: the browser bundle is a +// separate TypeScript project with its own mirrored types, and parameters typed +// this way are satisfiable from both sides without either importing the other's +// vocabulary. It also states honestly how little each answer depends on. + +/** + * The credential-bearing fields, whatever object they arrived on. + * + * `hasKey` is here because REDACTION IS A FIRST-CLASS SHAPE IN THIS CODEBASE, + * not an afterthought: the web API never sends `apiKey` to the browser, it sends + * `hasKey: boolean`. Without this the browser could not ask the shared + * classifier its question and would need a fourth private copy of the rule — + * which is the exact failure this module was written to end. + */ +export type CredentialFields = { + apiKey?: string | undefined + apiKeyFromEnv?: string | undefined + configDir?: string | undefined + /** the redacted stand-in for `apiKey`; equivalent for classification */ + hasKey?: boolean | undefined +} + +/** + * How an account authenticates. + * + * `conflict` is a REAL STATE, not an error case to be normalised away. A config + * can be hand-edited, and a build that only modelled the three valid answers + * would have to pick one for the fourth — which is exactly the silent + * wrong-account behaviour this whole area exists to end. + */ +export type CredentialSource = 'key' | 'key-from-env' | 'session' | 'none' | 'conflict' + +export function credentialSource(account: CredentialFields): CredentialSource { + const hasKey = Boolean(account.apiKey || account.apiKeyFromEnv || account.hasKey) + const hasSession = Boolean(account.configDir) + if (hasKey && hasSession) return 'conflict' + if (hasSession) return 'session' + if (account.apiKeyFromEnv) return 'key-from-env' + if (account.apiKey || account.hasKey) return 'key' + return 'none' +} + +/** + * The one sentence every surface uses for the conflict. + * + * Exported as a constant rather than written out at each call site because the + * three copies of this rule previously carried three different wordings, and a + * user who hits it in the web UI and then in the CLI should not have to work out + * whether they are the same problem. + */ +export const CONFLICT_REASON = + 'an account authenticates with either an API key or an existing agent login (a session ' + + 'directory), never both — which one pays must never have a subtle answer' + +/** + * Whether an account is well-formed. `null` means fine. + * + * Deliberately NOT a throw and not a boolean: callers need the sentence, and + * they present it very differently — a 400 body, a stderr line, a doctor check. + */ +export function validateAccount( + account: CredentialFields & { provider?: string | undefined }, +): string | null { + if (!account.provider) return 'provider is required' + if (credentialSource(account) === 'conflict') return CONFLICT_REASON + return null +} + +/** + * Which profiles name this account — the reverse index. + * + * A profile lists its accounts; nothing else says which profiles an account + * backs, and that is the question you have before deleting one or rotating a + * key. Sorted, because two surfaces listing the same set in different orders + * reads as a difference in meaning. + */ +export function accountsUsedBy( + profiles: Record | null | undefined, + accountName: string, +): string[] { + return Object.entries(profiles ?? {}) + .filter(([, p]) => (p.accounts ?? []).includes(accountName)) + .map(([name]) => name) + .sort() +} diff --git a/src/core/format.ts b/src/core/format.ts index e0ba4fc..ace4fda 100644 --- a/src/core/format.ts +++ b/src/core/format.ts @@ -57,3 +57,24 @@ export function formatToolSupport(tools: boolean | null | undefined): string { if (tools === false) return 'no tools' return 'tools unknown' } + +/** + * One subscription window — "37% used, resets 15:10" — for a person. + * + * AN UNPUBLISHED WINDOW IS "—", NEVER 0%. The endpoint genuinely omits these + * (both per-model weekly windows were null on the account this was built + * against), and rendering an omission as zero reads as "completely unused", + * which is the opposite of "we do not know". + * + * `resets` is optional because the two callers disagree for good reason: a CLI + * line has room for the reset time, a table cell in the browser does not. + */ +export function formatWindow( + window: { utilization: number | null; resetsAt: string | null } | null | undefined, + { resets = false }: { resets?: boolean } = {}, +): string { + if (!window || window.utilization === null) return '— not published' + const at = resets && window.resetsAt ? new Date(window.resetsAt) : null + const when = at && !Number.isNaN(at.getTime()) ? `, resets ${at.toLocaleString()}` : '' + return `${window.utilization}% used${when}` +} diff --git a/test/adapters/web.test.ts b/test/adapters/web.test.ts index 66aec4a..17a6969 100644 --- a/test/adapters/web.test.ts +++ b/test/adapters/web.test.ts @@ -17,6 +17,7 @@ import { guardDocumentRequest, tokensMatch, } from '../../src/adapters/web/security.ts' +import { CONFLICT_REASON } from '../../src/core/account.ts' import { handleApi, parseAccount, parseAgentProfile, redactAccount, redactState } from '../../src/adapters/web/api.ts' import { FALLBACK_SCRIPT_PATH, resolveAsset, startWebServer } from '../../src/adapters/web/server.ts' import { request } from 'node:http' @@ -405,6 +406,21 @@ test('installed agents are reported as unknown rather than faked when unavailabl assert.equal(agentsFound.length, 2) }) +test('the API refuses a key+session account with core\'s exact sentence', () => { + // The rule lives in core/account.ts so this endpoint, the CLI listing, the + // doctor and the launch path cannot drift apart on it — they already had, + // and the doctor was calling such an account healthy. + const refusal = parseAccount( + { provider: 'anthropic', apiKey: 'sk-x', configDir: '/home/u/.claude' }, + undefined, + ) + assert.equal(refusal, CONFLICT_REASON) + + // …and each mode ALONE is still accepted, or the refusal would be useless. + assert.equal(typeof parseAccount({ provider: 'anthropic', configDir: '/d' }, undefined), 'object') + assert.equal(typeof parseAccount({ provider: 'anthropic', apiKey: 'sk-x' }, undefined), 'object') +}) + test('session logins are reported as unknown rather than faked when unwired', () => { // Same rule as installedAgents, and it matters more here: an empty map would // be indistinguishable from "every account is logged out", which would send a diff --git a/test/core/account.test.ts b/test/core/account.test.ts new file mode 100644 index 0000000..4f77efe --- /dev/null +++ b/test/core/account.test.ts @@ -0,0 +1,90 @@ +// The account questions three surfaces used to answer separately. +// +// These tests are the reason the module exists, not decoration on top of it: +// before it, the CLI, the web API, the launch path and the doctor each decided +// "does this account hold a key or a login?" for themselves, and they had +// already diverged into a wrong answer that shipped. +import test from 'node:test' +import assert from 'node:assert/strict' +import { + CONFLICT_REASON, + accountsUsedBy, + credentialSource, + validateAccount, +} from '../../src/core/account.ts' + +test('each way of holding a credential is named, including having none', () => { + assert.equal(credentialSource({ apiKey: 'sk-x' }), 'key') + assert.equal(credentialSource({ apiKeyFromEnv: 'MY_TOKEN' }), 'key-from-env') + assert.equal(credentialSource({ configDir: '/home/u/.claude' }), 'session') + assert.equal(credentialSource({}), 'none') +}) + +test('an env-var key outranks a stored one, matching what the launch presents', () => { + // Not arbitrary: `apiKeyFromEnv` is the one that keeps the secret out of + // config.json, so an account carrying both is described by the safer source. + assert.equal(credentialSource({ apiKey: 'sk-x', apiKeyFromEnv: 'MY_TOKEN' }), 'key-from-env') +}) + +test('holding BOTH a key and a session directory is its own answer, not an error', () => { + // THE BUG THIS MODULE WAS WRITTEN FOR. A shape that only modelled the three + // valid states would have to pick one for the fourth, which is precisely the + // silently-wrong-account behaviour the session feature exists to end. + assert.equal(credentialSource({ apiKey: 'sk-x', configDir: '/d' }), 'conflict') + assert.equal(credentialSource({ apiKeyFromEnv: 'T', configDir: '/d' }), 'conflict') +}) + +test('the redacted browser shape classifies identically to the server shape', () => { + // The web API sends `hasKey: boolean` and never `apiKey`. If the classifier + // did not understand that, the browser would need a fourth private copy of + // this rule — the exact failure being fixed. + assert.equal(credentialSource({ hasKey: true }), 'key') + assert.equal(credentialSource({ hasKey: false }), 'none') + assert.equal(credentialSource({ hasKey: true, configDir: '/d' }), 'conflict') + // …and the two shapes agree with each other, which is the actual contract. + for (const dir of [undefined, '/d']) { + assert.equal( + credentialSource({ apiKey: 'sk-x', ...(dir ? { configDir: dir } : {}) }), + credentialSource({ hasKey: true, ...(dir ? { configDir: dir } : {}) }), + ) + } +}) + +test('validation refuses the conflict with ONE sentence, shared by every surface', () => { + // The three previous copies carried three different wordings. A user who hits + // this in the browser and then in the terminal should not have to work out + // whether it is the same problem. + assert.equal(validateAccount({ provider: 'anthropic', apiKey: 'k', configDir: '/d' }), CONFLICT_REASON) + assert.equal(validateAccount({ provider: 'anthropic', configDir: '/d' }), null) + assert.equal(validateAccount({ provider: 'anthropic', apiKey: 'k' }), null) + // A provider is still required, and that refusal comes first. + assert.equal(validateAccount({ apiKey: 'k' }), 'provider is required') +}) + +test('an account with no credential at all is VALID — some providers need none', () => { + // Ollama and anthropic-direct both authenticate without a key. Refusing this + // would break the local-model path entirely. + assert.equal(validateAccount({ provider: 'ollama' }), null) +}) + +test('the reverse index finds every profile naming an account, sorted', () => { + const profiles = { + work: { accounts: ['personal', 'spare'] }, + play: { accounts: ['personal'] }, + other: { accounts: ['someone-else'] }, + empty: {}, + } + // Sorted, because two surfaces listing the same set in different orders reads + // as a difference in meaning. + assert.deepEqual(accountsUsedBy(profiles, 'personal'), ['play', 'work']) + assert.deepEqual(accountsUsedBy(profiles, 'spare'), ['work']) + assert.deepEqual(accountsUsedBy(profiles, 'unused'), []) +}) + +test('a missing or empty profile map is not a crash', () => { + // Reached on a fresh config, and from the browser where the map arrives over + // the wire and may legitimately be absent. + assert.deepEqual(accountsUsedBy(undefined, 'x'), []) + assert.deepEqual(accountsUsedBy(null, 'x'), []) + assert.deepEqual(accountsUsedBy({}, 'x'), []) +}) diff --git a/web/src/routes/Accounts.tsx b/web/src/routes/Accounts.tsx index 068c171..234b8ca 100644 --- a/web/src/routes/Accounts.tsx +++ b/web/src/routes/Accounts.tsx @@ -1,7 +1,12 @@ import { useState } from 'react' import { css } from '../../styled-system/css' -import { ApiError, api, type Bootstrap, type UsageReport, type UsageWindow } from '../api' +import { ApiError, api, type Bootstrap, type ProviderAccount, type UsageReport } from '../api' import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' +// The SAME decisions the CLI and the API make, imported rather than restated. +// core/ is pure — no I/O, no node builtins — so it bundles into the browser as +// happily as it compiles for the launch path. +import { accountsUsedBy, credentialSource } from '../../../src/core/account' +import { formatWindow } from '../../../src/core/format' /** * Provider accounts — who pays. @@ -21,15 +26,39 @@ import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from * Only the user knows which account should pay instead. */ /** - * One window, in the smallest space that stays honest. + * Is this account ready to launch? The DECISION is core's; only the question is + * asked here. * - * An unpublished window renders as “—”, never as 0%. The endpoint genuinely - * omits these — both per-model weekly windows were null on the account this was - * built against — and showing an omission as zero would read as "completely - * unused", which is the opposite of "we do not know". + * A session account is ready when someone has LOGGED IN, not when a path is + * set: a directory nobody has logged into is indistinguishable in config.json + * from one that works, and fails only after execve. */ -function pct(w: UsageWindow | null): string { - return w?.utilization === null || w === null ? '—' : `${w.utilization}%` +function ready(account: ProviderAccount, login: string | null): boolean { + switch (credentialSource(account)) { + case 'session': + return Boolean(login) + case 'key': + case 'key-from-env': + return true + default: + return false + } +} + +/** The one-line credential summary, in the browser's own words. */ +function credentialLine(account: ProviderAccount, login: string | null): string { + switch (credentialSource(account)) { + case 'session': + return login ?? 'not logged in' + case 'key-from-env': + return `key from $${account.apiKeyFromEnv}` + case 'key': + return 'key stored' + case 'conflict': + return 'both a key and a login — the launch ignores the key' + default: + return 'no key' + } } export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Promise }) { @@ -303,9 +332,7 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom // The reverse index: a profile lists its accounts, but only this // view can say which profiles an account backs — the question you // have before deleting one or rotating a key. - const usedBy = Object.entries(data.state.profiles ?? {}) - .filter(([, p]) => (p.accounts ?? []).includes(name)) - .map(([n]) => n) + const usedBy = accountsUsedBy(data.state.profiles, name) const login = a.configDir ? (data.logins?.[name] ?? null) : null const measured = usage?.accounts.find((m) => m.name === name) return ( @@ -327,17 +354,7 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom looks identical in config.json and fails only after execve, so the dot tracks the login rather than the field. */} - +
{name} @@ -350,13 +367,7 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom
{a.provider} {' · '} - {a.configDir - ? (login ?? 'not logged in') - : a.apiKeyFromEnv - ? `key from $${a.apiKeyFromEnv}` - : a.hasKey - ? 'key stored' - : 'no key'} + {credentialLine(a, login)} {' · '} {usedBy.length > 0 ? `used by ${usedBy.join(', ')}` : 'unused'}
@@ -366,7 +377,7 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom ? 'key account — no subscription window' : measured.remaining === null ? 'could not be measured' - : `${measured.remaining}% left · 5h ${pct(measured.fiveHour)} · 7d ${pct(measured.sevenDay)}`} + : `${measured.remaining}% left · 5h ${formatWindow(measured.fiveHour)} · 7d ${formatWindow(measured.sevenDay)}`}
) : null}
From 0e47540d1835da80b412989ff81c8e8a11cc7b25 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:53:11 -0400 Subject: [PATCH 2/4] feat: tell people when they are running an old swisscode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 <31935831+jellologic@users.noreply.github.com> --- README.md | 30 +++- src/adapters/net/latest-version.ts | 60 +++++++ src/adapters/store/fs-version-store.ts | 128 +++++++++++++ src/cli.ts | 14 ++ src/composition/config-root.ts | 153 ++++++++++++++++ src/composition/launch-root.ts | 33 +++- src/core/version.ts | 42 +++++ test/adapters/version-check.test.ts | 237 +++++++++++++++++++++++++ test/core/version.test.ts | 54 ++++++ 9 files changed, 749 insertions(+), 2 deletions(-) create mode 100644 src/adapters/net/latest-version.ts create mode 100644 src/adapters/store/fs-version-store.ts create mode 100644 src/core/version.ts create mode 100644 test/adapters/version-check.test.ts create mode 100644 test/core/version.test.ts diff --git a/README.md b/README.md index 9edb1d8..ad14eac 100644 --- a/README.md +++ b/README.md @@ -62,16 +62,44 @@ npm install -g swisscode **Bun works too**, both as a runner and as the runtime: ```sh -bunx swisscode +bunx swisscode@latest # the @latest matters — see below bun install -g swisscode ``` +> **`bunx swisscode` can run a stale cached version.** Measured, not assumed: +> with 0.4.0 published, `npx swisscode` resolved 0.4.0 while plain +> `bunx swisscode` kept serving a cached 0.3.0 — which cannot read a config the +> newer version has migrated, so it reports `provider undefined`. Your config is +> safe (an older build refuses to write over a newer file), but the tool looks +> broken. `bunx swisscode@latest` forces a fresh resolve, and a global install +> avoids the question entirely. + Nothing special was needed for this: swisscode is plain ESM with no native addons, and `process.execve` — the call that makes handoff free — exists on Bun as well as Node. Verified by resolving a full launch plan under both and diffing: same profile, same agent, same provider, and a byte-identical set of `ANTHROPIC_*` / `CLAUDE_CODE_*` variables. Deno is untested. +### Staying current + +```sh +swisscode --cc-version # which swisscode is this? +swisscode config upgrade # check npm, and install a newer one +``` + +`--cc-version`, not `--version`: `--version` belongs to the agent and has always +printed *its* version, so scripts depend on it. swisscode answers on the +reserved `--cc-` prefix instead of taking a flag it does not own. + +When a newer release exists, a launch prints one line telling you. That notice +comes from a **cached** answer, never from a live request — the launch path is +structurally forbidden from touching the network, which is a property this tool +sells rather than an implementation detail. `swisscode config …`, the doctor and +the web UI refresh that cache (at most once a day) as a side errand. The honest +consequence: if you *only* ever launch and never run a config command, you will +not see the notice. A launcher that phoned home on every run would be a +different and worse tool. + Every argument that isn't listed below is forwarded to `claude` untouched: ```sh diff --git a/src/adapters/net/latest-version.ts b/src/adapters/net/latest-version.ts new file mode 100644 index 0000000..5f17522 --- /dev/null +++ b/src/adapters/net/latest-version.ts @@ -0,0 +1,60 @@ +// Asking npm what the latest swisscode is. +// +// CONFIGURATION-TIME ONLY. This module reaches the network, so it may never +// appear on the launch path — `test/architecture.test.ts` enforces that for +// `adapters/net` already. The launcher reads the cached answer instead. +// +// Uses the ABBREVIATED packument (`application/vnd.npm.install-v1+json`), which +// is what npm's own installer requests: it omits READMEs and per-version +// metadata, so the response is a few kB rather than a few hundred. We want one +// string out of it. + +/** The registry, overridable so a private mirror or a test can stand in. */ +export const DEFAULT_REGISTRY = 'https://registry.npmjs.org' + +export type LatestVersionOptions = { + packageName?: string + registry?: string + timeoutMs?: number + /** injected in tests; defaults to global fetch */ + fetchImpl?: typeof fetch +} + +/** + * The published `latest` dist-tag, or null. + * + * NEVER THROWS, and null covers every failure — offline, rate-limited, a + * private registry that does not carry this package, a shape change. This runs + * as a side errand of some command the user actually asked for, and an update + * check that could fail `config list` would be a worse bug than a stale + * version. + */ +export async function fetchLatestVersion({ + packageName = 'swisscode', + registry = DEFAULT_REGISTRY, + timeoutMs = 3000, + fetchImpl = fetch, +}: LatestVersionOptions = {}): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetchImpl( + `${registry.replace(/\/+$/, '')}/${encodeURIComponent(packageName)}`, + { + headers: { accept: 'application/vnd.npm.install-v1+json' }, + signal: controller.signal, + }, + ) + if (!response.ok) return null + const body: unknown = await response.json() + if (!body || typeof body !== 'object') return null + const tags = (body as { 'dist-tags'?: unknown })['dist-tags'] + if (!tags || typeof tags !== 'object') return null + const latest = (tags as Record).latest + return typeof latest === 'string' && latest !== '' ? latest : null + } catch { + return null + } finally { + clearTimeout(timer) + } +} diff --git a/src/adapters/store/fs-version-store.ts b/src/adapters/store/fs-version-store.ts new file mode 100644 index 0000000..14729db --- /dev/null +++ b/src/adapters/store/fs-version-store.ts @@ -0,0 +1,128 @@ +// The last version we heard was published. +// +// THE LAUNCH PATH MAY NOT ASK THE NETWORK — `test/architecture.test.ts` forbids +// `fetch` and every socket module there, and that rule is the product, not a +// preference. So the launcher cannot check for updates; it can only read what +// something else already learned. +// +// This is that something. `config`, `doctor` and the web UI refresh it, and a +// launch reads one tiny file to decide whether to print a single line. No +// network in the launch's causal chain, direct or spawned. +// +// In the STATE directory beside the cursor and the usage snapshot, for the same +// reason all three live there: it is regenerable. Losing it costs one refresh. + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { stateDir } from './fs-cursor-store.ts' + +type ReadableEnv = Record + +/** What the registry said, and when. */ +export type VersionSnapshot = { + /** the published `latest` dist-tag, e.g. "0.4.0" */ + latest: string + /** epoch ms */ + checkedAt: number +} + +export type VersionStorePort = { + read: () => VersionSnapshot | null + /** best effort; a failed write must never fail the command that triggered it */ + write: (snapshot: VersionSnapshot) => void +} + +/** + * How long a check is worth trusting, and therefore how often the refreshing + * commands actually reach the network. + * + * A DAY. Releases are not frequent enough for anything shorter to find news, + * and a notice that appears within a day of a release is soon enough for a tool + * nobody is paged about. Matches the model-catalog cache, which made the same + * call for the same reason. + */ +export const VERSION_TTL_MS = 24 * 60 * 60 * 1000 + +export type FsVersionStoreOptions = { + env?: ReadableEnv + dir?: string | null +} + +export function createFsVersionStore({ + env = process.env, + dir = null, +}: FsVersionStoreOptions = {}): VersionStorePort { + const directory = dir ?? stateDir(env) + const file = join(directory, 'version.json') + + return { + read() { + try { + const parsed: unknown = JSON.parse(readFileSync(file, 'utf8')) + if (!parsed || typeof parsed !== 'object') return null + const { latest, checkedAt } = parsed as Record + if (typeof latest !== 'string' || latest === '') return null + if (typeof checkedAt !== 'number' || !Number.isFinite(checkedAt)) return null + return { latest, checkedAt } + } catch { + // Absent, unreadable, or not JSON. All mean "we have not heard". + return null + } + }, + write(snapshot) { + try { + mkdirSync(directory, { recursive: true, mode: 0o700 }) + writeFileSync(file, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 }) + } catch { + // The command that triggered this has already done its real work. + } + }, + } +} + +/** + * Whether the cached answer is old enough to be worth re-asking. + * + * Separate from `read` because the two callers want different things: a launch + * reads a stale snapshot happily (a day-old version number is still true enough + * to warn on), while a refresher needs to know whether to spend a request. + */ +export function isStale( + snapshot: VersionSnapshot | null, + now: number, + ttlMs: number = VERSION_TTL_MS, +): boolean { + if (!snapshot) return true + // A clock that moved backwards makes `now - checkedAt` negative, which would + // otherwise read as "checked in the future, definitely fresh" and could + // wedge the check off permanently. + const age = now - snapshot.checkedAt + return age < 0 || age >= ttlMs +} + +/** + * The version of swisscode that is actually running. + * + * Read from the package manifest rather than baked in by the build, because a + * constant substituted at build time is exactly the thing that goes stale and + * lies — and this value's whole job is to be compared against the registry. + * + * Lives in this module because this is where "which version" is already the + * subject, and because it keeps the launch path from growing another file for + * one function. + * + * The path is the same from `src/` and from `dist/`: both put this module three + * levels below the package root. Null on any failure — an unreadable manifest + * must never be the reason a launch does not happen. + */ +export function installedVersion(): string | null { + try { + const here = dirname(fileURLToPath(import.meta.url)) + const parsed: unknown = JSON.parse(readFileSync(join(here, '..', '..', '..', 'package.json'), 'utf8')) + const version = (parsed as { version?: unknown }).version + return typeof version === 'string' && version !== '' ? version : null + } catch { + return null + } +} diff --git a/src/cli.ts b/src/cli.ts index fea92ad..dc140e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -91,6 +91,20 @@ function fail(err: unknown): never { } export async function runCli(argv: string[]): Promise { + // `--cc-version` BEFORE parsing, and deliberately NOT `--version`. + // + // `--version` belongs to the agent. swisscode is a drop-in launcher, and a + // drop-in that intercepted the target's most common flag would be a trap: + // `swisscode --version` has always printed Claude Code's version, scripts + // rely on it, and quietly changing that to print ours would break them for a + // convenience. The `--cc-` prefix is the reserved namespace that exists for + // exactly this, so swisscode's own version answers there. + if (argv[0] === '--cc-version') { + const { installedVersion } = await import('./adapters/store/fs-version-store.ts') + console.log(installedVersion() ?? 'unknown') + return + } + const parsed = parseArgv(argv) // An unknown --cc-* option, a --cc-model with a bad tier, a repeated diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index 58b5429..ff45236 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -35,7 +35,17 @@ import { describeIdentity, readSessionIdentity } from '../adapters/claude-sessio import { accountLogin } from '../adapters/claude-session/onboard.ts' import { swapCredential } from '../adapters/claude-session/swap.ts' import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' import { CONFLICT_REASON, accountsUsedBy, credentialSource } from '../core/account.ts' +import { isNewer } from '../core/version.ts' +import { + createFsVersionStore, + installedVersion, + isStale, + type VersionStorePort, +} from '../adapters/store/fs-version-store.ts' +import { fetchLatestVersion } from '../adapters/net/latest-version.ts' import { formatWindow } from '../core/format.ts' import type { LaunchDeps } from './launch-root.ts' import type { LoadResult, Profile, State } from '../ports/config-store.ts' @@ -90,6 +100,8 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config accounts login adopt a Claude subscription: makes a session [--dir ] directory and runs the agent so you can /login swisscode config accounts usage how much of each subscription is left, and cache it + swisscode config upgrade check npm for a newer swisscode and install it + [--dry-run] (--dry-run only prints the command) swisscode config accounts swap move one account's login into another session --into directory — narrower than /login, which hits every running session at once @@ -158,6 +170,11 @@ export async function runConfigCommand({ return 0 } + // A side errand, never awaited and never able to fail the command: the + // launch path cannot check for updates itself, so the commands that ARE + // allowed to reach the network keep the cache warm for it. + void refreshVersionCheck(deps.version ?? createFsVersionStore()).catch(() => {}) + switch (head) { case 'list': return listProfiles({ deps, out }) @@ -178,6 +195,8 @@ export async function runConfigCommand({ return doctorCommand({ deps, args: rest, out, err }) case 'web': return webCommand({ deps, args: rest, out, err }) + case 'upgrade': + return upgradeCommand({ deps, args: rest, out, err }) case 'accounts': return accountsCommand({ deps, args: rest, out, err }) case 'agents': @@ -995,6 +1014,140 @@ function swapAccount({ return 0 } + +/** + * Refresh the cached "latest published version", at most once a day. + * + * THE LAUNCH PATH CANNOT DO THIS — it may not touch the network, by an + * invariant `test/architecture.test.ts` enforces — so the commands that are + * already allowed to reach out do it on its behalf, and the launcher reads the + * file they leave behind. + * + * Fire-and-forget and never awaited by a command's happy path: a registry that + * is slow, down, or replaced by a private mirror that has never heard of + * swisscode must not delay or fail `config list`. + */ +async function refreshVersionCheck(store: VersionStorePort, now = Date.now()): Promise { + if (!isStale(store.read(), now)) return + // A TIGHTER TIMEOUT THAN AN ASKED-FOR REQUEST DESERVES, on purpose. The + // command's own output is already printed by the time this settles, so what + // this budget really buys is how long the shell prompt is held afterwards by + // a request nobody asked for. At most once a day, and 1.5s is the most a side + // errand should ever cost someone. + const latest = await fetchLatestVersion({ timeoutMs: 1500 }) + if (latest) store.write({ latest, checkedAt: now }) +} + +/** + * How this copy of swisscode was installed, inferred from where it is running. + * + * INFERENCE, AND SAID SO. There is no reliable API for "which package manager + * put you here", so this reads the module path — which is honest for the common + * installs and admits when it does not recognise one rather than guessing a + * command that might do something unexpected to someone's system. + */ +export function detectInstall(modulePath: string): { + kind: 'npm-global' | 'bun-global' | 'ephemeral' | 'unknown' + command: string | null + note: string +} { + const path = modulePath.replace(/\\/g, '/') + // Ephemeral runners are checked FIRST: their caches live inside paths that + // also contain "node_modules", so testing for a global install first would + // misread them and recommend an install the user never wanted. + if (/[/](_npx|\.npm[/]_npx)[/]/.test(path)) { + return { + kind: 'ephemeral', + command: null, + note: 'running via npx, which resolves the latest version each time — nothing to upgrade.', + } + } + if (/[/]\.bun[/]install[/]cache[/]/.test(path)) { + return { + kind: 'ephemeral', + command: null, + note: + 'running from bun\'s cache. bunx can serve a STALE version — use `bunx swisscode@latest` ' + + 'to force a fresh resolve, or install it properly with `bun install -g swisscode`.', + } + } + if (/[/]\.bun[/]/.test(path)) { + return { kind: 'bun-global', command: 'bun install -g swisscode', note: 'installed globally with bun.' } + } + if (/[/]node_modules[/]/.test(path)) { + return { kind: 'npm-global', command: 'npm install -g swisscode', note: 'installed with npm.' } + } + return { + kind: 'unknown', + command: null, + note: 'could not tell how this was installed — upgrade it the way you installed it.', + } +} + +/** + * `swisscode config upgrade` — say what is out, and offer to fetch it. + * + * Prints the command BEFORE running it. This is the only command in swisscode + * that modifies something outside the config directory, and a tool that + * silently reinstalled itself would be exactly the kind of surprise this + * project spends its comments avoiding. + */ +async function upgradeCommand({ + deps, + args, + out, + err, +}: { + deps: LaunchDeps + args: string[] + out: Emit + err: Emit +}): Promise { + const store = deps.version ?? createFsVersionStore() + const running = installedVersion() + out(`installed ${running ?? 'unknown'}`) + + const latest = (await fetchLatestVersion()) ?? store.read()?.latest ?? null + if (!latest) { + err('swisscode: could not reach the registry, so there is nothing to compare against.') + return 1 + } + if (latest) store.write({ latest, checkedAt: Date.now() }) + out(`published ${latest}`) + + const install = detectInstall(fileURLToPath(import.meta.url)) + if (!isNewer(latest, running)) { + out('') + out('Already current.') + // Still worth saying for a bunx user: "current" here describes the copy + // that is running, which bunx may have taken from its cache. + if (install.kind === 'ephemeral') out(` note: ${install.note}`) + return 0 + } + + out('') + out(` ${install.note}`) + if (!install.command) return 0 + + if (args.includes('--dry-run')) { + out(` would run: ${install.command}`) + return 0 + } + out(` running: ${install.command}`) + out('') + try { + const [bin, ...rest] = install.command.split(' ') + execFileSync(bin!, rest, { stdio: 'inherit' }) + out('') + out(`Upgraded. Check with \`swisscode --cc-version\`.`) + return 0 + } catch (e) { + err(`swisscode: the upgrade command failed: ${(e as { message?: string }).message ?? 'unknown error'}`) + err(`Run it yourself: ${install.command}`) + return 1 + } +} + function accountsCommand({ deps, args, diff --git a/src/composition/launch-root.ts b/src/composition/launch-root.ts index 6ee253c..e9ef239 100644 --- a/src/composition/launch-root.ts +++ b/src/composition/launch-root.ts @@ -14,6 +14,12 @@ import { resolveProfileRefs, type CursorPort } from '../core/resolve.ts' import { createFsConfigStore } from '../adapters/store/fs-config-store.ts' import { createFsCursorStore } from '../adapters/store/fs-cursor-store.ts' import { createFsUsageStore, type UsageStorePort } from '../adapters/store/fs-usage-store.ts' +import { + createFsVersionStore, + installedVersion, + type VersionStorePort, +} from '../adapters/store/fs-version-store.ts' +import { isNewer } from '../core/version.ts' import { createNodeProcess, detectRecursion } from '../adapters/process/node-process.ts' import { registry as providerRegistry } from '../adapters/providers/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' @@ -64,6 +70,16 @@ export type LaunchDeps = { * network, which this path is banned from reaching. */ usage?: UsageStorePort + /** + * The last version the registry reported, READ ONLY here. + * + * The launch path may not ask the network — that is the invariant the whole + * project is built on — so it cannot check for updates. It reads what + * `config`/`doctor`/the web UI already learned and says one line if the + * installed build is behind. Optional, so a caller that wires nothing simply + * gets no notice rather than a fabricated one. + */ + version?: VersionStorePort } export function defaultDeps(): LaunchDeps { @@ -74,6 +90,7 @@ export function defaultDeps(): LaunchDeps { proc: createNodeProcess(), cursor: createFsCursorStore(), usage: createFsUsageStore(), + version: createFsVersionStore(), } } @@ -385,7 +402,7 @@ export function main({ deps = null, report = defaultReport, }: MainOptions): PlannedLaunch { - const { store, registry, agents, proc } = deps ?? defaultDeps() + const { store, registry, agents, proc, version } = deps ?? defaultDeps() const planned = planLaunch({ store, registry, @@ -419,6 +436,20 @@ export function main({ const banner = bannerFor(planned) if (banner) report(banner) + + // ONE LINE, FROM A FILE, NEVER FROM THE NETWORK. + // + // The launch path is forbidden to make network calls, so it cannot check + // for updates — it reads what `config`/`doctor`/the web UI last learned. + // The consequence is honest and worth knowing: someone who ONLY ever + // launches never refreshes the cache and so never sees this. That is the + // correct trade. A launcher that phoned home on every run, or spawned + // something that did, would be a different and worse tool. + const known = version?.read()?.latest + const running = known ? installedVersion() : null + if (isNewer(known, running)) { + report(`swisscode: ${running} is installed; ${known} is out. Upgrade: swisscode config upgrade`) + } } // Never stdout: stdout may be piped into something that parses it. diff --git a/src/core/version.ts b/src/core/version.ts new file mode 100644 index 0000000..dfb4485 --- /dev/null +++ b/src/core/version.ts @@ -0,0 +1,42 @@ +// Is the installed swisscode older than the published one? +// +// Pure, and deliberately the smallest comparison that is correct for THIS +// project's versions rather than a general semver implementation. swisscode +// publishes plain `MAJOR.MINOR.PATCH` — no ranges, no build metadata — and a +// full semver parser on the launch path would be a dependency (forbidden) or a +// few hundred lines of range logic nothing here asks for. +// +// PRERELEASES ARE HANDLED BY BEING IGNORED, explicitly: a version carrying a +// `-` suffix never triggers a notice. Someone running `swisscode@next` opted +// into being ahead, and nagging them to "upgrade" to a lower stable number +// would be both wrong and impossible to act on. + +/** `1.2.3` -> [1,2,3]; anything else -> null. */ +function parse(version: string): [number, number, number] | null { + const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim()) + if (!match) return null + // `!` on three groups a successful match guarantees. + return [Number(match[1]!), Number(match[2]!), Number(match[3]!)] +} + +/** + * Whether `latest` is strictly newer than `current`. + * + * FALSE IS THE ANSWER FOR EVERYTHING UNCERTAIN — unparseable input on either + * side, a prerelease, equal versions, or a `latest` that is somehow older. + * This drives a message that interrupts someone's launch, so the bar is "we are + * sure", not "we suspect". A missed notice costs nothing; a wrong one trains + * people to ignore the next. + */ +export function isNewer(latest: string | null | undefined, current: string | null | undefined): boolean { + if (!latest || !current) return false + const a = parse(latest) + const b = parse(current) + if (!a || !b) return false + for (let i = 0; i < 3; i++) { + // `!` — both tuples are fixed length 3. + if (a[i]! > b[i]!) return true + if (a[i]! < b[i]!) return false + } + return false +} diff --git a/test/adapters/version-check.test.ts b/test/adapters/version-check.test.ts new file mode 100644 index 0000000..8bbd3e1 --- /dev/null +++ b/test/adapters/version-check.test.ts @@ -0,0 +1,237 @@ +// The update notice, end to end, without a network. +// +// The shape being pinned here is the whole design: the LAUNCH PATH MAY NOT ASK +// THE REGISTRY (test/architecture.test.ts forbids fetch there), so it reads a +// file that some earlier config/doctor/web command wrote. These tests exist to +// keep that split from quietly collapsing into "just fetch it at launch". +import test from 'node:test' +import assert from 'node:assert/strict' +import { chmodSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + VERSION_TTL_MS, + createFsVersionStore, + installedVersion, + isStale, +} from '../../src/adapters/store/fs-version-store.ts' +import { fetchLatestVersion } from '../../src/adapters/net/latest-version.ts' +import { detectInstall } from '../../src/composition/config-root.ts' +import { main } from '../../src/composition/launch-root.ts' +import { registry } from '../../src/adapters/providers/registry.ts' +import { registry as agents } from '../../src/adapters/agents/registry.ts' + +const fresh = () => mkdtempSync(join(tmpdir(), 'swisscode-version-')) + +// ── the cache ── + +test('a snapshot round-trips, 0600 in a 0700 directory', () => { + const dir = join(fresh(), 'state') + const store = createFsVersionStore({ dir }) + store.write({ latest: '0.5.0', checkedAt: 123 }) + assert.deepEqual(store.read(), { latest: '0.5.0', checkedAt: 123 }) + assert.equal(statSync(dir).mode & 0o777, 0o700) + assert.equal(statSync(join(dir, 'version.json')).mode & 0o777, 0o600) +}) + +test('a corrupt or partial file reads as absent, never as a version', () => { + const dir = fresh() + const store = createFsVersionStore({ dir }) + for (const body of ['', 'not json', '[]', 'null', '{}', '{"latest":""}', '{"latest":"1.0.0"}', '{"checkedAt":1}']) { + writeFileSync(join(dir, 'version.json'), body) + assert.equal(store.read(), null, `${JSON.stringify(body)} should read as absent`) + } +}) + +test('an unwritable directory does not throw — the command already did its work', () => { + const parent = fresh() + const dir = join(parent, 'state') + const store = createFsVersionStore({ dir }) + store.write({ latest: '0.5.0', checkedAt: 1 }) + // Make it read-only and write again; the failure must be swallowed. + chmodSync(dir, 0o500) + try { + assert.doesNotThrow(() => store.write({ latest: '0.6.0', checkedAt: 2 })) + } finally { + chmodSync(dir, 0o700) + } +}) + +test('staleness drives whether to spend a request, and a backwards clock counts as stale', () => { + const now = 1_000_000_000 + assert.equal(isStale(null, now), true, 'never checked') + assert.equal(isStale({ latest: '1.0.0', checkedAt: now - 1000 }, now), false, 'just checked') + assert.equal(isStale({ latest: '1.0.0', checkedAt: now - VERSION_TTL_MS - 1 }, now), true) + // A checkedAt in the future would otherwise read as "definitely fresh" and + // could wedge the check off permanently. + assert.equal(isStale({ latest: '1.0.0', checkedAt: now + 60_000 }, now), true) +}) + +test('the running version is read from the manifest, not baked in', () => { + // Baked-in constants are exactly the thing that goes stale, and this value's + // only job is to be compared against the registry. + const version = installedVersion() + assert.match(String(version), /^\d+\.\d+\.\d+/) + assert.equal(version, JSON.parse(readFileSync('package.json', 'utf8')).version) +}) + +// ── the registry read ── + +test('the latest dist-tag is picked out of the abbreviated packument', async () => { + const seen: { url: string; accept: unknown }[] = [] + const latest = await fetchLatestVersion({ + fetchImpl: (async (url: string, init: { headers: Record }) => { + seen.push({ url, accept: init.headers.accept }) + return { ok: true, json: async () => ({ 'dist-tags': { latest: '9.9.9' }, versions: {} }) } + }) as unknown as typeof fetch, + }) + assert.equal(latest, '9.9.9') + // The abbreviated form is what npm's own installer asks for: a few kB instead + // of a few hundred, for one string. + assert.equal(seen[0]?.accept, 'application/vnd.npm.install-v1+json') + assert.match(String(seen[0]?.url), /registry\.npmjs\.org\/swisscode$/) +}) + +test('every registry failure answers null rather than throwing', async () => { + // This runs as a side errand of a command the user actually asked for. An + // update check that could fail `config list` would be the worse bug. + const cases: Array<() => unknown> = [ + () => ({ ok: false, json: async () => ({}) }), + () => ({ ok: true, json: async () => null }), + () => ({ ok: true, json: async () => ({}) }), + () => ({ ok: true, json: async () => ({ 'dist-tags': {} }) }), + () => ({ ok: true, json: async () => ({ 'dist-tags': { latest: 42 } }) }), + () => { + throw new Error('offline') + }, + ] + for (const [i, make] of cases.entries()) { + const got = await fetchLatestVersion({ fetchImpl: (async () => make()) as unknown as typeof fetch }) + assert.equal(got, null, `case ${i} should be null`) + } +}) + +// ── install detection ── + +test('an ephemeral runner is recognised BEFORE a global install', () => { + // npx and bun caches both sit inside paths containing "node_modules", so + // checking for a global install first would misread them and recommend an + // install the user never asked for. + assert.equal(detectInstall('/Users/me/.npm/_npx/abc123/node_modules/swisscode/dist/x.js').kind, 'ephemeral') + assert.equal(detectInstall('/Users/me/.bun/install/cache/swisscode@0.3.0@@@1/dist/x.js').kind, 'ephemeral') +}) + +test('the bunx note names the stale-cache trap, because that is a real failure', () => { + // Reproduced against the live registry: bunx served a cached 0.3.0 while npx + // resolved 0.4.0, and 0.3.0 cannot read a v3 config. + const bunx = detectInstall('/Users/me/.bun/install/cache/swisscode@0.3.0@@@1/dist/x.js') + assert.match(bunx.note, /stale/i) + assert.match(bunx.note, /swisscode@latest/) + assert.equal(bunx.command, null, 'there is nothing to install for an ephemeral run') +}) + +test('global installs map to the right package manager', () => { + assert.equal( + detectInstall('/usr/local/lib/node_modules/swisscode/dist/x.js').command, + 'npm install -g swisscode', + ) + assert.equal(detectInstall('/Users/me/.bun/bin/swisscode').command, 'bun install -g swisscode') +}) + +test('an unrecognised location admits it rather than guessing a command', () => { + const unknown = detectInstall('/opt/weird/place/swisscode.js') + assert.equal(unknown.kind, 'unknown') + assert.equal(unknown.command, null, 'guessing here could run something unexpected on a machine') +}) + +// ── the notice at launch ── + +/** A launch harness whose `replace` records instead of replacing the process. */ +function launchHarness(versionStore: ReturnType | undefined) { + const reported: string[] = [] + const replaced: string[] = [] + const state = { + version: 3, + providerAccounts: { z: { provider: 'zai', apiKey: 'k' } }, + agentProfiles: { z: { models: { opus: 'glm-5.2' } } }, + profiles: { z: { agentProfile: 'z', accounts: ['z'] } }, + defaultProfile: 'z', + bindings: {}, + settings: {}, + } + return { + reported, + replaced, + deps: { + store: { + load: () => ({ state, corrupt: false, readOnly: false, migrated: false, warnings: [] }), + save: () => '/tmp/config.json', + path: () => '/tmp/config.json', + }, + registry, + agents, + proc: { + env: () => ({}), + cwd: () => '/work', + resolveBinary: () => '/usr/local/bin/claude', + replace: (bin: string) => { + replaced.push(bin) + }, + }, + ...(versionStore ? { version: versionStore } : {}), + }, + report: (line: string) => reported.push(line), + } +} + +test('a newer cached version produces ONE line, and still launches', async () => { + const store = createFsVersionStore({ dir: fresh() }) + store.write({ latest: '99.0.0', checkedAt: Date.now() }) + const h = launchHarness(store) + main({ deps: h.deps as never, report: h.report }) + + const notice = h.reported.filter((l) => /is out/.test(l)) + assert.equal(notice.length, 1, 'exactly one notice, not one per warning channel') + assert.match(notice[0]!, /99\.0\.0/) + assert.match(notice[0]!, /swisscode config upgrade/) + // The point of the whole design: it still hands off. A version notice must + // never be a reason a launch does not happen. + assert.deepEqual(h.replaced, ['/usr/local/bin/claude']) +}) + +test('an up-to-date or absent cache says NOTHING', async () => { + for (const store of [ + createFsVersionStore({ dir: fresh() }), // never written + (() => { + const s = createFsVersionStore({ dir: fresh() }) + s.write({ latest: '0.0.1', checkedAt: Date.now() }) + return s + })(), + undefined, // nothing wired at all + ]) { + const h = launchHarness(store) + main({ deps: h.deps as never, report: h.report }) + assert.equal(h.reported.filter((l) => /is out/.test(l)).length, 0) + assert.deepEqual(h.replaced, ['/usr/local/bin/claude']) + } +}) + +test('SWISSCODE_QUIET suppresses the notice with everything else', async () => { + const store = createFsVersionStore({ dir: fresh() }) + store.write({ latest: '99.0.0', checkedAt: Date.now() }) + const h = launchHarness(store) + h.deps.proc.env = () => ({ SWISSCODE_QUIET: '1' }) + main({ deps: h.deps as never, report: h.report }) + assert.deepEqual(h.reported, [], 'quiet means quiet') + assert.deepEqual(h.replaced, ['/usr/local/bin/claude']) +}) + +test('a stale cache still warns — the launcher never refreshes it itself', async () => { + // Deliberate. The launch path may not reach the network, so a day-old version + // number is the best it can have, and it is still true enough to warn on. + const store = createFsVersionStore({ dir: fresh() }) + store.write({ latest: '99.0.0', checkedAt: Date.now() - VERSION_TTL_MS * 30 }) + const h = launchHarness(store) + main({ deps: h.deps as never, report: h.report }) + assert.equal(h.reported.filter((l) => /is out/.test(l)).length, 1) +}) diff --git a/test/core/version.test.ts b/test/core/version.test.ts new file mode 100644 index 0000000..04062bf --- /dev/null +++ b/test/core/version.test.ts @@ -0,0 +1,54 @@ +// Deciding whether the installed build is behind the published one. +// +// This drives a line that interrupts someone's launch, so the whole point of +// these tests is the NEGATIVE cases: everything uncertain must answer "no". +import test from 'node:test' +import assert from 'node:assert/strict' +import { isNewer } from '../../src/core/version.ts' + +test('a strictly greater version is newer, component by component', () => { + assert.equal(isNewer('0.4.0', '0.3.0'), true) + assert.equal(isNewer('1.0.0', '0.99.99'), true) + assert.equal(isNewer('0.4.1', '0.4.0'), true) + assert.equal(isNewer('0.10.0', '0.9.0'), true, 'components compare numerically, not as strings') +}) + +test('equal or older is NOT newer', () => { + assert.equal(isNewer('0.4.0', '0.4.0'), false) + assert.equal(isNewer('0.3.0', '0.4.0'), false) + assert.equal(isNewer('0.9.0', '0.10.0'), false, 'string comparison would get this wrong') +}) + +test('a prerelease NEVER triggers a notice, in either position', () => { + // Someone on `swisscode@next` opted into being ahead. Telling them to + // "upgrade" to a lower stable number would be wrong and unactionable. + assert.equal(isNewer('0.5.0-beta.1', '0.4.0'), false) + assert.equal(isNewer('0.5.0', '0.5.0-beta.1'), false) +}) + +test('anything unparseable answers no rather than guessing', () => { + // A missed notice costs nothing. A wrong one trains people to ignore the next. + for (const [latest, current] of [ + ['', '0.4.0'], + ['0.4.0', ''], + ['garbage', '0.4.0'], + ['0.4.0', 'garbage'], + ['0.4', '0.3.0'], + ['v0.5.0', '0.4.0'], + ['0.4.0.1', '0.4.0'], + ]) { + assert.equal(isNewer(latest, current), false, `${latest} vs ${current} should be undecidable`) + } +}) + +test('a missing value on either side answers no', () => { + // Both are real: the cache may hold nothing, and reading the installed + // version off the manifest can fail. + assert.equal(isNewer(null, '0.4.0'), false) + assert.equal(isNewer('0.5.0', null), false) + assert.equal(isNewer(undefined, undefined), false) +}) + +test('surrounding whitespace does not defeat the comparison', () => { + assert.equal(isNewer(' 0.5.0\n', '0.4.0'), true) +}) From 24aaba260d320ef5cebd26bc304329581baadb9e Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:00:05 -0400 Subject: [PATCH 3/4] refactor(web): the browser picker uses core, and stops being the worse one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 <31935831+jellologic@users.noreply.github.com> --- web/src/api.ts | 26 ++++++++++------- web/src/routes/ModelPicker.tsx | 52 +++++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/web/src/api.ts b/web/src/api.ts index 3497ef9..ab4c1c3 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -208,20 +208,26 @@ export type DoctorReport = { summary: { counts: Record; exitCode: number } } -export type CatalogModel = { - id: string - name: string - description?: string - context: number | null - pricing: { prompt: number; completion: number } | null - /** TRI-STATE. null is UNKNOWN, false is CONFIRMED ABSENT. Do not collapse. */ - tools: boolean | null -} +/** + * A catalog row, RE-EXPORTED FROM CORE rather than mirrored here. + * + * This used to be a hand-written subset — id, name, context, pricing, tools — + * and the subset was the bug. The server sends the whole `NormalizedModel` + * (the catalog endpoint spreads its result), so the browser was already + * receiving `benchmarks` and simply could not see them: the picker could not + * call core's `rank`, which sorts on the coding benchmark, and so showed models + * in whatever order the provider happened to list them. + * + * A type-only import — it erases, costs the bundle nothing, and cannot drift. + */ +import type { CatalogCapabilities, NormalizedModel } from '../../src/ports/catalog' + +export type CatalogModel = NormalizedModel export type CatalogResult = { id: string label: string - capabilities: { pricing: boolean; benchmarks: boolean; toolSupportKnown: boolean } + capabilities: CatalogCapabilities models: CatalogModel[] fromCache: boolean stale: boolean diff --git a/web/src/routes/ModelPicker.tsx b/web/src/routes/ModelPicker.tsx index 53bd397..6b33db5 100644 --- a/web/src/routes/ModelPicker.tsx +++ b/web/src/routes/ModelPicker.tsx @@ -2,6 +2,12 @@ import { useEffect, useMemo, useState } from 'react' import { css } from '../../styled-system/css' import { ApiError, api, type CatalogModel, type CatalogResult } from '../api' import { Banner, Button, inputStyle } from '../ui' +// The SAME filtering, ranking and formatting the Ink picker uses. These are +// properties of the data, not of the widget, and the browser had drifted: +// its own copy dropped the free-only filter, never sorted at all, and rendered +// a free model as "$0.00/M" — the exact thing this file's header forbids. +import { filterModels, rank } from '../../../src/core/catalog' +import { formatContext, formatPrice } from '../../../src/core/format' /** * The browsable model list, for providers that publish one. @@ -15,6 +21,20 @@ import { Banner, Button, inputStyle } from '../ui' * * Nothing missing is rendered as a number. A catalog with no prices shows * no prices, rather than "$0.00", which would read as free. */ +/** + * The per-million price pair, or just "free". + * + * `formatPrice` already returns "free" for zero — appending "/M in" to that + * gives "free/M in", which is not a thing anyone says. The FORMATTING is + * core's; only this bit of phrasing is the browser's, which is exactly the + * split that should exist between them. + */ +function priceLabel(pricing: { prompt: number; completion: number }): string { + const input = formatPrice(pricing.prompt) + const output = formatPrice(pricing.completion) + return input === 'free' && output === 'free' ? 'free' : `${input}/M in · ${output}/M out` +} + export function ModelPicker({ catalogId, tier, @@ -30,6 +50,7 @@ export function ModelPicker({ const [error, setError] = useState(null) const [query, setQuery] = useState('') const [toolsOnly, setToolsOnly] = useState(true) + const [freeOnly, setFreeOnly] = useState(false) useEffect(() => { let live = true @@ -44,17 +65,12 @@ export function ModelPicker({ const rows = useMemo(() => { if (!data) return [] - const terms = query.toLowerCase().split(/\s+/).filter(Boolean) - // The filter is inert when the catalog cannot speak to tool support, which - // is what stops it hiding everything. - const filterActive = toolsOnly && data.capabilities.toolSupportKnown - return data.models.filter((m) => { - if (filterActive && m.tools === false) return false - if (terms.length === 0) return true - const hay = `${m.id} ${m.name}`.toLowerCase() - return terms.every((t) => hay.includes(t)) - }) - }, [data, query, toolsOnly]) + // `rank` before display, exactly as the terminal picker does: models with a + // published coding benchmark first and best-first, then everything else + // alphabetically. Registry order is arbitrary — it put the useful models + // wherever the provider happened to list them. + return filterModels(data.models, { query, toolsOnly, freeOnly }, data.capabilities).sort(rank) + }, [data, query, toolsOnly, freeOnly]) return (
) : null} + {data?.capabilities.pricing ? ( + + ) : null} {data?.stale ? stale cache : null} {data?.fromCache && !data.stale ? cached : null}
@@ -174,12 +196,8 @@ export function ModelPicker({
{m.name} {/* Absent data stays absent. "$0.00" would read as free. */} - {m.pricing - ? ` · $${(m.pricing.prompt * 1_000_000).toFixed(2)}/M in · $${( - m.pricing.completion * 1_000_000 - ).toFixed(2)}/M out` - : ''} - {m.context ? ` · ${Math.round(m.context / 1000)}K context` : ''} + {m.pricing ? ` · ${priceLabel(m.pricing)}` : ''} + {m.context ? ` · ${formatContext(m.context)} context` : ''}
))} From da4cb2bee4fd38050fd58e78032ae44b992a69ad Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:20:37 -0400 Subject: [PATCH 4/4] build: make web an npm workspace, with one lockfile and both runtimes working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 <31935831+jellologic@users.noreply.github.com> --- .gitignore | 8 ++++++++ AGENTS.md | 12 ++++++++++++ build.js | 30 ++++++++++++++++++++++++------ package-lock.json | 25 +++++++++++++++++++------ package.json | 12 +++++------- web/package.json | 17 +++++++++++++++++ 6 files changed, 85 insertions(+), 19 deletions(-) create mode 100644 web/package.json diff --git a/.gitignore b/.gitignore index d063c3d..74679ed 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,11 @@ web/styled-system/ # Playwright MCP writes screenshots here during manual verification .playwright-mcp/ profiles.png + +# Bun resolves fresh rather than reading package-lock.json, so a committed +# bun.lock would drift from it silently — measured at 5 transitive packages. +# package-lock.json is canonical: `npm ci` gates CI, and npm trusted publishing +# (OIDC + provenance) keeps the npm CLI in the release path permanently. +# Bun stays a first-class RUNTIME — `bun run`, `bunx` — just not the installer. +bun.lock +bun.lockb diff --git a/AGENTS.md b/AGENTS.md index d8e1e79..2671fc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ runtime assertions and is checked by `tsc`. `npm run typecheck` runs it. | `src/composition/**` | Five composition roots: `launch-root` (hot path), `config-root`, `doctor-root`, `ui-root`, `web-root`. | | `bin/swisscode.js` | Published entry shim. Plain JS, never compiled, imports exactly `../dist/cli.js`. | | `test/**` | `.ts`, run from source, never compiled, never packed. | +| `web/**` | An npm WORKSPACE (`@swisscode/web`, private). Owns the browser-only devDeps — vite, panda, preact, `@types/react-dom`. Ships as built assets inside the swisscode package, never as its own release. | ## Hard invariants @@ -196,6 +197,17 @@ neutral `LaunchIntent` is missing something — propose that instead. ## Useful facts +- **`package-lock.json` is the only lockfile. Install with npm.** `bun install` + does not read it and resolves fresh — measured drift of 5 transitive packages + — and `bun publish` has no OIDC/provenance, so the npm CLI is permanently in + the release path (`publish.yml`). `bun.lock` is gitignored. +- **Bun is a first-class RUNTIME, not the installer.** `bun run