diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts index 9f5856c..7958920 100644 --- a/src/adapters/web/api.ts +++ b/src/adapters/web/api.ts @@ -4,15 +4,30 @@ // object out, so every branch — including every refusal — is testable without // binding a socket. The server module is the only part that knows about sockets. // -// It is thin by construction. Everything it does is already a port operation, -// which is the whole reason a second UI was cheap: the Ink wizard and this API -// are two adapters over one unchanged core. +// It is thin by construction, and now literally so: every mutation below is a +// pure function in core/operations.ts, and this file's remaining job is HTTP — +// routing, revision conflicts, redaction and status codes. That split is what +// makes a second front end cheap. It was not always true; the rules used to +// live half here and half in the Ink wizard's own `finish()`, with no way to +// tell the two agreed. 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 { + deleteAccount, + deleteProfile, + deleteProvider, + deleteSetup, + parseAccount, + parseSetup, + putAccount, + putProfile, + putProvider, + putSettings, + putSetup, + setDefaultProfile, +} from '../../core/operations.ts' +import type { OpResult } from '../../core/operations.ts' import type { IdentityCollision } from '../../core/account.ts' import { COMPAT_ENV, CREDENTIAL_ENVS } from '../agents/claude-code/env.ts' import { CATALOG_SOURCE, CLAUDE_ENV_CATALOG } from '../agents/claude-code/env-catalog.ts' @@ -188,125 +203,24 @@ function commit(store: ConfigStorePort, state: State, extra: unknown = {}): ApiR } /** - * A provider account submitted by the browser. - * - * Whitelisted rather than spread: an unknown key from a hostile or buggy client - * must not reach config.json, where a future swisscode would read it as - * meaningful. - * - * `apiKey` is accepted (write-only) but only when NON-EMPTY — an empty string - * from a form the user did not touch must not erase a stored key, which is the - * single most destructive mistake this endpoint could make. Clearing is an - * explicit `null`, so "I did not touch this" and "remove my credential" stay - * different requests. + * The shape parsers moved to core/operations.ts with the mutations they belong + * to. Re-exported here because they are part of this module's tested surface + * and callers should not have to care that the rules relocated. */ -export function parseAccount( - input: unknown, - existing: ProviderAccount | undefined, -): ProviderAccount | string { - if (!isObjectLike(input)) return 'account must be an object' - const provider = str(input.provider) ?? existing?.provider - if (!provider) return 'provider is required' - - const account: ProviderAccount = { ...(existing ?? {}), provider } - if (typeof input.label === 'string') account.label = input.label - if (typeof input.configDir === 'string') { - if (input.configDir) account.configDir = input.configDir - else delete account.configDir - } - if (typeof input.baseUrl === 'string') account.baseUrl = input.baseUrl - if (typeof input.apiKey === 'string' && input.apiKey.length > 0) account.apiKey = input.apiKey - if (input.apiKey === null) delete account.apiKey - if (typeof input.apiKeyFromEnv === 'string') { - if (input.apiKeyFromEnv) account.apiKeyFromEnv = input.apiKeyFromEnv - else delete account.apiKeyFromEnv - } - // The two modes are MUTUALLY EXCLUSIVE, and the conflict is refused rather - // 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 -} - -/** A setup submitted by the browser. Holds no credential. */ -export function parseAgentProfile( - input: unknown, - existing: Setup | undefined, -): Setup | string { - if (!isObjectLike(input)) return 'setup must be an object' - const setup: Setup = { ...(existing ?? {}) } - - if (typeof input.label === 'string') setup.label = input.label - if (typeof input.agent === 'string') setup.agent = input.agent - if (typeof input.skipPermissions === 'boolean') { - setup.skipPermissions = input.skipPermissions - } - - if (isObjectLike(input.models)) { - const models: Record = {} - for (const tier of TIERS) { - const v = input.models[tier] - if (typeof v === 'string') models[tier] = v - } - setup.models = models - } - - if (isObjectLike(input.compat)) { - const compat: Record = {} - for (const [k, v] of Object.entries(input.compat)) { - if (typeof v === 'boolean') compat[k] = v - } - setup.compat = compat as NonNullable - } - - if (isObjectLike(input.env)) { - const env: Record = {} - for (const [k, v] of Object.entries(input.env)) { - if (typeof v === 'string') env[k] = v - } - setup.env = env - } - - // Measured windows only. A non-integer or non-positive entry is dropped - // rather than stored: this feeds CLAUDE_CODE_AUTO_COMPACT_WINDOW, and a - // window set too large overflows the conversation instead of compacting it. - if (isObjectLike(input.contextWindows)) { - const windows: Record = {} - for (const [model, v] of Object.entries(input.contextWindows)) { - if (typeof v === 'number' && Number.isInteger(v) && v > 0) windows[model] = v - } - setup.contextWindows = windows - } - - return setup +export { parseAccount, parseSetup, parseProfile } from '../../core/operations.ts' + +/** Map a core refusal onto the status code it deserves. */ +function refuse(result: Extract): ApiResponse { + const status = result.kind === 'missing' ? 404 : 400 + return result.reasons + ? json(status, { error: result.reason, errors: result.reasons }) + : fail(status, result.reason) } -/** - * The pairing. References only — no credential, no agent settings. - * - * References are NOT validated against the store here; that is the caller's - * job, because it holds the state and can name what is missing. Validating - * shape and validating existence are different failures and deserve different - * messages. - */ -export function parseProfile(input: unknown, existing: Profile | undefined): Profile | string { - if (!isObjectLike(input)) return 'profile must be an object' - const setup = str(input.setup) ?? existing?.setup - if (!setup) return 'setup is required' - - const accounts = Array.isArray(input.accounts) - ? input.accounts.filter((a): a is string => typeof a === 'string' && a.length > 0) - : (existing?.accounts ?? []) - if (accounts.length === 0) return 'a profile needs at least one provider account' - - const profile: Profile = { ...(existing ?? {}), setup, accounts } - if (typeof input.label === 'string') profile.label = input.label - if (input.strategy === 'single' || input.strategy === 'round-robin' || input.strategy === 'usage') { - profile.strategy = input.strategy - } - return profile +/** Apply a pure operation and persist it, or report why it was refused. */ +function apply(store: ConfigStorePort, result: OpResult): ApiResponse { + if (!result.ok) return refuse(result) + return commit(store, result.state, result.meta) } export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { @@ -389,55 +303,16 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { if (!name) return fail(400, 'profile name is required') if (req.method === 'PUT') { - const valid = validateProfileName(name) - if (!valid.ok) return fail(400, valid.reason) const conflict = revisionConflict(store, req.body) if (conflict) return conflict - - const loaded = store.load() const body = isObjectLike(req.body) ? req.body.profile : null - const parsed = parseProfile(body, loaded.state.profiles?.[name]) - if (typeof parsed === 'string') return fail(400, parsed) - - // References are checked HERE, where the state is in hand. parseProfile - // validated the shape; this validates that the things it names exist. - if (!loaded.state.setups?.[parsed.setup]) { - return fail(400, `no setup named "${parsed.setup}"`) - } - const missing = parsed.accounts.filter((a) => !loaded.state.providerAccounts?.[a]) - if (missing.length > 0) { - return fail(400, `no provider account named "${missing[0]}"`) - } - - const state: State = { - ...loaded.state, - profiles: { ...loaded.state.profiles, [name]: parsed }, - } - // First profile created becomes the default, matching the wizard: a lone - // profile that is not the default is a state the CLI would then refuse to - // launch from. - if (!state.defaultProfile) state.defaultProfile = name - return commit(store, state) + return apply(store, putProfile(store.load().state, name, body)) } if (req.method === 'DELETE') { const conflict = revisionConflict(store, req.body) if (conflict) return conflict - const loaded = store.load() - if (!loaded.state.profiles?.[name]) return fail(404, `no profile named "${name}"`) - - const profiles = { ...loaded.state.profiles } - delete profiles[name] - // Bindings to a deleted profile are pruned, exactly as `config rm` does. - // Leaving them would make a directory silently fall back to the default. - const bindings = Object.fromEntries( - Object.entries(loaded.state.bindings ?? {}).filter(([, p]) => p !== name), - ) - const state: State = { ...loaded.state, profiles, bindings } - // `string | null`, not optional — null is the "no default" state the - // launcher already knows how to report, so clear rather than delete. - if (state.defaultProfile === name) state.defaultProfile = null - return commit(store, state) + return apply(store, deleteProfile(store.load().state, name)) } } @@ -452,33 +327,14 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { if (req.method === 'PUT') { const conflict = revisionConflict(store, req.body) if (conflict) return conflict - const loaded = store.load() - const parsed = parseAccount( - isObjectLike(req.body) ? req.body.account : null, - loaded.state.providerAccounts?.[name], - ) - if (typeof parsed === 'string') return fail(400, parsed) - return commit(store, { - ...loaded.state, - providerAccounts: { ...loaded.state.providerAccounts, [name]: parsed }, - }) + const body = isObjectLike(req.body) ? req.body.account : null + return apply(store, putAccount(store.load().state, name, body)) } if (req.method === 'DELETE') { const conflict = revisionConflict(store, req.body) if (conflict) return conflict - const loaded = store.load() - if (!loaded.state.providerAccounts?.[name]) return fail(404, `no account named "${name}"`) - - // Profiles referencing it are REPORTED, never silently repaired: only the - // user knows which account should pay instead. - const affected = accountsUsedBy(loaded.state.profiles, name) - - const accounts = { ...loaded.state.providerAccounts } - delete accounts[name] - return commit(store, { ...loaded.state, providerAccounts: accounts }, { - affectedProfiles: affected, - }) + return apply(store, deleteAccount(store.load().state, name)) } } @@ -489,29 +345,14 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { if (req.method === 'PUT') { const conflict = revisionConflict(store, req.body) if (conflict) return conflict - const loaded = store.load() - const parsed = parseAgentProfile( - isObjectLike(req.body) ? req.body.setup : null, - loaded.state.setups?.[name], - ) - if (typeof parsed === 'string') return fail(400, parsed) - return commit(store, { - ...loaded.state, - setups: { ...loaded.state.setups, [name]: parsed }, - }) + const body = isObjectLike(req.body) ? req.body.setup : null + return apply(store, putSetup(store.load().state, name, body)) } if (req.method === 'DELETE') { const conflict = revisionConflict(store, req.body) if (conflict) return conflict - const loaded = store.load() - if (!loaded.state.setups?.[name]) return fail(404, `no setup named "${name}"`) - const affected = Object.entries(loaded.state.profiles ?? {}) - .filter(([, pr]) => pr.setup === name) - .map(([n]) => n) - const setups = { ...loaded.state.setups } - delete setups[name] - return commit(store, { ...loaded.state, setups }, { affectedProfiles: affected }) + return apply(store, deleteSetup(store.load().state, name)) } } @@ -523,70 +364,36 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { const conflict = revisionConflict(store, req.body) if (conflict) return conflict - const loaded = store.load() const submitted = isObjectLike(req.body) ? req.body.provider : null - const candidate = isObjectLike(submitted) ? { ...submitted, id } : submitted - - // The runtime twin of registry.test.ts. A shipped descriptor is guarded - // by tests; one typed into a browser is guarded by exactly this call, so - // the two lists of rules have to stay in step. - const verdict = validateCustomProvider(candidate, { - // The BASE ids, not the merged list: a custom provider must not shadow - // a shipped preset, but it may of course overwrite ITSELF. + // The runtime twin of registry.test.ts. A shipped descriptor is guarded by + // tests; one typed into a browser is guarded by this call, so the two + // lists of rules have to stay in step. + // + // These three are PARAMETERS because they are Claude Code's: core/ may not + // name a CLAUDE_CODE_ or ANTHROPIC_ variable, so the adapter that owns + // them supplies them. RESERVED_PROVIDER_IDS is the BASE list, not the + // merged one — a custom provider must not shadow a shipped preset, but it + // may of course overwrite itself. + return apply(store, putProvider(store.load().state, id, submitted, { reservedIds: RESERVED_PROVIDER_IDS, knownCompatFlags: Object.keys(COMPAT_ENV), credentialEnvs: CREDENTIAL_ENVS, - }) - if (!verdict.ok) return json(400, { error: verdict.errors[0], errors: verdict.errors }) - - const providersMap = { ...(loaded.state.providers ?? {}) } - providersMap[id] = toCustomProvider(candidate as Record) - // Warnings ride along on success: they describe a config that is legal - // and probably wrong, which is the user's call to make, not ours. - return commit(store, { ...loaded.state, providers: providersMap }, { - warnings: verdict.warnings, - }) + })) } if (req.method === 'DELETE') { if (!id) return fail(400, 'provider id is required') const conflict = revisionConflict(store, req.body) if (conflict) return conflict - const loaded = store.load() - if (!loaded.state.providers?.[id]) return fail(404, `no custom provider named "${id}"`) - - // ACCOUNTS point at providers now, not profiles — so deleting a provider - // orphans accounts, and those in turn orphan whichever profiles use them. - // Both are reported, never silently repaired: only the user knows where a - // profile should point next. - const orphanedAccounts = Object.entries(loaded.state.providerAccounts ?? {}) - .filter(([, a]) => a.provider === id) - .map(([name]) => name) - const orphaned = Object.entries(loaded.state.profiles ?? {}) - .filter(([, p]) => (p.accounts ?? []).some((a) => orphanedAccounts.includes(a))) - .map(([name]) => name) - - const providersMap = { ...loaded.state.providers } - delete providersMap[id] - return commit(store, { ...loaded.state, providers: providersMap }, { - orphanedAccounts, - orphanedProfiles: orphaned, - }) + return apply(store, deleteProvider(store.load().state, id)) } } if (resource === 'settings' && req.method === 'PUT') { const conflict = revisionConflict(store, req.body) if (conflict) return conflict - const loaded = store.load() const input = isObjectLike(req.body) ? req.body.settings : null - if (!isObjectLike(input)) return fail(400, 'settings must be an object') - const settings = { ...loaded.state.settings } - if (typeof input.quiet === 'boolean') settings.quiet = input.quiet - if (Number.isInteger(input.bindingWalkDepth)) { - settings.bindingWalkDepth = input.bindingWalkDepth as number - } - return commit(store, { ...loaded.state, settings }) + return apply(store, putSettings(store.load().state, input)) } if (resource === 'default' && req.method === 'PUT') { @@ -594,9 +401,7 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { if (conflict) return conflict const name = isObjectLike(req.body) ? str(req.body.name) : null if (!name) return fail(400, 'name is required') - const loaded = store.load() - if (!loaded.state.profiles?.[name]) return fail(404, `no profile named "${name}"`) - return commit(store, { ...loaded.state, defaultProfile: name }) + return apply(store, setDefaultProfile(store.load().state, name)) } if (resource === 'bindings') { diff --git a/src/core/operations.ts b/src/core/operations.ts new file mode 100644 index 0000000..09ece3f --- /dev/null +++ b/src/core/operations.ts @@ -0,0 +1,337 @@ +// Every mutation the configuration supports, as pure functions. +// +// State in, state out. No I/O, no clock, no HTTP — a caller loads, calls one of +// these, and decides what to do with the result. +// +// WHY THIS EXISTS SEPARATELY FROM THE WEB API. There used to be two editors, +// the Ink wizard and the browser, and the rules lived in whichever one you were +// looking at: the wizard minted account+setup+profile in its own `finish()`, +// while shape parsing and reference checking lived in adapters/web/api.ts. +// Two editors, two implementations of the same rules, and no way to tell they +// agreed. The wizard has since been deleted; a terminal editor is expected to +// return, and this is what makes that cheap. What lets two front ends share +// behaviour is not shared UI — it is that neither of them owns the logic. +// +// The shape follows `bindPath`/`unbindPath` in core/binding.ts, which already +// worked this way: a discriminated result carrying either the next state or the +// reason it was refused. + +import { accountsUsedBy, validateAccount } from './account.ts' +import { validateProfileName } from './migrate.ts' +import { toCustomProvider, validateCustomProvider } from './provider-def.ts' +import { TIERS } from './tiers.ts' +import type { + CustomProvider, + Profile, + ProviderAccount, + Settings, + Setup, + State, +} from '../ports/config-store.ts' + +/** + * Why an operation was refused. + * + * `invalid` is a bad request; `missing` is a name that is not there. Kept as a + * kind rather than a status code so core/ stays ignorant of HTTP — the adapter + * maps these to 400 and 404, and a terminal caller maps them to exit codes. + */ +export type OpFailure = { ok: false; kind: 'invalid' | 'missing'; reason: string; reasons?: string[] } + +/** + * `meta` carries facts the caller should surface but that are not errors — + * profiles left dangling by a delete, warnings about a legal-but-odd provider. + * Reported, never silently repaired: only the user knows what should replace a + * reference they removed. + */ +export type OpSuccess = { ok: true; state: State; meta?: Record } + +export type OpResult = OpSuccess | OpFailure + +const invalid = (reason: string, reasons?: string[]): OpFailure => + reasons ? { ok: false, kind: 'invalid', reason, reasons } : { ok: false, kind: 'invalid', reason } +const missing = (reason: string): OpFailure => ({ ok: false, kind: 'missing', reason }) + +function isObjectLike(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + +function str(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined +} + +// --------------------------------------------------------------- accounts -- + +/** + * A submitted provider account. + * + * Whitelisted rather than spread: an unknown key from a hostile or buggy client + * must not reach config.json, where a future swisscode would read it as + * meaningful. + * + * `apiKey` is accepted write-only and only when NON-EMPTY — an empty string + * from a form the user did not touch must not erase a stored key, which is the + * single most destructive mistake an editor could make. Clearing is an explicit + * `null`, so "I did not touch this" and "remove my credential" stay different + * requests. + */ +export function parseAccount( + input: unknown, + existing: ProviderAccount | undefined, +): ProviderAccount | string { + if (!isObjectLike(input)) return 'account must be an object' + const provider = str(input.provider) ?? existing?.provider + if (!provider) return 'provider is required' + + const account: ProviderAccount = { ...(existing ?? {}), provider } + if (typeof input.label === 'string') account.label = input.label + if (typeof input.configDir === 'string') { + if (input.configDir) account.configDir = input.configDir + else delete account.configDir + } + if (typeof input.baseUrl === 'string') account.baseUrl = input.baseUrl + if (typeof input.apiKey === 'string' && input.apiKey.length > 0) account.apiKey = input.apiKey + if (input.apiKey === null) delete account.apiKey + if (typeof input.apiKeyFromEnv === 'string') { + if (input.apiKeyFromEnv) account.apiKeyFromEnv = input.apiKeyFromEnv + else delete account.apiKeyFromEnv + } + // The two modes are MUTUALLY EXCLUSIVE, and the conflict is refused rather + // than resolved by precedence. The rule lives in core/account.ts so the launch + // path and the doctor reach the same verdict an editor does — they used to + // disagree, and the doctor called a conflicting account healthy. + const bad = validateAccount(account) + if (bad) return bad + return account +} + +export function putAccount(state: State, name: string, input: unknown): OpResult { + const parsed = parseAccount(input, state.providerAccounts?.[name]) + if (typeof parsed === 'string') return invalid(parsed) + return { + ok: true, + state: { ...state, providerAccounts: { ...state.providerAccounts, [name]: parsed } }, + } +} + +export function deleteAccount(state: State, name: string): OpResult { + if (!state.providerAccounts?.[name]) return missing(`no account named "${name}"`) + const accounts = { ...state.providerAccounts } + delete accounts[name] + return { + ok: true, + state: { ...state, providerAccounts: accounts }, + meta: { affectedProfiles: accountsUsedBy(state.profiles, name) }, + } +} + +// ----------------------------------------------------------------- setups -- + +/** A submitted setup. Holds no credential. */ +export function parseSetup(input: unknown, existing: Setup | undefined): Setup | string { + if (!isObjectLike(input)) return 'setup must be an object' + const setup: Setup = { ...(existing ?? {}) } + + if (typeof input.label === 'string') setup.label = input.label + if (typeof input.agent === 'string') setup.agent = input.agent + if (typeof input.skipPermissions === 'boolean') setup.skipPermissions = input.skipPermissions + + if (isObjectLike(input.models)) { + const models: Record = {} + for (const tier of TIERS) { + const v = input.models[tier] + if (typeof v === 'string') models[tier] = v + } + setup.models = models + } + + if (isObjectLike(input.compat)) { + const compat: Record = {} + for (const [k, v] of Object.entries(input.compat)) { + if (typeof v === 'boolean') compat[k] = v + } + setup.compat = compat as NonNullable + } + + if (isObjectLike(input.env)) { + const env: Record = {} + for (const [k, v] of Object.entries(input.env)) { + if (typeof v === 'string') env[k] = v + } + setup.env = env + } + + // Measured windows only. A non-integer or non-positive entry is dropped + // rather than stored: this feeds CLAUDE_CODE_AUTO_COMPACT_WINDOW, and a window + // set too large overflows the conversation instead of compacting it. + if (isObjectLike(input.contextWindows)) { + const windows: Record = {} + for (const [model, v] of Object.entries(input.contextWindows)) { + if (typeof v === 'number' && Number.isInteger(v) && v > 0) windows[model] = v + } + setup.contextWindows = windows + } + + return setup +} + +export function putSetup(state: State, name: string, input: unknown): OpResult { + const parsed = parseSetup(input, state.setups?.[name]) + if (typeof parsed === 'string') return invalid(parsed) + return { ok: true, state: { ...state, setups: { ...state.setups, [name]: parsed } } } +} + +export function deleteSetup(state: State, name: string): OpResult { + if (!state.setups?.[name]) return missing(`no setup named "${name}"`) + const affected = Object.entries(state.profiles ?? {}) + .filter(([, p]) => p.setup === name) + .map(([n]) => n) + const setups = { ...state.setups } + delete setups[name] + return { ok: true, state: { ...state, setups }, meta: { affectedProfiles: affected } } +} + +// --------------------------------------------------------------- profiles -- + +/** + * The pairing. References only — no credential, no agent settings. + * + * Shape only: whether the things it names EXIST is checked by `putProfile`, + * which holds the state. Validating shape and validating existence are + * different failures and deserve different messages. + */ +export function parseProfile(input: unknown, existing: Profile | undefined): Profile | string { + if (!isObjectLike(input)) return 'profile must be an object' + const setup = str(input.setup) ?? existing?.setup + if (!setup) return 'setup is required' + + const accounts = Array.isArray(input.accounts) + ? input.accounts.filter((a): a is string => typeof a === 'string' && a.length > 0) + : (existing?.accounts ?? []) + if (accounts.length === 0) return 'a profile needs at least one provider account' + + const profile: Profile = { ...(existing ?? {}), setup, accounts } + if (typeof input.label === 'string') profile.label = input.label + if (input.strategy === 'single' || input.strategy === 'round-robin' || input.strategy === 'usage') { + profile.strategy = input.strategy + } + return profile +} + +export function putProfile(state: State, name: string, input: unknown): OpResult { + // Name rules apply at CREATION only, so a hand-edited config keeps working. + if (!state.profiles?.[name]) { + const verdict = validateProfileName(name) + if (!verdict.ok) return invalid(verdict.reason) + } + + const parsed = parseProfile(input, state.profiles?.[name]) + if (typeof parsed === 'string') return invalid(parsed) + + if (!state.setups?.[parsed.setup]) return invalid(`no setup named "${parsed.setup}"`) + const absent = parsed.accounts.filter((a) => !state.providerAccounts?.[a]) + if (absent.length > 0) return invalid(`no provider account named "${absent[0]}"`) + + const next: State = { ...state, profiles: { ...state.profiles, [name]: parsed } } + // The first profile created becomes the default: a lone profile that is not + // the default is a state a launch would then refuse to start from. + if (!next.defaultProfile) next.defaultProfile = name + return { ok: true, state: next } +} + +export function deleteProfile(state: State, name: string): OpResult { + if (!state.profiles?.[name]) return missing(`no profile named "${name}"`) + const profiles = { ...state.profiles } + delete profiles[name] + + // Bindings to a deleted profile are pruned, exactly as `config rm` does. + // Leaving them would make a directory silently fall back to the default. + // + // KNOWN INCOMPLETE, and preserved verbatim rather than fixed here: a + // BindingValue is `string | {profile, overrides}`, and this compares the whole + // value, so only the string form is pruned. An object-form binding survives + // and resolves to a profile that no longer exists. Filed separately — an + // extraction that quietly changed behaviour would be impossible to review. + const bindings = Object.fromEntries( + Object.entries(state.bindings ?? {}).filter(([, p]) => p !== name), + ) + const next: State = { ...state, profiles, bindings } + // `string | null`, not optional — null is the "no default" state the launcher + // already knows how to report, so clear rather than delete. + if (next.defaultProfile === name) next.defaultProfile = null + return { ok: true, state: next } +} + +export function setDefaultProfile(state: State, name: string): OpResult { + if (!state.profiles?.[name]) return missing(`no profile named "${name}"`) + return { ok: true, state: { ...state, defaultProfile: name } } +} + +// -------------------------------------------------------------- providers -- + +/** + * `reservedIds`, `knownCompatFlags` and `credentialEnvs` are PARAMETERS because + * they are agent-specific: the compat flags and credential variable names are + * Claude Code's, and core/ may not name them. The caller supplies them from the + * adapter that owns them. + */ +export type ProviderRules = { + reservedIds: readonly string[] + knownCompatFlags: readonly string[] + credentialEnvs: readonly string[] +} + +export function putProvider( + state: State, + id: string, + input: unknown, + rules: ProviderRules, +): OpResult { + const candidate = isObjectLike(input) ? { ...input, id } : input + const verdict = validateCustomProvider(candidate, { + reservedIds: rules.reservedIds, + knownCompatFlags: rules.knownCompatFlags, + credentialEnvs: rules.credentialEnvs, + }) + if (!verdict.ok) return invalid(verdict.errors[0] ?? 'invalid provider', verdict.errors) + + const providers: Record = { ...(state.providers ?? {}) } + providers[id] = toCustomProvider(candidate as Record) + // Warnings ride along on success: they describe a config that is legal and + // probably wrong, which is the user's call to make. + return { ok: true, state: { ...state, providers }, meta: { warnings: verdict.warnings } } +} + +export function deleteProvider(state: State, id: string): OpResult { + if (!state.providers?.[id]) return missing(`no custom provider named "${id}"`) + + // Accounts point at providers, so deleting one orphans accounts, and those in + // turn orphan whichever profiles use them. Both are reported, never silently + // repaired: only the user knows where a profile should point next. + const orphanedAccounts = Object.entries(state.providerAccounts ?? {}) + .filter(([, a]) => a.provider === id) + .map(([name]) => name) + const orphanedProfiles = Object.entries(state.profiles ?? {}) + .filter(([, p]) => (p.accounts ?? []).some((a) => orphanedAccounts.includes(a))) + .map(([name]) => name) + + const providers = { ...state.providers } + delete providers[id] + return { + ok: true, + state: { ...state, providers }, + meta: { orphanedAccounts, orphanedProfiles }, + } +} + +// --------------------------------------------------------------- settings -- + +export function putSettings(state: State, input: unknown): OpResult { + if (!isObjectLike(input)) return invalid('settings must be an object') + const settings: Settings = { ...state.settings } + if (typeof input.quiet === 'boolean') settings.quiet = input.quiet + if (Number.isInteger(input.bindingWalkDepth)) { + settings.bindingWalkDepth = input.bindingWalkDepth as number + } + return { ok: true, state: { ...state, settings } } +} diff --git a/test/adapters/web.test.ts b/test/adapters/web.test.ts index 5521886..1b55137 100644 --- a/test/adapters/web.test.ts +++ b/test/adapters/web.test.ts @@ -20,7 +20,7 @@ import { 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 { handleApi, parseAccount, parseSetup, 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' import { registry as providers } from '../../src/adapters/providers/registry.ts' @@ -587,7 +587,7 @@ test('deleting a provider reports orphaned profiles rather than silently repairi test('contextWindows accepts measured integers and drops anything else', () => { // It feeds CLAUDE_CODE_AUTO_COMPACT_WINDOW; a bad value there overflows the // conversation instead of compacting it. - const parsed = parseAgentProfile( + const parsed = parseSetup( { contextWindows: { good: 200000, zero: 0, neg: -1, str: '100', frac: 1.5 } }, undefined, ) diff --git a/test/core/operations.test.ts b/test/core/operations.test.ts new file mode 100644 index 0000000..dac03a3 --- /dev/null +++ b/test/core/operations.test.ts @@ -0,0 +1,173 @@ +// The proof that configuration editing no longer needs a front end. +// +// This file imports nothing from adapters/ or web/. If a terminal editor comes +// back, this is the surface it will drive — and the fact that a whole lifecycle +// is expressible here is what makes that a thin adapter rather than a rewrite. +import test from 'node:test' +import assert from 'node:assert/strict' +import { + deleteAccount, + deleteProfile, + deleteProvider, + deleteSetup, + putAccount, + putProfile, + putProvider, + putSettings, + putSetup, + setDefaultProfile, +} from '../../src/core/operations.ts' +import { emptyState } from '../../src/core/migrate.ts' +import type { State } from '../../src/ports/config-store.ts' + +/** Unwrap a success, failing loudly with the refusal reason if it is not one. */ +function ok(result: ReturnType): State { + assert.ok(result.ok, result.ok ? '' : `refused: ${result.reason}`) + return result.state +} + +test('a whole profile lifecycle runs through core operations alone', () => { + let state = emptyState() + + state = ok(putAccount(state, 'work', { provider: 'zai', apiKey: 'zai-secret' })) + assert.equal(state.providerAccounts.work?.provider, 'zai') + + state = ok(putSetup(state, 'main', { models: { opus: 'glm-5.2' }, skipPermissions: true })) + assert.equal(state.setups.main?.models?.opus, 'glm-5.2') + + state = ok(putProfile(state, 'day', { setup: 'main', accounts: ['work'] })) + assert.deepEqual(state.profiles.day, { setup: 'main', accounts: ['work'] }) + // The first profile becomes the default: a lone profile that is not the + // default is a state a launch would refuse to start from. + assert.equal(state.defaultProfile, 'day') + + state = ok(putProfile(state, 'day', { setup: 'main', accounts: ['work'], label: 'Daily' })) + assert.equal(state.profiles.day?.label, 'Daily') + + state = ok(putProfile(state, 'night', { setup: 'main', accounts: ['work'] })) + state = ok(setDefaultProfile(state, 'night')) + assert.equal(state.defaultProfile, 'night') + + state = ok(deleteProfile(state, 'night')) + assert.equal(state.defaultProfile, null, 'deleting the default clears it rather than guessing') + assert.ok(state.profiles.day, 'the other profile survives') +}) + +test('a profile cannot reference a setup or account that does not exist', () => { + const state = ok(putAccount(emptyState(), 'work', { provider: 'zai', apiKey: 'k' })) + + const noSetup = putProfile(state, 'p', { setup: 'nope', accounts: ['work'] }) + assert.equal(noSetup.ok, false) + assert.match(noSetup.ok === false ? noSetup.reason : '', /no setup named "nope"/) + + const withSetup = ok(putSetup(state, 'main', {})) + const noAccount = putProfile(withSetup, 'p', { setup: 'main', accounts: ['ghost'] }) + assert.equal(noAccount.ok, false) + assert.match(noAccount.ok === false ? noAccount.reason : '', /no provider account named "ghost"/) +}) + +test('a refusal distinguishes a bad request from a name that is not there', () => { + const state = emptyState() + const gone = deleteProfile(state, 'nope') + assert.equal(gone.ok, false) + assert.equal(gone.ok === false ? gone.kind : '', 'missing', 'maps to 404') + + const bad = putAccount(state, 'x', { notAProvider: true }) + assert.equal(bad.ok, false) + assert.equal(bad.ok === false ? bad.kind : '', 'invalid', 'maps to 400') +}) + +test('an empty apiKey does not erase a stored one, but an explicit null does', () => { + // The single most destructive mistake an editor could make: a form the user + // never touched submitting '' and silently wiping a credential. + let state = ok(putAccount(emptyState(), 'a', { provider: 'zai', apiKey: 'secret' })) + + state = ok(putAccount(state, 'a', { provider: 'zai', apiKey: '' })) + assert.equal(state.providerAccounts.a?.apiKey, 'secret', 'blank left the key alone') + + state = ok(putAccount(state, 'a', { provider: 'zai', apiKey: null })) + assert.equal(state.providerAccounts.a?.apiKey, undefined, 'explicit null cleared it') +}) + +test('a key and a session directory together are refused, not resolved by precedence', () => { + const both = putAccount(emptyState(), 'a', { + provider: 'anthropic', apiKey: 'k', configDir: '/tmp/session', + }) + assert.equal(both.ok, false) +}) + +test('deleting an account reports the profiles left dangling rather than repairing them', () => { + let state = ok(putAccount(emptyState(), 'work', { provider: 'zai', apiKey: 'k' })) + state = ok(putSetup(state, 'main', {})) + state = ok(putProfile(state, 'day', { setup: 'main', accounts: ['work'] })) + + const result = deleteAccount(state, 'work') + assert.ok(result.ok) + assert.deepEqual(result.meta?.affectedProfiles, ['day']) + // Only the user knows which account should pay instead. + assert.ok(result.state.profiles.day, 'the profile is left in place, not deleted') +}) + +test('deleting a setup reports the profiles that used it', () => { + let state = ok(putAccount(emptyState(), 'work', { provider: 'zai', apiKey: 'k' })) + state = ok(putSetup(state, 'main', {})) + state = ok(putProfile(state, 'day', { setup: 'main', accounts: ['work'] })) + + const result = deleteSetup(state, 'main') + assert.ok(result.ok) + assert.deepEqual(result.meta?.affectedProfiles, ['day']) +}) + +test('a custom provider is validated against rules the caller supplies', () => { + // The rules are parameters because they are Claude Code's: core/ may not name + // a CLAUDE_CODE_ or ANTHROPIC_ variable, so the adapter that owns them passes + // them in. + const rules = { + reservedIds: ['zai', 'anthropic'], + knownCompatFlags: ['forceIdleTimeoutOff'], + credentialEnvs: ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'], + } + const shadow = putProvider(emptyState(), 'zai', { + label: 'Mine', baseUrl: 'https://example.com', credentialEnv: 'ANTHROPIC_AUTH_TOKEN', + }, rules) + assert.equal(shadow.ok, false, 'a custom provider must not shadow a shipped preset') + + const fine = putProvider(emptyState(), 'mine', { + label: 'Mine', baseUrl: 'https://example.com', credentialEnv: 'ANTHROPIC_AUTH_TOKEN', + }, rules) + assert.ok(fine.ok) + assert.ok(fine.state.providers?.mine) +}) + +test('deleting a provider reports the accounts and profiles it orphans', () => { + const rules = { reservedIds: [], knownCompatFlags: [], credentialEnvs: ['ANTHROPIC_AUTH_TOKEN'] } + let state = ok(putProvider(emptyState(), 'mine', { + label: 'Mine', baseUrl: 'https://example.com', credentialEnv: 'ANTHROPIC_AUTH_TOKEN', + }, rules)) + state = ok(putAccount(state, 'acct', { provider: 'mine', apiKey: 'k' })) + state = ok(putSetup(state, 'main', {})) + state = ok(putProfile(state, 'day', { setup: 'main', accounts: ['acct'] })) + + const result = deleteProvider(state, 'mine') + assert.ok(result.ok) + assert.deepEqual(result.meta?.orphanedAccounts, ['acct']) + assert.deepEqual(result.meta?.orphanedProfiles, ['day']) +}) + +test('settings take only the keys they define', () => { + const result = putSettings(emptyState(), { quiet: true, bindingWalkDepth: 5, bogus: 'x' }) + assert.ok(result.ok) + assert.equal(result.state.settings.quiet, true) + assert.equal(result.state.settings.bindingWalkDepth, 5) + assert.ok(!('bogus' in result.state.settings)) +}) + +test('a context window that was not measured is dropped rather than stored', () => { + // Feeds CLAUDE_CODE_AUTO_COMPACT_WINDOW: a window set too large overflows the + // conversation instead of compacting it. + const result = putSetup(emptyState(), 'main', { + contextWindows: { good: 200000, zero: 0, negative: -1, fractional: 1.5, text: 'big' }, + }) + assert.ok(result.ok) + assert.deepEqual(result.state.setups.main?.contextWindows, { good: 200000 }) +})