diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 212676f..91836e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,8 +67,16 @@ jobs: run: npm ci # `npm test` is the whole gate on purpose: tsc --noEmit (which is what - # runs test/ports.conformance.ts), then the two-stage build, then - # node --test over the suite. Splitting it into separate steps would let - # the three UI suites run against a stale dist/. + # runs test/ports.conformance.ts), then the build, then node --test over + # the suite. Splitting it into separate steps would let the three UI + # suites run against a stale dist/. - name: Test run: npm test + + # What users actually download. Shipping the web UI means everyone gets a + # React bundle they may never open; the budget keeps that a decision that + # is re-made on every PR rather than one that drifts. One cell is enough — + # the tarball is identical across the matrix. + - name: Size budget + if: matrix.os == 'ubuntu-latest' && matrix.node == 24 + run: npm run size diff --git a/.gitignore b/.gitignore index 8fdcab5..d063c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ dist/ *.log .DS_Store .playwright-mcp/ +web/styled-system/ + +# Playwright MCP writes screenshots here during manual verification +.playwright-mcp/ +profiles.png diff --git a/AGENTS.md b/AGENTS.md index 9439717..d8e1e79 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,8 +49,8 @@ runtime assertions and is checked by `tsc`. `npm run typecheck` runs it. |---|---| | `src/core/**` | Pure domain logic. No I/O, no top-level `let`/`var`, imports nothing outside `core/` and `node:` builtins at runtime. | | `src/ports/**` | Interfaces only. Every file must erase to exactly `export {}`. | -| `src/adapters/**` | Implementations: providers, agent CLIs, catalogs, fs, process, net, clock, doctor probe, Ink UI. | -| `src/composition/**` | Four composition roots: `launch-root` (hot path), `config-root`, `doctor-root`, `ui-root`. | +| `src/adapters/**` | Implementations: providers, agent CLIs, catalogs, fs, process, net, clock, doctor probe, session logins, usage, web API, Ink UI. | +| `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. | @@ -63,9 +63,13 @@ not a patch. 1. **The launch path** — the static import closure rooted at `src/cli.ts` — never reaches React, Ink, any `.tsx`, `node_modules`, `adapters/ui`, - `adapters/catalog`, `config-root`, or the doctor. It never calls `fetch` and - never imports `node:http`/`https`/`net`/`tls`/`dgram`. It stays under 40 - modules. + `adapters/catalog`, `adapters/usage`, `adapters/claude-session`, + `config-root`, or the doctor. It never calls `fetch` and never imports + `node:http`/`https`/`net`/`tls`/`dgram`. It stays at or under 42 modules. + Session inspection is excluded for a sharper reason than bundle size: + `.claude.json` runs to ~200 kB on a well-used account and reading a + credential can raise a keychain prompt, so a launch lowers a session + directory to an env var **without opening it**. **The only sanctioned escape hatch is a dynamic `import()` from `src/cli.ts`.** 2. **`core/` is pure**: no I/O, no clock, no `process.env`, no top-level mutable state. `dist/ui.js` inlines its own copy of `core/`, so module state would @@ -205,6 +209,17 @@ neutral `LaunchIntent` is missing something — propose that instead. - CI (`.github/workflows/ci.yml`) runs `npm test` on every push to `main` and every PR, across Node 22 + 24 on ubuntu and macOS. `publish.yml` runs it again on a `v*` tag before publishing via npm trusted publishing (OIDC, no token). +- A Claude subscription is a **login**, not a key: an account carries either + `apiKey`/`apiKeyFromEnv` or `configDir`, never both, and the validator refuses + the combination rather than picking one. Naming the default `~/.claude` + **unsets** `CLAUDE_CONFIG_DIR` rather than writing the path — Claude Code + branches on whether the variable is set, not on its value, so writing it is a + different and empty login. `isDefaultConfigDir` owns that question; do not + re-derive it. +- Exactly one module writes a credential (`adapters/claude-session/swap.ts`) and + it writes a `0600` file, never the keychain: `security add-generic-password` + either puts the secret in argv or truncates stdin at 128 bytes. Read + `docs/SECURITY.md` before touching it. - A provider needing no real credential sets `credentialOptional: true` plus `defaultCredential` — the placeholder ships in source, is explicitly **not** a secret, and is excluded from the doctor's redaction set for that reason. diff --git a/README.md b/README.md index 7e085d9..9edb1d8 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,19 @@ invocation, which you'll feel on a tool you launch dozens of times a day: npm install -g swisscode ``` +**Bun works too**, both as a runner and as the runtime: + +```sh +bunx swisscode +bun install -g swisscode +``` + +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. + Every argument that isn't listed below is forwarded to `claude` untouched: ```sh @@ -304,6 +317,135 @@ stderr when it is on — it is not shipped on by default, because a preset that quietly removed a feature to dodge an upstream bug would be the kind of silent behaviour this tool exists to avoid. +## Claude subscription accounts + +Everything above assumes an API key. A Claude Pro or Max subscription is not a +key — it is a **login**, stored by Claude Code in your keychain and pointed at by +`CLAUDE_CONFIG_DIR`. swisscode can hold several of them and pick between them. + +An account is one or the other, never both; a config naming a key *and* a +session directory is refused rather than resolved by precedence. + +```sh +swisscode config accounts login work # make a session dir, run /login inside +swisscode config accounts login personal --dir ~/.claude # adopt the login you already have +swisscode config accounts # who each account is, no keychain prompt +``` + +`login` creates `~/.config/swisscode/accounts/` at `0700`, then runs the +agent there so you can complete `/login` once. After that the account is a +normal thing profiles can reference. + +> **Naming `~/.claude` means *unsetting* `CLAUDE_CONFIG_DIR`, not setting it to +> that path.** Claude Code chooses its keychain item on whether the variable is +> *set*, not on its value — so `CLAUDE_CONFIG_DIR="$HOME/.claude"` is a +> different, empty login from the one you use every day. swisscode handles this +> for you; it is documented because the failure is silent. Identity comes from a +> shared `~/.claude.json`, so a tool that gets this wrong reports the *correct* +> email for a session that cannot authenticate. + +### How much is left + +```sh +swisscode config accounts usage +``` + +Reads each subscription's real 5-hour and 7-day windows from Anthropic — the +same figures `/usage` shows you, for every account at once, without switching +into each one to look. It caches them, and a profile with +`"strategy": "usage"` then launches on whichever account has the most left: + +```json +{ "agentProfile": "default", "accounts": ["personal", "work"], "strategy": "usage" } +``` + +Ranking uses the **tighter of the two windows, never their average**. An account +at 5% of its 5-hour window and 95% of its weekly one has almost nothing left, +and averaging to 50% would send work straight at it. An account that cannot be +measured is treated as *unknown* and passed over, never as *full*. + +`swisscode config doctor` refreshes the same cache, and the Accounts screen in +the web UI has a button for it. Nothing measures on the launch path — a launch +resolves a directory and hands it to the agent without opening it, which is why +an ordinary `swisscode` never raises a keychain prompt. + +### Switching a running session + +```sh +swisscode config accounts swap --into work personal +``` + +`/login` writes **one global slot**, and every running Claude Code re-reads it +within about 30 seconds — which is why switching accounts in one terminal +switches all of them, and why an artifact created just after a switch can land +on the wrong account. `swap` writes **one directory's** slot instead. Sessions +using any other directory are unaffected. + +Three things to know: + +- A session already running in that directory picks it up **within ~30s**, not + instantly. swisscode cannot reach into a running process; the agent re-reads + its credential on its own timer. +- Anything that session creates *after* that point belongs to the new account. +- Overwriting a *different* account's login needs `--yes`. + +### Where this sits with Anthropic + +Stated plainly, because you are entitled to decide for yourself: + +- **Rotating between subscriptions you pay for is fine. Sharing or reselling one + is not.** swisscode is structurally incapable of the second: it never sits in + the request path, holds no server, and proxies nothing. It sets environment + variables and calls `execve`. +- **`CLAUDE_CONFIG_DIR` and the usage endpoint are undocumented.** Both work, + and per-directory isolation is the mechanism Claude Code itself implements, + but neither is a contract. If Anthropic changes them, expect the measurement + to degrade to "unknown" rather than to lie — every parse here fails to null. +- **Per-launch selection is the well-trodden part; `swap` is not.** Choosing an + account before starting the agent is ordinary. Overwriting a live session's + credential is doing programmatically what `/login` already does by hand — same + official client, same accounts you pay for — but it is not something Anthropic + has explicitly blessed. +- **`claude -p` bills a credit pool**, not the subscription window, so the + 5-hour figure describes interactive sessions. No behaviour change; just don't + read the number as covering headless runs. + +## Web UI + +The CLI is not the only way in. `swisscode config web` serves a local +configuration UI — profiles, providers, agents, model tiers, compatibility +flags and settings, everything the CLI can express: + +```sh +swisscode config web # prints a URL; Ctrl-C to stop +swisscode config web --port 7391 +``` + +It is a **singleton by construction**: the port bind is the mutex, so a second +instance tells you one is already running rather than racing it. It binds +`127.0.0.1` only, never `0.0.0.0`. + +**Your keys never reach the browser.** The page is told whether a key is *set*, +never what it is, and editing is write-only: leaving the field blank keeps the +stored key, and clearing one is a separate, explicit action. That is the same +rule `config doctor` follows. + +Because a browser will talk to `localhost` on behalf of any page you have open, +the server checks the `Host` header against an exact allowlist (this is the +DNS-rebinding defence — the connection genuinely comes from loopback, so nothing +at the socket level can tell an attack apart), validates `Origin`, and requires a +per-run token in a custom header, which forces a CORS preflight it never answers. + +Edits are checked against the revision they were based on, so a browser tab left +open while you run `swisscode config work` in a terminal gets a conflict rather +than silently overwriting it. + +You can also define **your own providers** here — a gateway or local server +swisscode ships no preset for. They are validated on save with the same rules the +shipped presets are tested against: no `/v1` suffix, no hand-typed `[1m]`, real +compatibility flags. Shipped presets stay read-only, and a custom provider cannot +shadow one. + ## Agents The **provider** is which model backend you talk to; the **agent** is which @@ -399,11 +541,14 @@ holds an API key in plaintext. ```json { - "version": 2, - "profiles": { - "openrouter": { - "provider": "openrouter", - "apiKey": "sk-or-…", + "version": 3, + "providerAccounts": { + "openrouter": { "provider": "openrouter", "apiKey": "sk-or-…" }, + "personal": { "provider": "anthropic", "configDir": "/Users/me/.claude" } + }, + "agentProfiles": { + "default": { + "agent": "claude-code", "models": { "opus": "openrouter/fusion", "sonnet": "…", @@ -416,21 +561,32 @@ holds an API key in plaintext. "env": { "ANY_EXTRA_VAR": "value" } } }, - "defaultProfile": "openrouter", + "profiles": { + "work": { "agentProfile": "default", "accounts": ["openrouter"], "strategy": "single" } + }, + "defaultProfile": "work", "bindings": { "/Users/me/clients/acme": "acme" }, "settings": { "quiet": false, "bindingWalkDepth": 40 } } ``` +Three separate things, because they vary independently. A **provider account** +is who pays — a key, or a subscription login. An **agent profile** is what runs +— which CLI, which model per tier, which flags. A **profile** pairs them and +says how to choose when it names more than one account (`single`, `round-robin`, +or `usage`). One agent profile can be shared by several profiles that bill +different accounts, which is the arrangement the older flat shape could not +express. + `bindings` records absolute paths, which means client names and project layout. That's new non-credential information in this file — worth remembering before pasting it into a bug report. -A config written by swisscode 0.1.0 — a single flat object with a top-level -`provider` — is migrated to this shape automatically the first time a newer -version reads it. The original is kept beside it as `config.v1.bak.json`, and a -migration that cannot be written to disk is used in memory rather than blocking -the launch. +Older configs migrate automatically the first time a newer version reads them — +v1's single flat object with a top-level `provider`, and v2's profiles that +carried credential and agent settings together. The original is kept beside it +as `config.v1.bak.json`, and a migration that cannot be written to disk is used +in memory rather than blocking the launch. Claude Code has **four** model tiers. A tier you leave out inherits the provider's default; a tier set to the empty string is explicitly unset. Setting diff --git a/build.js b/build.js index 1517919..996ac5c 100644 --- a/build.js +++ b/build.js @@ -9,9 +9,12 @@ // allowed to reach. import { execFileSync } from 'node:child_process' import { existsSync, rmSync } from 'node:fs' +import { join } from 'node:path' import * as esbuild from 'esbuild' const TSC = 'node_modules/typescript/bin/tsc' +const PANDA = '../node_modules/@pandacss/dev/bin.js' +const VITE = '../node_modules/vite/bin/vite.js' // Stale output is worse than no output: a deleted module would otherwise linger // in dist/ and keep resolving. @@ -41,7 +44,41 @@ await esbuild.build({ format: 'esm', target: 'node22', jsx: 'automatic', + // Minified, unlike stage 1's output. This one is already a single opaque + // blob that nothing on the launch path may reach and no human reads module by + // module, so the auditability argument that keeps `dist/` unbundled does not + // apply to it — and it halves, 74.5 kB to 38.2 kB. + minify: true, external: ['ink', 'react', 'react/jsx-runtime', 'ink-select-input', 'ink-text-input'], }) -console.log(`built dist/ (tsc) and dist/ui.js (esbuild, from ${uiRoot})`) +// Stage 3. The web UI, built by Vite into dist/web. +// +// Its whole toolchain — vite, react-dom, Panda — is a devDependency and none of +// it ships: `files` is bin/dist/README, so users receive the emitted assets and +// nothing that produced them. The runtime dependency count is unchanged. +// +// Skipped when the toolchain is absent so `npm ci --omit=dev` and a published +// tarball rebuild both still work; the server falls back to a page that says so +// rather than 404ing. +const webRoot = 'web' +let webBuilt = false +if (existsSync(join(webRoot, 'node_modules', '.bin', 'vite')) || existsSync('node_modules/vite')) { + // Panda is CODEGEN, and it has to run before Vite: the generated + // styled-system/ is what the app imports, and its PostCSS plugin is what + // fills the @layer declarations. Skipping it produces a build that succeeds + // and a page that renders completely unstyled. + execFileSync(process.execPath, [PANDA, 'codegen', '--config', 'panda.config.ts'], { + cwd: webRoot, + stdio: 'inherit', + }) + execFileSync(process.execPath, [VITE, 'build'], { cwd: webRoot, stdio: 'inherit' }) + webBuilt = true +} else { + console.log('skipped dist/web (frontend toolchain not installed)') +} + +console.log( + `built dist/ (tsc), dist/ui.js (esbuild, from ${uiRoot})` + + (webBuilt ? ' and dist/web (vite)' : ''), +) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 369a7de..688b09e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,10 +114,25 @@ that an adapter meets it. Here the check is real in three places: caught. Gated on the provider id rather than generalised into an "introspect a provider" port method: one example is not enough to know that abstraction's shape, and the second caller is what should define it. -- **`ui/*`** — the Ink wizard. The only `.tsx` in the project and the only code - that imports React. - -### `composition/` — the four roots +- **`claude-session/*`** — Claude subscription logins, which belong to the agent + rather than to us. Split three ways on purpose: `identity` reads *who* an + account is (a file read, no credential, no prompt, so listing is free), + `credentials` reads the token *only* to measure usage and never refreshes, and + `swap` is the **only writer in the project**. Keeping the writer in its own + module is what makes the read path's "cannot damage a login" claim structural + rather than careful. +- **`usage/*`** — how much of a subscription or a balance is left. + `anthropic-subscription` reads the 5-hour and 7-day windows, `openrouter` + reads a credit balance, and `measure` is the loop both `config accounts usage` + and the doctor share. Configuration-time only, by architecture test. +- **`web/*`** — the local HTTP API. `api.ts` is a pure + `(request, deps) -> response` function, which is why every refusal in it is + testable without a socket; `api-async.ts` owns the three routes that do I/O + and hands back `null` for anything else, so the pure handler keeps the rest. +- **`ui/*`** — the Ink wizard. The only `.tsx` in `src/` and the only code there + that imports React. (The browser SPA lives in `web/`, outside `src/`.) + +### `composition/` — the five roots | Root | Reached by | Notes | |---|---|---| @@ -125,6 +140,7 @@ that an adapter meets it. Here the check is real in three places: | `config-root` | dynamic import | Every `swisscode config ` subcommand. | | `doctor-root` | dynamic import | `config doctor`. Never runs automatically — the probes are real, billable inference requests. | | `ui-root` | dynamic import of `dist/ui.js` | The Ink wizard, bundled separately. | +| `web-root` | dynamic import | `config web`. The port bind is the singleton mutex — no lockfile, no stale-PID reasoning. | Each takes the same `LaunchDeps` bag (`store`, `registry`, `agents`, `proc`), declared once in `launch-root` and imported by the others with `import type`, so @@ -199,11 +215,19 @@ measuring anything at runtime, so it cannot flake. - never reaches React, Ink, or any `.tsx` file - never reaches `node_modules` — it resolves only inside `src/` and `node:` builtins -- never reaches `adapters/ui`, `adapters/catalog`, `config-root`, or the doctor +- never reaches `adapters/ui`, `adapters/catalog`, `adapters/usage`, + `adapters/claude-session`, `config-root`, or the doctor. The last two matter + for a sharper reason than bundle size: `.claude.json` is a ~200 kB file on a + well-used account, and reading a credential can raise a keychain prompt. A + launch resolves a session directory and lowers it to an environment variable + **without opening it** — the agent reads its own credential, milliseconds + later, as it always did - never calls `fetch`, and never imports `node:http`/`https`/`net`/`tls`/`dgram`. A launcher must not make network calls. The `execve`/`spawn` in the process adapter is the one deliberate subprocess exception -- stays under 40 modules, so it remains auditable in one sitting +- stays at or under 42 modules, so it remains auditable in one sitting. The + ceiling is a real constraint, not a decoration — it has already turned a + module away, and every raise has to name the capability that earned it The **only** sanctioned escape hatch is a dynamic `import()` from `src/cli.ts`. That is not a lint rule to be worked around — it is the property the project diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5b38822..baba1a4 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -119,6 +119,40 @@ file with mode `0600` and then moved into place, with the mode re-asserted by aside rather than overwritten, and a v1 file is backed up before migration. (`adapters/store/fs-config-store.ts`) +**Exactly one module reads a credential swisscode did not write, and exactly one +writes one.** Session mode handles Claude subscription logins, which belong to +the agent rather than to us, so the surface is deliberately tiny: + +- `adapters/claude-session/identity.ts` reads *who* an account is from + `.claude.json`. No credential, no keychain, no prompt — which is why listing + accounts is free. +- `adapters/claude-session/credentials.ts` reads the token, only so usage can be + measured. It never refreshes: refreshing needs Anthropic's own OAuth client + id, and impersonating their client is the line this design stays behind. An + expired token is *reported*; the agent refreshes it itself on its next run. +- `adapters/claude-session/swap.ts` is the only writer. It is a separate module + precisely so the read path's "never writes" promise holds by construction. + +**A moved credential never touches argv, and never gets truncated.** `ps` shows +argv to every user on the machine, so a secret must not go there. +`/usr/bin/security add-generic-password` offers no safe alternative: `-w ` +is argv, and `-w` reading from stdin **silently truncates at 128 bytes** — +measured, 500 in and 128 stored with exit 0 and no warning, against a real +credential of ~3.9 kB. So the credential is written as a `0600` file in a `0700` +directory instead, and any competing keychain item for that directory is dropped +afterwards so exactly one stored credential remains. The blob is moved as opaque +bytes, never parsed into a shape that could be logged or narrowed — dropping +`refreshToken` would hand the target a login that dies at the next refresh. +(`test/adapters/claude-session-swap.test.ts`: *THE SECRET NEVER REACHES ARGV*, +*THE BLOB IS NEVER TRUNCATED*) + +**Measuring usage is never automatic.** A keychain read can raise an unlock +dialog, so it happens only when asked: `config accounts usage`, `config doctor`, +or the button in the web UI. The web route is a `POST` for the same reason — a +`GET` is something a browser may prefetch, retry or replay on its own +initiative, and nothing that can pop a system dialog should be reachable that +way. The launch path never measures at all; it reads a cached snapshot. + **Binary resolution cannot be hijacked into a loop.** `SWISSCODE=1` is set in the child; seeing it in the ambient environment means swisscode resolved to itself via an alias or a shim, and the launch is refused rather than recursing into a diff --git a/package-lock.json b/package-lock.json index 1253338..8e39ea3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,16 @@ "swisscode": "bin/swisscode.js" }, "devDependencies": { + "@pandacss/dev": "^1.11.4", "@types/node": "^22.20.1", "@types/react": "^19.2.17", - "esbuild": "^0.25.0", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "esbuild": "^0.28.1", "ink-testing-library": "^4.0.0", - "typescript": "^7.0.2" + "preact": "^10.29.7", + "typescript": "^7.0.2", + "vite": "^8.1.5" }, "engines": { "node": ">=22" @@ -41,10 +46,429 @@ "node": ">=18" } }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@clack/core": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", + "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz", + "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "0.5.0", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.2.tgz", + "integrity": "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -59,9 +483,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -76,9 +500,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -93,9 +517,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -110,9 +534,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -127,9 +551,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -144,9 +568,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -161,9 +585,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -178,9 +602,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -195,9 +619,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -212,9 +636,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -229,9 +653,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -246,9 +670,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -263,9 +687,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -280,9 +704,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -297,9 +721,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -314,9 +738,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -331,9 +755,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -348,9 +772,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -365,9 +789,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -382,9 +806,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -399,9 +823,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -416,9 +840,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -433,9 +857,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -450,9 +874,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -467,9 +891,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -483,523 +907,641 @@ "node": ">=18" } }, - "node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "devOptional": true, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { - "csstype": "^3.2.2" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", "optional": true, - "os": [ - "aix" - ], + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, "engines": { - "node": ">=16.20.0" + "node": ">= 8" } }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pandacss/config": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/config/-/config-1.11.4.tgz", + "integrity": "sha512-fYdfRtMOtxdtrQQJmgoN6/IxKQOlQEpTGjUXqlHsHZ193JvwvE0FGEVVrGMLEOb/nbPn4vrscfFak+k4G8Qzww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pandacss/logger": "1.11.4", + "@pandacss/preset-base": "1.11.4", + "@pandacss/preset-panda": "1.11.4", + "@pandacss/shared": "1.11.4", + "@pandacss/types": "1.11.4", + "esbuild": "0.25.12", + "escalade": "3.2.0", + "microdiff": "1.5.0", + "typescript": "6.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/config/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "aix" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "node_modules/@pandacss/config/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ - "x64" + "arm" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "node_modules/@pandacss/config/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "freebsd" + "android" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "node_modules/@pandacss/config/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "freebsd" + "android" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "node_modules/@pandacss/config/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ - "arm" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "node_modules/@pandacss/config/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ - "arm64" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "darwin" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "node_modules/@pandacss/config/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ - "loong64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "node_modules/@pandacss/config/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ - "mips64el" + "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ - "ppc64" + "arm" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ - "s390x" + "ia32" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ - "x64" + "loong64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ - "arm64" + "mips64el" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ - "x64" + "ppc64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ - "arm64" + "riscv64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ - "x64" + "s390x" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "node_modules/@pandacss/config/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "sunos" + "linux" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "node_modules/@pandacss/config/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", "cpu": [ "arm64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "win32" + "netbsd" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "node_modules/@pandacss/config/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", "optional": true, "os": [ - "win32" + "netbsd" ], "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "node_modules/@pandacss/config/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@pandacss/config/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=18" } }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@pandacss/config/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=18" } }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "node_modules/@pandacss/config/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@pandacss/config/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=18" } }, - "node_modules/cli-boxes": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", - "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "node_modules/@pandacss/config/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18.20 <19 || >=20.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", - "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", - "license": "MIT", - "dependencies": { - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/@pandacss/config/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-toolkit": { - "version": "1.49.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", - "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { + "node_modules/@pandacss/config/node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", @@ -1041,207 +1583,3147 @@ "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", + "node_modules/@pandacss/config/node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=8" + "node": ">=14.17" } }, - "node_modules/figures": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", - "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "node_modules/@pandacss/core": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/core/-/core-1.11.4.tgz", + "integrity": "sha512-2C9qwnmdP24oaoTFc+dTMjJHQZ6iV/l4VCLR4hZlGTiQ60k0EFMK4sV998vu0n57fi4p1I1yTDk9Ew4Wu27pGg==", + "dev": true, "license": "MIT", "dependencies": { - "is-unicode-supported": "^2.0.0" + "@csstools/postcss-cascade-layers": "5.0.2", + "@pandacss/is-valid-prop": "^1.11.4", + "@pandacss/logger": "1.11.4", + "@pandacss/shared": "1.11.4", + "@pandacss/token-dictionary": "1.11.4", + "@pandacss/types": "1.11.4", + "browserslist": "4.28.1", + "lodash.merge": "4.6.2", + "outdent": "0.8.0", + "postcss": "8.5.14", + "postcss-discard-duplicates": "7.0.2", + "postcss-discard-empty": "7.0.1", + "postcss-minify-selectors": "7.0.5", + "postcss-nested": "7.0.2", + "postcss-normalize-whitespace": "7.0.1", + "postcss-selector-parser": "7.1.1", + "ts-pattern": "5.9.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=20" } }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "node_modules/@pandacss/dev": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/dev/-/dev-1.11.4.tgz", + "integrity": "sha512-jMexLMJAxnDutYZdzh9M3f1WTYX4Dl7QtsxJsQHRu4Vw3S5Sqb7awR+rP3PDPa8/zFNIbMhQXYtURkKByPrclA==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@clack/prompts": "0.11.0", + "@pandacss/config": "1.11.4", + "@pandacss/logger": "1.11.4", + "@pandacss/mcp": "1.11.4", + "@pandacss/node": "1.11.4", + "@pandacss/postcss": "1.11.4", + "@pandacss/preset-base": "1.11.4", + "@pandacss/preset-panda": "1.11.4", + "@pandacss/shared": "1.11.4", + "@pandacss/token-dictionary": "1.11.4", + "@pandacss/types": "1.11.4", + "cac": "6.7.14" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "license": "MIT", - "engines": { - "node": ">=12" + "bin": { + "panda": "bin.js", + "pandacss": "bin.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=20" } }, - "node_modules/ink": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", - "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", + "node_modules/@pandacss/extractor": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/extractor/-/extractor-1.11.4.tgz", + "integrity": "sha512-4xmyy5pUGZdqmyx+mRcq6xKuBZoLcU9EeNXnmMrrQKw8BhkxGOl3DNst0ZthH7S7JXEx9Aazc2Ac7kMlOh+cNQ==", + "dev": true, "license": "MIT", "dependencies": { - "@alcalzone/ansi-tokenize": "^0.3.0", - "ansi-escapes": "^7.3.0", - "ansi-styles": "^6.2.3", - "auto-bind": "^5.0.1", - "chalk": "^5.6.2", - "cli-boxes": "^4.0.1", - "cli-cursor": "^4.0.0", - "cli-truncate": "^6.0.0", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.45.1", - "indent-string": "^5.0.0", - "is-in-ci": "^2.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.33.0", - "scheduler": "^0.27.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^9.0.0", - "stack-utils": "^2.0.6", - "string-width": "^8.2.0", - "terminal-size": "^4.0.1", - "type-fest": "^5.5.0", - "widest-line": "^6.0.0", - "wrap-ansi": "^10.0.0", - "ws": "^8.20.0", - "yoga-layout": "~3.2.1" + "@pandacss/shared": "1.11.4", + "ts-evaluator": "1.2.0", + "ts-morph": "28.0.0" }, "engines": { - "node": ">=22" - }, - "peerDependencies": { - "@types/react": ">=19.2.0", - "react": ">=19.2.0", - "react-devtools-core": ">=6.1.2" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } + "node": ">=20" } }, - "node_modules/ink-select-input": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", - "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", + "node_modules/@pandacss/generator": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/generator/-/generator-1.11.4.tgz", + "integrity": "sha512-UDL8J/qdnMXwEEjU7OvQfMTCk+BIntILg/b0fh76xEgex08TrvBLtEM/rZ3rOh95yCVuGwQI9n1Njzz8u3bKxg==", + "dev": true, "license": "MIT", "dependencies": { - "figures": "^6.1.0", - "to-rotated": "^1.0.0" + "@pandacss/core": "1.11.4", + "@pandacss/is-valid-prop": "^1.11.4", + "@pandacss/logger": "1.11.4", + "@pandacss/shared": "1.11.4", + "@pandacss/token-dictionary": "1.11.4", + "@pandacss/types": "1.11.4", + "javascript-stringify": "2.1.0", + "outdent": " ^0.8.0", + "pluralize": "8.0.0", + "postcss": "8.5.14", + "ts-pattern": "5.9.0" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5.0.0", - "react": ">=18.0.0" + "node": ">=20" } }, - "node_modules/ink-testing-library": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", - "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "node_modules/@pandacss/is-valid-prop": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/is-valid-prop/-/is-valid-prop-1.11.4.tgz", + "integrity": "sha512-RWxInlS+lGgKiF0fB0HO76vsJFgRvbavm5Z25/GqqN8MPHXYA6n5rZnfdp4itEXy5DJkQ9vt3yrwa2IKiuhtrA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } + "node": ">=20" } }, - "node_modules/ink-text-input": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", - "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "node_modules/@pandacss/logger": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/logger/-/logger-1.11.4.tgz", + "integrity": "sha512-NY9aW9tCQR2yJVqdCpoB4LeOIaLk1HxztAc0CrgJgNROteEyj7lV65lr8FyWCi4RH3WBZvIhyPMldd3CSon/OA==", + "dev": true, "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "type-fest": "^4.18.2" + "@pandacss/types": "1.11.4", + "kleur": "4.1.5" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5", - "react": ">=18" + "node": ">=20" } }, - "node_modules/ink-text-input/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" + "node_modules/@pandacss/mcp": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/mcp/-/mcp-1.11.4.tgz", + "integrity": "sha512-MDsfhaVje+gso5z1lwHnCj3G6/wsQXi4aVJ6KsJiBpUiZNYs5QS7wRoPT8cUIIoDQJztTxIDPR9ozoL1O+hJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "0.11.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "@pandacss/logger": "1.11.4", + "@pandacss/node": "1.11.4", + "@pandacss/token-dictionary": "1.11.4", + "@pandacss/types": "1.11.4", + "zod": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=20" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/@pandacss/node": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/node/-/node-1.11.4.tgz", + "integrity": "sha512-u1/y1oHVASGwXfoKbADVPcrPi54zJ0R8Qg1EvPpRNjw+ovcqJsFkZHBN03lCwSOFbb2ukvCKf4rfMzzNvABajg==", + "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.1" + "@pandacss/config": "1.11.4", + "@pandacss/core": "1.11.4", + "@pandacss/generator": "1.11.4", + "@pandacss/logger": "1.11.4", + "@pandacss/parser": "1.11.4", + "@pandacss/plugin-lightningcss": "1.11.4", + "@pandacss/plugin-svelte": "1.11.4", + "@pandacss/plugin-vue": "1.11.4", + "@pandacss/reporter": "1.11.4", + "@pandacss/shared": "1.11.4", + "@pandacss/token-dictionary": "1.11.4", + "@pandacss/types": "1.11.4", + "browserslist": "4.28.1", + "chokidar": "4.0.3", + "fast-glob": "3.3.3", + "fs-extra": "11.3.2", + "get-tsconfig": "^4.13.0", + "glob-parent": "6.0.2", + "is-glob": "4.0.3", + "lodash.merge": "4.6.2", + "look-it-up": "2.1.0", + "outdent": " ^0.8.0", + "p-limit": "5.0.0", + "package-manager-detector": "1.6.0", + "perfect-debounce": "1.0.0", + "picomatch": "4.0.4", + "pkg-types": "2.3.0", + "pluralize": "8.0.0", + "postcss": "8.5.14", + "prettier": "3.2.5", + "ts-morph": "28.0.0", + "ts-pattern": "5.9.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=20" } }, - "node_modules/is-in-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", - "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "node_modules/@pandacss/parser": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/parser/-/parser-1.11.4.tgz", + "integrity": "sha512-GyJ/+O9QVUC8tCxc9FJPrL8Oqltc7OVDIg8uC46FMBFI2IrG8sJI+J1QkLGZ+OL4AYZ897JjYQK+pbYsPesHsA==", + "dev": true, "license": "MIT", - "bin": { - "is-in-ci": "cli.js" + "dependencies": { + "@pandacss/config": "^1.11.4", + "@pandacss/core": "^1.11.4", + "@pandacss/extractor": "1.11.4", + "@pandacss/logger": "1.11.4", + "@pandacss/shared": "1.11.4", + "@pandacss/types": "1.11.4", + "ts-morph": "28.0.0", + "ts-pattern": "5.9.0" }, "engines": { "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "node_modules/@pandacss/plugin-lightningcss": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/plugin-lightningcss/-/plugin-lightningcss-1.11.4.tgz", + "integrity": "sha512-DGEu7gkDfReBU5rqWfZ0+AAywS5OPfDmBchjuuEUmNUb+Cj3STEsd7jPTo4GZEMBFdd5GIMOIB/GScwZrrKCww==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@pandacss/logger": "1.11.4", + "@pandacss/types": "1.11.4", + "browserslist": "4.28.1", + "lightningcss": "1.31.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/plugin-svelte": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/plugin-svelte/-/plugin-svelte-1.11.4.tgz", + "integrity": "sha512-S9gohIV49MaOozt3+Xc6EiJgXd5Rc12I90XrI1KNfoKjwKjWuz0IZQgJOr67LQZIXn/BlAnO6aNugI5cBaUBRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pandacss/types": "1.11.4", + "magic-string": "0.30.21" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/plugin-vue": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/plugin-vue/-/plugin-vue-1.11.4.tgz", + "integrity": "sha512-wP+VnQByq1x6aQMyG2/70CRiLG4UORUC+bL+j86x0kj1UxdOpsPRf8TF1P5EyJPfenrnwUyDChaL4hJWL83JjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pandacss/types": "1.11.4", + "@vue/compiler-sfc": "3.5.25", + "magic-string": "0.30.21" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/postcss": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/postcss/-/postcss-1.11.4.tgz", + "integrity": "sha512-FOVKZDqqDrHzNNu0qE4Xi7SN+AheVUj3fFBg5B+Isj+uSh+w2YJNf5lyrJLU95PczMQl8NzrAocI5u6qHLBJVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pandacss/node": "1.11.4", + "postcss": "8.5.14" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/preset-base": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/preset-base/-/preset-base-1.11.4.tgz", + "integrity": "sha512-Pf4BX9aaUiwuYg2RBiwYA9wdzifJFNTJRYVNJv7YJHrojZipWY7ELR8PVRA4gdAbIjog7KoWlOmKz6PcUZNfnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pandacss/types": "1.11.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/preset-panda": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.4.tgz", + "integrity": "sha512-Z6yrY7o0Xyqc6JpnAZ954APGs5VhLeZLJqTzAJw9eu6Y0pP5SWaOeztZCdPwWZvK3LbyXliYzc3FY4cxEaVYNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pandacss/types": "1.11.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/reporter": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/reporter/-/reporter-1.11.4.tgz", + "integrity": "sha512-6+lCjdOTGwlL+P6RKEDlmExcYwgoedlLhR7JAgw0UF9IvgMxvmH7Xk6RX8drYq6B+a1fGG4RN9V371ySZKLzbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pandacss/core": "1.11.4", + "@pandacss/generator": "1.11.4", + "@pandacss/logger": "1.11.4", + "@pandacss/shared": "1.11.4", + "@pandacss/types": "1.11.4", + "table": "6.9.0", + "wordwrapjs": "5.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/shared": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/shared/-/shared-1.11.4.tgz", + "integrity": "sha512-zFPz5yladwlsynFBkFKtzDDqArlVwgy1MzW3Rbz8NqfT72pOvklmuizXoScHsEPv3ox8Y/tdMcJf48rn8R5e+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/token-dictionary": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/token-dictionary/-/token-dictionary-1.11.4.tgz", + "integrity": "sha512-K6agiBFJdu8ncyh3Z4EC43noDVlRNWFIdD6JFnsa2FIhCuhIoJP1hEInicURTfcoq4Z6OakAPonL4IW+54vbXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pandacss/logger": "^1.11.4", + "@pandacss/shared": "1.11.4", + "@pandacss/types": "1.11.4", + "picomatch": "4.0.4", + "ts-pattern": "5.9.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@pandacss/types": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.4.tgz", + "integrity": "sha512-Bn+ez7ckm2E406QBF0W2ckEeFIBFo91qvOy10/bdxxiAHR76fBmg6tr4YlHbLHdXpcSBjXFWrGWo8nqyKJSx2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz", + "integrity": "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "license": "MIT", + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crosspath": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crosspath/-/crosspath-2.0.0.tgz", + "integrity": "sha512-ju88BYCQ2uvjO2bR+SsgLSTwTSctU+6Vp2ePbKPgSCZyy4MWZxYsT738DlKVRE5utUjobjPRm1MkTYKJxCmpTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^17.0.36" + }, + "engines": { + "node": ">=14.9.0" + } + }, + "node_modules/crosspath/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.395", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", + "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/exsolve": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ink": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-select-input": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.2.0.tgz", + "integrity": "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ==", + "license": "MIT", + "dependencies": { + "figures": "^6.1.0", + "to-rotated": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5.0.0", + "react": ">=18.0.0" + } + }, + "node_modules/ink-testing-library": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-4.0.0.tgz", + "integrity": "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/react": ">=18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink-text-input/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/javascript-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/javascript-stringify/-/javascript-stringify-2.1.0.tgz", + "integrity": "sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/look-it-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/look-it-up/-/look-it-up-2.1.0.tgz", + "integrity": "sha512-nMoGWW2HurtuJf6XAL56FWTDCWLOTSsanrgwOyaR5Y4e3zfG5N/0cU5xWZSEU3tBxhQugRbV1xL9jb+ug7yZww==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/microdiff": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/microdiff/-/microdiff-1.5.0.tgz", + "integrity": "sha512-Drq+/THMvDdzRYrK0oxJmOKiC24ayUV8ahrt8l3oRK51PWt6gdtrIGrlIH3pT/lFh1z93FbAcidtsHcWbnRz8Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-fn": { @@ -1250,237 +4732,1766 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-path": { + "version": "0.11.8", + "resolved": "https://registry.npmjs.org/object-path/-/object-path-0.11.8.tgz", + "integrity": "sha512-YJjNZrlXJFM42wTBn6zgOJVar9KFJvzx6sTWDte8sWZF//cnjl0BxHNpfZx+ZffXX63A9q0b1zsFiBX4g4X5KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/outdent": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", + "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.2.tgz", + "integrity": "sha512-eTonaQvPZ/3i1ASDHOKkYwAybiM45zFIc7KXils4mQmHLqIswXD9XNOKEVxtTFnsmwYzF66u4LMgSr0abDlh5w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-discard-empty": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.1.tgz", + "integrity": "sha512-cFrJKZvcg/uxB6Ijr4l6qmn3pXQBna9zyrPC+sK0zjbkDUZew+6xDltSF7OeB7rAtzaaMVYSdbod+sZOCWnMOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.5.tgz", + "integrity": "sha512-x2/IvofHcdIrAm9Q+p06ZD1h6FPcQ32WtCRVodJLDR+WMn8EVHI1kvLxZuGKz/9EY5nAmI6lIQIrpo4tBy5+ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "postcss-selector-parser": "^7.1.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-nested": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-7.0.2.tgz", + "integrity": "sha512-5osppouFc0VR9/VYzYxO03VaDa3e8F23Kfd6/9qcZTUI8P58GIYlArOET2Wq0ywSl2o2PjELhYOFI4W7l5QHKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.1.tgz", + "integrity": "sha512-vsbgFHMFQrJBJKrUFJNZ2pgBeBkC2IvvoHjz1to0/0Xk7sII24T0qFOiJzG6Fu3zJoq/0yI4rKWi7WhApW+EFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.32" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/table/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/table/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/table/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^2.1.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=6" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/to-rotated": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", + "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/patch-console": { + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-evaluator": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ts-evaluator/-/ts-evaluator-1.2.0.tgz", + "integrity": "sha512-ncSGek1p92bj2ifB7s9UBgryHCkU9vwC5d+Lplt12gT9DH+e41X8dMoHRQjIMeAvyG7j9dEnuHmwgOtuRIQL+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "crosspath": "^2.0.0", + "object-path": "^0.11.8" + }, + "engines": { + "node": ">=14.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/wessberg/ts-evaluator?sponsor=1" + }, + "peerDependencies": { + "jsdom": ">=14.x || >=15.x || >=16.x || >=17.x || >=18.x || >=19.x || >=20.x || >=21.x || >=22.x", + "typescript": ">=3.2.x || >= 4.x || >= 5.x" + }, + "peerDependenciesMeta": { + "jsdom": { + "optional": true + } + } + }, + "node_modules/ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/react-reconciler": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", - "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" }, - "peerDependencies": { - "react": "^19.2.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/slice-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", - "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=22" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/string-width": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "license": "MIT", + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=20" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/terminal-size": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", - "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", - "license": "MIT", + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/to-rotated": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/to-rotated/-/to-rotated-1.0.0.tgz", - "integrity": "sha512-KsEID8AfgUy+pxVRLsWp0VzCa69wxzUDZnzGbyIST/bcgcrMvTYoFBX/QORH4YApoD89EDuUovx4BTdpOn319Q==", + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/type-fest": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", - "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", - "license": "(MIT OR CC0-1.0)", + "node_modules/vite/node_modules/postcss": { + "version": "8.5.22", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", + "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", "dependencies": { - "tagged-tag": "^1.0.0" + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^10 || ^12 || >=14" } }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "Apache-2.0", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, "bin": { - "tsc": "bin/tsc" + "node-which": "bin/node-which" }, "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" + "node": ">= 8" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, "node_modules/widest-line": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", @@ -1496,6 +6507,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wordwrapjs": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-5.1.1.tgz", + "integrity": "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.17" + } + }, "node_modules/wrap-ansi": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", @@ -1513,6 +6534,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", @@ -1534,11 +6562,51 @@ } } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index ef0f868..7c40683 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "swisscode", "version": "0.3.0", - "description": "Drop-in launcher for Claude Code, Kilo & OpenCode against Ollama (local, no key), OpenRouter, z.ai/GLM, Kimi, DeepSeek, Qwen & more — named profiles, per-directory bindings and real 1M-context fixes, with no proxy or daemon.", + "description": "Drop-in launcher for Claude Code, Kilo & OpenCode. Run multiple Claude Pro/Max accounts, check usage limits and switch accounts without /login — or point any coding CLI at Ollama (local, no key), OpenRouter, z.ai/GLM, DeepSeek & Qwen. No proxy, no daemon.", "type": "module", "bin": { "swisscode": "./bin/swisscode.js" @@ -18,22 +18,22 @@ "build": "node build.js", "dev": "node build.js && tsc -p tsconfig.build.json --watch --preserveWatchOutput", "typecheck": "tsc --noEmit", - "test": "tsc --noEmit && node build.js && node --test \"test/**/*.test.ts\"", + "test": "tsc --noEmit && npm run typecheck:web && node build.js && node --test \"test/**/*.test.ts\"", "prepare": "node build.js", - "prepublishOnly": "node build.js" + "prepublishOnly": "node build.js", + "size": "node scripts/size-budget.js", + "dev:web": "vite --config web/vite.config.ts", + "typecheck:web": "tsc --noEmit -p web/tsconfig.json" }, "keywords": [ "claude", "claude-code", "anthropic", - "anthropic-api", "cli", "launcher", - "ai", "llm", "coding-agent", "ai-agent", - "agentic", "provider-switcher", "claude-code-router", "openrouter", @@ -48,10 +48,16 @@ "siliconflow", "ollama", "local-llm", - "local-ai", - "offline-ai", - "self-hosted", - "developer-tools" + "developer-tools", + "claude-max", + "claude-pro", + "subscription", + "multiple-accounts", + "account-switcher", + "usage-limits", + "rate-limits", + "claude-usage", + "bunx" ], "license": "MIT", "repository": { @@ -69,10 +75,15 @@ "react": "^19.2.0" }, "devDependencies": { + "@pandacss/dev": "^1.11.4", "@types/node": "^22.20.1", "@types/react": "^19.2.17", - "esbuild": "^0.25.0", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.2.0", + "esbuild": "^0.28.1", "ink-testing-library": "^4.0.0", - "typescript": "^7.0.2" + "preact": "^10.29.7", + "typescript": "^7.0.2", + "vite": "^8.1.5" } } diff --git a/scripts/size-budget.js b/scripts/size-budget.js new file mode 100644 index 0000000..e03ea7e --- /dev/null +++ b/scripts/size-budget.js @@ -0,0 +1,66 @@ +// The published tarball has a ceiling, and busting it is a build failure. +// +// swisscode is a launcher whose pitch is that it is small and gets out of the +// way. Shipping a web UI means every user downloads a React bundle they may +// never open — a deliberate trade, made once. This script is what stops that +// trade being silently re-made, a few kilobytes at a time, by changes nobody +// weighed. +// +// It measures `npm pack`, not the source tree: what matters is what users +// actually download, and `files` (bin, dist, README) is what decides that. + +import { execFileSync } from 'node:child_process' + +/** + * Packed (gzipped) tarball ceiling, in kilobytes. + * + * Set with headroom over the current artifact, not flush against it: a budget + * that fails on the next honest change trains people to raise it reflexively, + * which is the same as not having one. Raising it should be a visible line in a + * diff with a reason attached. + * + * LOWERED from 260 when the artifact fell to ~118 kB — stripping comments from + * the emitted JS, minifying dist/ui.js, and swapping react-dom for preact/compat + * in the browser bundle. A ceiling with more slack beneath it than artifact + * above it is not a budget; it is a number. 150 keeps ~27% headroom, which is + * room for an honest feature and not room for a silent regression. + */ +const BUDGET_KB = 150 + +// `--ignore-scripts` because `prepare` runs the whole build, and this script is +// meant to MEASURE the artifact, not rebuild it — in CI the build has already +// happened, and rebuilding here would double the work and hide a stale dist/. +const raw = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + encoding: 'utf8', +}) + +/** + * Find the JSON array, tolerantly. + * + * The first attempt sliced from the first `[` and broke immediately: npm emits + * ANSI control sequences (``) around progress output, so the first `[` + * in the stream belonged to an escape code rather than to the payload. Strip + * the escapes first, then match an array that actually starts with an object. + */ +const clean = raw.replace(/\[[0-9;]*[a-zA-Z]/g, '') +const match = clean.match(/\[\s*\{[\s\S]*\}\s*\]/) +if (!match) { + console.error('could not find the pack report in npm output') + process.exit(1) +} +const report = JSON.parse(match[0]) +const { size, unpackedSize, entryCount } = report[0] + +const kb = size / 1000 +const line = `tarball ${kb.toFixed(1)} kB packed · ${(unpackedSize / 1000).toFixed(1)} kB unpacked · ${entryCount} files` + +if (kb > BUDGET_KB) { + console.error(`size budget EXCEEDED: ${line} (ceiling ${BUDGET_KB} kB)`) + console.error( + 'Either shrink what ships, or raise BUDGET_KB in scripts/size-budget.js with a note ' + + 'saying what grew and why it is worth it to someone who never opens the web UI.', + ) + process.exit(1) +} + +console.log(`size budget ok: ${line} (ceiling ${BUDGET_KB} kB, ${(BUDGET_KB - kb).toFixed(1)} kB spare)`) diff --git a/src/adapters/agents/claude-code/doctor.ts b/src/adapters/agents/claude-code/doctor.ts index d6996a9..4151188 100644 --- a/src/adapters/agents/claude-code/doctor.ts +++ b/src/adapters/agents/claude-code/doctor.ts @@ -24,7 +24,7 @@ import { SOFT_RESERVED } from '../../../core/migrate.ts' import { isInsecureRemoteBaseUrl, sanitizeUrlForDisplay } from '../../../core/url-safety.ts' import type { EnvPlan } from './env.ts' import type { ProfileSelection } from '../../../core/profile.ts' -import type { ConfigModes, LoadResult, Profile } from '../../../ports/config-store.ts' +import type { ConfigModes, LoadResult, ResolvedProfile } from '../../../ports/config-store.ts' import type { ClaudeCodeCredentialEnv } from '../../../ports/claude-code.ts' import type { ProviderDescriptor, ResolvedModels, Tier } from '../../../ports/provider.ts' import type { @@ -118,7 +118,7 @@ export type StaticChecksInput = { /** resolveProfile() result */ selection: ProfileSelection /** after overrides */ - profile: Profile | null + profile: ResolvedProfile | null /** descriptor, or null when unknown */ provider: ProviderDescriptor | null /** buildEnvPlan() result */ @@ -471,7 +471,7 @@ export type ProbeSpec = { * signature stays stable. */ export function probeSpec( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, plan: EnvPlan | null | undefined, ): ProbeSpec { diff --git a/src/adapters/agents/claude-code/env.ts b/src/adapters/agents/claude-code/env.ts index 42326cf..aff0db1 100644 --- a/src/adapters/agents/claude-code/env.ts +++ b/src/adapters/agents/claude-code/env.ts @@ -6,17 +6,52 @@ // variable this tool emits is chosen here. The generic accumulator it is built // on (makeEnvWriter, materializeEnv) is neutral and lives in core/env-plan.ts. +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' import { TIERS } from '../../../core/tiers.ts' import { definedEntriesOf, makeEnvWriter, resolveCredential } from '../../../core/env-plan.ts' import { TIER_ENV } from './tiers.ts' import { autoCompactWindow, withExtendedContext } from './context.ts' import { inspectAmbient } from './hygiene.ts' -import type { Profile } from '../../../ports/config-store.ts' +import type { ResolvedProfile } from '../../../ports/config-store.ts' import type { ClaudeCodeCompatEnv, ClaudeCodeCompatFlag } from '../../../ports/claude-code.ts' import type { ProviderDescriptor, ResolvedModels, Tier } from '../../../ports/provider.ts' import type { EnvMap } from '../../../ports/process.ts' import type { EnvWarning } from '../../../ports/agent.ts' +/** + * Does this session directory mean "the default login"? + * + * Lives here, in the env lowering, because it is an ENV-LOWERING QUESTION: the + * answer decides whether CLAUDE_CONFIG_DIR is written or cleared. It earns no + * module of its own — the launch path is held under 40 modules so it stays + * auditable in a sitting, and that budget is a real constraint rather than a + * decoration. + * + * MEASURED, and the measurement is the whole reason it exists. Claude Code + * chooses which Keychain item holds the credential from WHETHER + * `CLAUDE_CONFIG_DIR` IS SET, hashing the path into the service name whenever + * it is — so the default directory has two distinct credentials depending on + * how you arrive at it. Verified on a real machine: + * + * claude config ls -> logged in + * CLAUDE_CONFIG_DIR=$HOME/.claude claude config ls -> "Not logged in" + * + * Same directory, same `.claude.json`, different login. Identity is shared — + * that file really is the same one — so a session lowered the wrong way reports + * the correct email while being unable to authenticate, which is about the most + * confusing failure on offer. + */ +export function isDefaultConfigDir( + dir: string, + env: Record = process.env, +): boolean { + // `resolve` so a trailing slash, a doubled separator, or a relative spelling + // of the same directory all answer alike. A near-miss does not fail loudly; + // it silently takes the hashed-credential branch. + return resolve(dir) === resolve(join(env.HOME || homedir(), '.claude')) +} + /** * CompatFlags -> env var. Descriptors never spell a variable name; they set a * boolean and this table decides what that means. Each entry below has a @@ -56,6 +91,21 @@ export const COMPAT_ENV: Record = Object.freeze({ }, }) satisfies Record +/** + * The variable spellings that may carry the credential, as RUNTIME data. + * + * ports/claude-code.ts has the type (`ClaudeCodeCredentialEnv`), but a type + * erases — and validating a provider typed in by a user needs a list at + * runtime. It lives here because this adapter is the designated home for the + * dialect: core/ is forbidden from naming these, which is what keeps a + * user-defined provider validated by injection rather than by core learning + * Anthropic's vocabulary. + */ +export const CREDENTIAL_ENVS: readonly string[] = Object.freeze([ + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', +]) + /** * The finished plan, Claude-Code-internal shape. `set`/`unset` are the neutral * half (assignable to ports/agent.ts `EnvPlan`); `warnings` and `resolvedModels` @@ -73,7 +123,7 @@ export type EnvPlan = { } export function buildEnvPlan( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, ambientEnv: EnvMap = {}, ): EnvPlan { @@ -130,14 +180,54 @@ export function buildEnvPlan( write('ANTHROPIC_API_KEY', '') } + // 4b. SESSION MODE. The account points at a directory holding a login Claude + // Code already performed, so the credential is not ours to supply — we + // just tell it where to look. + // + // BOTH credential variables are cleared, and that is the whole point. + // ANTHROPIC_API_KEY overrides an OAuth login outright, and a stale + // ANTHROPIC_AUTH_TOKEN left in a shell would be presented instead of the + // subscription this account names. Verified before this was written: the + // anthropic-direct path cleared only the first, so a stale auth token + // survived into the child — a silent wrong-account launch, which is the + // exact failure the golden maps exist to catch. + // + // SETTING THE VARIABLE TO THE DEFAULT PATH IS NOT THE SAME AS LEAVING IT + // UNSET, and this is not a subtlety we may round off. Claude Code decides + // which Keychain item holds the credential from whether CLAUDE_CONFIG_DIR + // IS SET — not from what it contains — hashing the path into the service + // name whenever it is. So the default directory has two different + // credentials depending on how you arrive at it. Verified on this machine: + // + // claude config ls -> logged in + // CLAUDE_CONFIG_DIR=$HOME/.claude ... ls -> "Not logged in" + // + // Same directory, same `.claude.json`, different login. An account that + // names the default directory therefore lowers to UNSETTING the variable, + // which is also what makes adopting an existing `~/.claude` work with no + // re-login. Writing the path instead would hand the user a session that + // reports the right email — identity comes from `.claude.json`, which IS + // shared — while being logged out. + const sessionDir = profile?.configDir + if (sessionDir) { + write('CLAUDE_CONFIG_DIR', isDefaultConfigDir(sessionDir, ambientEnv) ? '' : sessionDir) + write('ANTHROPIC_API_KEY', '') + write('ANTHROPIC_AUTH_TOKEN', '') + } + // 5. Credential, unconditionally — an empty one clears a stale variable. // `defaultCredential` covers the keyless endpoint: a local Ollama ignores // the token entirely (verified: no header, a wrong key and a bearer token // all behave identically), but Claude Code still wants the variable to // carry something, so the descriptor supplies the placeholder rather than // every user being told to invent one. - const credentialEnv = provider?.credentialEnv ?? 'ANTHROPIC_AUTH_TOKEN' - write(credentialEnv, resolveCredential(profile, ambientEnv) || (provider?.defaultCredential ?? '')) + // Skipped entirely in session mode: step 4b already cleared both + // variables, and writing one back — even an empty one — would re-open the + // question of which credential a subscription launch presented. + if (!sessionDir) { + const credentialEnv = provider?.credentialEnv ?? 'ANTHROPIC_AUTH_TOKEN' + write(credentialEnv, resolveCredential(profile, ambientEnv) || (provider?.defaultCredential ?? '')) + } // 6. All four tiers, from one table. const effectiveModels: Partial> = { diff --git a/src/adapters/agents/claude-code/hygiene.ts b/src/adapters/agents/claude-code/hygiene.ts index 5c6952a..371f32f 100644 --- a/src/adapters/agents/claude-code/hygiene.ts +++ b/src/adapters/agents/claude-code/hygiene.ts @@ -20,7 +20,7 @@ import { TIER_ENV_VARS } from './tiers.ts' import { SUFFIX, bareModelId, supportsExtendedContext } from './context.ts' import { sanitizeUrlForDisplay } from '../../../core/url-safety.ts' -import type { Profile } from '../../../ports/config-store.ts' +import type { ResolvedProfile } from '../../../ports/config-store.ts' import type { ProviderDescriptor } from '../../../ports/provider.ts' import type { EnvMap } from '../../../ports/process.ts' import type { EnvWarning, WarningSeverity } from '../../../ports/agent.ts' @@ -40,7 +40,7 @@ export type PlanFacts = { export type HygieneContext = { provider?: ProviderDescriptor | null | undefined - profile?: Profile | null | undefined + profile?: ResolvedProfile | null | undefined } const w = (severity: WarningSeverity, code: string, message: string): EnvWarning => ({ @@ -235,7 +235,7 @@ export type StaleStoredModel = { * advice about stored data rather than a correctness fix. */ export function staleStoredModels( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, ): StaleStoredModel[] { const ec = provider?.extendedContext diff --git a/src/adapters/agents/claude-code/index.ts b/src/adapters/agents/claude-code/index.ts index 00551b5..a454595 100644 --- a/src/adapters/agents/claude-code/index.ts +++ b/src/adapters/agents/claude-code/index.ts @@ -48,6 +48,9 @@ export const claudeCode = { skipPermissions: true, extendedContextSuffix: true, compatFlags: true, + // CLAUDE_CONFIG_DIR. The whole reason session mode exists: a subscription + // login lives in a directory rather than in a variable we could carry. + sessionDir: true, }, binary, translate(input: TranslateInput): Translation { diff --git a/src/adapters/agents/kilo/index.ts b/src/adapters/agents/kilo/index.ts index 7b13465..44c4960 100644 --- a/src/adapters/agents/kilo/index.ts +++ b/src/adapters/agents/kilo/index.ts @@ -19,6 +19,7 @@ import { ambientUnset, anthropicOptions, collapsedTierWarning, + sessionUnavailableWarning, extendedContextWarning, modelRef, modelsBlock, @@ -50,6 +51,9 @@ export const kilo = { skipPermissions: true, extendedContextSuffix: false, compatFlags: false, + // Kilo takes its credential inline in KILO_CONFIG_CONTENT and has no notion of + // an existing login directory, so a session-mode account gives it nothing. + sessionDir: false, }, binary, translate(input: TranslateInput): Translation { @@ -72,6 +76,8 @@ export const kilo = { const set: Record = { [KILO_CONFIG_ENV]: JSON.stringify(config) } const warnings: EnvWarning[] = [] + const noSession = sessionUnavailableWarning(intent, 'Kilo') + if (noSession) warnings.push(noSession) const collapse = collapsedTierWarning(intent, ['opus'], 'Kilo') if (collapse) warnings.push(collapse) const ext = extendedContextWarning(intent, primary, 'Kilo') diff --git a/src/adapters/agents/opencode/index.ts b/src/adapters/agents/opencode/index.ts index 1207fa3..17336f0 100644 --- a/src/adapters/agents/opencode/index.ts +++ b/src/adapters/agents/opencode/index.ts @@ -16,6 +16,7 @@ import { ambientUnset, anthropicOptions, collapsedTierWarning, + sessionUnavailableWarning, extendedContextWarning, modelRef, modelsBlock, @@ -49,6 +50,9 @@ export const opencode = { skipPermissions: true, extendedContextSuffix: false, compatFlags: false, + // OpenCode takes its credential inline in OPENCODE_CONFIG_CONTENT, so a + // session-mode account gives it nothing to authenticate with. + sessionDir: false, }, binary, translate(input: TranslateInput): Translation { @@ -77,6 +81,8 @@ export const opencode = { : [...passthrough] const warnings: EnvWarning[] = [] + const noSession = sessionUnavailableWarning(intent, 'OpenCode') + if (noSession) warnings.push(noSession) const collapse = collapsedTierWarning(intent, ['opus', 'haiku'], 'OpenCode') if (collapse) warnings.push(collapse) const ext = extendedContextWarning(intent, primary, 'OpenCode') diff --git a/src/adapters/agents/shared.ts b/src/adapters/agents/shared.ts index a4ec224..a41da85 100644 --- a/src/adapters/agents/shared.ts +++ b/src/adapters/agents/shared.ts @@ -125,3 +125,30 @@ export function extendedContextWarning( `Claude-Code-specific model suffix that ${agentLabel} does not send.`, } } + +/** + * Warn when an account authenticates by SESSION and this agent cannot use one. + * + * A session-mode account carries no credential — only a path to a directory + * where some agent already logged in. Claude Code can be pointed at it; + * Kilo and OpenCode take their credential inline and have no equivalent, so + * they would launch with nothing to authenticate with and fail at the first + * request with a message about the endpoint rather than about the account. + * + * `high`, unlike the tier-collapse warning: a collapsed tier still launches + * something useful, whereas this launch cannot work at all. + */ +export function sessionUnavailableWarning( + intent: LaunchIntent, + agentLabel: string, +): EnvWarning | null { + if (!intent.sessionDir) return null + return { + severity: 'high', + code: 'session-unsupported', + message: + `this account authenticates with an existing ${'`claude`'} login (a session directory), ` + + `and ${agentLabel} cannot use one — it needs a credential of its own. Give the ` + + `account an API key, or point this profile at an agent that supports sessions.`, + } +} diff --git a/src/adapters/claude-session/credentials.ts b/src/adapters/claude-session/credentials.ts new file mode 100644 index 0000000..030e997 --- /dev/null +++ b/src/adapters/claude-session/credentials.ts @@ -0,0 +1,226 @@ +// Reading the OAuth token a session directory owns. +// +// THE ONE MODULE IN THIS TOOL THAT TOUCHES A CREDENTIAL IT DID NOT PUT THERE, +// and it is deliberately the smallest thing that can work: read, report, never +// refresh and never write. It exists so `usage` selection can ask Anthropic +// how much of each subscription is left — nothing else. +// +// WHAT THIS DOES NOT DO, on purpose: +// +// - It never REFRESHES an expired token. Refreshing needs Anthropic's own +// OAuth client id, which is the impersonation line this design stays behind. +// An expired token is reported as expired; the agent refreshes it itself the +// next time you run it, which is both correct and free. +// - It never WRITES. Writing lives in `swap.ts`, alone, so that nothing on +// this path can damage a login — a property held by construction rather +// than by care. +// - It never LOGS the token, and no caller is given a shape that tempts it to. +// +// OFF THE LAUNCH PATH — `test/architecture.test.ts` enforces it. A launch hands +// the directory to the agent and lets the agent read its own credential, which +// is why an ordinary `swisscode` never raises a Keychain prompt. + +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { isDefaultConfigDir } from '../agents/claude-code/env.ts' + +type ReadableEnv = Record + +/** + * An OAuth credential, as Claude Code stores it. + * + * `accessToken` is the only field anything here uses. `refreshToken` is + * deliberately NOT in this type: nothing in swisscode may refresh, so carrying + * one around would only create the opportunity. + */ +export type SessionCredential = { + accessToken: string + /** epoch ms, when published */ + expiresAt: number | null + /** e.g. 'max' — Anthropic's own word for the plan behind this token */ + subscriptionType: string | null + /** where it was found, for diagnosis */ + source: 'keychain' | 'file' +} + +export type CredentialResult = + | { ok: true; credential: SessionCredential; expired: boolean } + /** + * `reason` names the fix, and `kind` lets a caller distinguish "log in" from + * "we could not look" — a Keychain prompt the user dismissed is not the same + * finding as an account that was never logged into. + */ + | { ok: false; kind: 'absent' | 'denied' | 'unreadable'; reason: string } + +/** + * The Keychain service name holding a session directory's credential. + * + * DERIVED FROM CLAUDE CODE'S OWN RULE, whose shape is: + * + * isDefaultDir = !process.env.CLAUDE_CONFIG_DIR + * dirHash = isDefaultDir ? '' : `-${sha256(configDir).slice(0, 8)}` + * service = `Claude Code-credentials${dirHash}` + * + * Two things follow, and both matter more than they look: + * + * 1. The branch is on whether the VARIABLE IS SET, not on the path's value — + * the same asymmetry `isDefaultConfigDir` exists for. A session lowered as + * "unset the variable" must be looked up under the UNHASHED name. + * 2. The hash is over the string that would be WRITTEN to CLAUDE_CONFIG_DIR. + * We control that string — the env lowering writes `account.configDir` + * verbatim — so this hashes exactly the same string rather than a + * re-normalised spelling of it. Normalising here and not there would produce + * a name that is right in every test and wrong on every machine. + * + * The unhashed branch is VERIFIED live: the real item on this machine is + * `Claude Code-credentials`, matching exactly. The hashed branch follows the + * rule above but cannot be confirmed without performing a real `/login` into a + * custom directory, so `config doctor` reports what it finds rather than + * asserting the name is right. + */ +export function keychainService(configDir: string, env: ReadableEnv = process.env): string { + if (isDefaultConfigDir(configDir, env)) return 'Claude Code-credentials' + const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8) + return `Claude Code-credentials-${hash}` +} + +/** + * Where the credential lives when it is a file rather than a Keychain item. + * + * Unlike `.claude.json`, this one has NO asymmetry — it is inside the config + * directory either way. Worth stating because the neighbouring file does not + * behave like this, and assuming they match is an easy way to read the wrong + * account's token. + */ +export function credentialFilePath(configDir: string): string { + return join(configDir, '.credentials.json') +} + +/** Pull the fields we use out of whichever envelope the token arrived in. */ +function parseCredential(raw: string, source: 'keychain' | 'file'): SessionCredential | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') return null + // Both stores wrap the token in `claudeAiOauth`; tolerate a bare object too, + // since the wrapper is not ours and tolerating it costs one line. + const o = parsed as Record + const oauth = (o.claudeAiOauth ?? o) as Record + const accessToken = oauth.accessToken + if (typeof accessToken !== 'string' || accessToken === '') return null + return { + accessToken, + expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : null, + subscriptionType: + typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : null, + source, + } +} + +export type ReadCredentialOptions = { + env?: ReadableEnv + /** injected in tests; defaults to /usr/bin/security */ + keychain?: (service: string) => string + /** injected in tests; defaults to node:fs */ + readFile?: (path: string) => string + platform?: NodeJS.Platform + /** injected so expiry is decidable without a clock */ + now?: number +} + +/** + * `/usr/bin/security find-generic-password -s -w`. + * + * ABSOLUTE PATH, so a `security` earlier on PATH cannot impersonate it — this + * command's whole job is handling a secret, and PATH is attacker-influenced on a + * shared machine. + * + * The service name goes in argv, which is fine: it is not a secret and it is + * already visible in the Keychain. THE TOKEN COMES BACK ON STDOUT and is never + * placed in argv by anything here. + */ +function readKeychain(service: string): string { + return execFileSync('/usr/bin/security', ['find-generic-password', '-s', service, '-w'], { + encoding: 'utf8', + // A Keychain prompt the user never answers must not hang a configuration + // screen forever. 20s is generous for a click and short enough to recover. + timeout: 20_000, + // The token is on stdout; keep the CLI's own chatter off our stderr. + stdio: ['ignore', 'pipe', 'ignore'], + }) +} + +/** + * Read the credential for a session directory. + * + * Never throws and never refreshes. On macOS it tries the Keychain, then the + * file — Claude Code writes the file on platforms without a Keychain, and some + * macOS setups end up with one too, so trying both is what actually works + * rather than what the platform table says should. + */ +export function readSessionCredential( + configDir: string, + { + env = process.env, + keychain = readKeychain, + readFile = (p) => readFileSync(p, 'utf8'), + platform = process.platform, + now = Date.now(), + }: ReadCredentialOptions = {}, +): CredentialResult { + let denied = false + + if (platform === 'darwin') { + try { + const credential = parseCredential(keychain(keychainService(configDir, env)), 'keychain') + if (credential) { + return { ok: true, credential, expired: isExpired(credential, now) } + } + } catch (e) { + // Exit 44 is "item not found", which is a normal state for a directory + // nobody has logged into. Anything else — a dismissed prompt, a locked + // keychain — is a DIFFERENT finding and must not be reported as + // "not logged in", which would send the user to fix the wrong thing. + const status = (e as { status?: number }).status + denied = status !== 44 && status !== undefined + } + } + + try { + const credential = parseCredential(readFile(credentialFilePath(configDir)), 'file') + if (credential) return { ok: true, credential, expired: isExpired(credential, now) } + } catch { + /* fall through to the verdict below */ + } + + if (denied) { + return { + ok: false, + kind: 'denied', + reason: + 'the keychain refused to hand over this account\'s token — the prompt was dismissed, ' + + 'or the keychain is locked. Unlock it and try again; nothing is wrong with the account.', + } + } + return { + ok: false, + kind: 'absent', + reason: `no login found for ${configDir}. Run \`swisscode config accounts login\` for it.`, + } +} + +/** + * Expiry is REPORTED, never acted on. + * + * A token past `expiresAt` still identifies the account; it just will not + * authenticate. Callers say so and move on — the agent refreshes it on its next + * run, which is the only party entitled to. + */ +function isExpired(credential: SessionCredential, now: number): boolean { + return credential.expiresAt !== null && credential.expiresAt <= now +} diff --git a/src/adapters/claude-session/identity.ts b/src/adapters/claude-session/identity.ts new file mode 100644 index 0000000..8b3919d --- /dev/null +++ b/src/adapters/claude-session/identity.ts @@ -0,0 +1,197 @@ +// Who a Claude Code session directory is logged in as. +// +// READS NO CREDENTIAL. Everything here comes out of `.claude.json`, which the +// agent writes for its own bookkeeping, so listing every account's identity +// costs one file read apiece and prompts nothing — no Keychain, no network, no +// unlock dialog. That is the whole reason identity is a separate module from +// `credentials.ts`: `config accounts` runs constantly and must stay free. +// +// OFF THE LAUNCH PATH, like the doctor probes and the catalogs. +// `test/architecture.test.ts` holds that line: the launch path resolves a +// session directory and lowers it to an env var without ever opening it, since +// a launch that stats a 200 kB JSON file to print a nicer banner has spent the +// budget this tool exists to protect. + +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { isDefaultConfigDir } from '../agents/claude-code/env.ts' + +type ReadableEnv = Record + +/** + * What `.claude.json` is willing to tell us about the logged-in account. + * + * Every field optional, and every field VERIFIED PRESENT on a real Max account + * rather than assumed from the shape of the type. The nulls below are why: + * this was written against the real file, and the obvious-looking fields turned + * out to be the empty ones. + */ +export type SessionIdentity = { + /** stable across email changes; the honest key for "same account?" */ + accountUuid?: string + email?: string + displayName?: string + organizationName?: string + organizationUuid?: string + /** + * A human-readable plan, best effort. + * + * MEASURED, NOT ASSUMED. `seatTier` and `userRateLimitTier` — the two fields + * that sound like the answer — are both `null` on a live Max 20x account. + * The field that actually carries it is `organizationRateLimitTier` + * ("default_claude_max_20x"), with `organizationType` ("claude_max") behind + * it. Preferring the plausible-sounding fields would have shown every user a + * blank plan, which is the class of bug this codebase measures to avoid. + */ + plan?: string + /** true when the account can spend beyond the subscription window */ + extraUsage?: boolean +} + +/** + * Where a session directory keeps its `.claude.json`. + * + * THE ASYMMETRY IS REAL AND IT IS THE POINT. Confirmed by running the agent + * against a throwaway dir: + * + * CLAUDE_CONFIG_DIR unset -> ~/.claude.json (a SIBLING of ~/.claude) + * CLAUDE_CONFIG_DIR= -> /.claude.json (INSIDE it) + * + * There is deliberately NO fallback between the two. If a custom directory has + * no `.claude.json`, the answer is "never logged in" — reading `~/.claude.json` + * instead would report the DEFAULT account's identity for a directory that is + * not it, which is precisely the silently-wrong-account failure this whole + * feature exists to end. + */ +export function configFilePath(configDir: string, env: ReadableEnv = process.env): string { + // Shares `isDefaultConfigDir` with the launch path rather than re-deriving + // "is this the default directory?" — the two must agree, and the launch path + // owns that question because it is the one that has to unset the variable. + return isDefaultConfigDir(configDir, env) + ? join(env.HOME || homedir(), '.claude.json') + : join(resolve(configDir), '.claude.json') +} + +/** The shape we pick out of the file. Everything else is the agent's business. */ +type OAuthAccount = { + accountUuid?: unknown + emailAddress?: unknown + displayName?: unknown + organizationName?: unknown + organizationUuid?: unknown + organizationType?: unknown + organizationRateLimitTier?: unknown + userRateLimitTier?: unknown + seatTier?: unknown + hasExtraUsageEnabled?: unknown +} + +const str = (v: unknown): string | undefined => + typeof v === 'string' && v.trim() !== '' ? v : undefined + +/** + * Turn a rate-limit tier id into something worth printing. + * + * Falls through to the raw id rather than to a blank: an unrecognised tier is + * far more useful shown verbatim than hidden, and a new plan name appearing in + * output is a smaller failure than a plan silently reading as "—". + */ +function readablePlan(a: OAuthAccount): string | undefined { + const raw = + str(a.organizationRateLimitTier) ?? + str(a.userRateLimitTier) ?? + str(a.seatTier) ?? + str(a.organizationType) + if (!raw) return undefined + const known: Record = { + default_claude_max_20x: 'Max 20x', + default_claude_max_5x: 'Max 5x', + default_claude_pro: 'Pro', + claude_max: 'Max', + claude_pro: 'Pro', + } + return known[raw] ?? raw +} + +export type ReadIdentityOptions = { + env?: ReadableEnv + /** injected in tests; defaults to node:fs */ + readFile?: (path: string) => string +} + +/** + * Read the identity of a session directory, or null. + * + * Null covers every "we cannot say" case — no directory, no file, unparseable + * file, or a file with no `oauthAccount` (a real state: a directory the agent + * has started in but nobody has run `/login` in yet). The caller distinguishes + * those with `existsSync` if it cares; for display purposes they are all + * "not logged in", and guessing between them would be inventing detail. + */ +export function readSessionIdentity( + configDir: string, + { env = process.env, readFile = (p) => readFileSync(p, 'utf8') }: ReadIdentityOptions = {}, +): SessionIdentity | null { + let parsed: unknown + try { + parsed = JSON.parse(readFile(configFilePath(configDir, env))) + } catch { + // Absent, unreadable, or not JSON. All three mean "we cannot say". + return null + } + if (!parsed || typeof parsed !== 'object') return null + const account = (parsed as { oauthAccount?: unknown }).oauthAccount + if (!account || typeof account !== 'object') return null + + const a = account as OAuthAccount + const identity: SessionIdentity = {} + // Conditional assignment throughout: `exactOptionalPropertyTypes` makes + // `email: undefined` a different type from an absent `email`, and callers + // branch on presence. + const accountUuid = str(a.accountUuid) + if (accountUuid) identity.accountUuid = accountUuid + const email = str(a.emailAddress) + if (email) identity.email = email + const displayName = str(a.displayName) + if (displayName) identity.displayName = displayName + const organizationName = str(a.organizationName) + if (organizationName) identity.organizationName = organizationName + const organizationUuid = str(a.organizationUuid) + if (organizationUuid) identity.organizationUuid = organizationUuid + const plan = readablePlan(a) + if (plan) identity.plan = plan + if (typeof a.hasExtraUsageEnabled === 'boolean') identity.extraUsage = a.hasExtraUsageEnabled + + // An `oauthAccount` with nothing recognisable in it is not an identity. + return Object.keys(identity).length > 0 ? identity : null +} + +/** + * One line naming the account, for a list. + * + * Prefers the email, because that is what the user typed at `/login` and what + * `/status` shows them. The org name is a poor substitute — on a personal Max + * account it is literally "'s Organization" — so it appears only when + * there is no email at all. + */ +export function describeIdentity(identity: SessionIdentity | null): string { + if (!identity) return 'not logged in' + const who = identity.email ?? identity.displayName ?? identity.organizationName ?? 'logged in' + return identity.plan ? `${who} · ${identity.plan}` : who +} + +/** + * Whether a directory looks like one the agent has ever run in. + * + * Distinguishes "never used" from "used but logged out", which is the + * difference between `config accounts login` being the fix and it being a + * puzzle. Deliberately a path check, not a login check. + */ +export function sessionDirLooksInitialised( + configDir: string, + { env = process.env, exists }: { env?: ReadableEnv; exists: (p: string) => boolean }, +): boolean { + const file = configFilePath(configDir, env) + return exists(file) || exists(dirname(file)) +} diff --git a/src/adapters/claude-session/onboard.ts b/src/adapters/claude-session/onboard.ts new file mode 100644 index 0000000..2ab502e --- /dev/null +++ b/src/adapters/claude-session/onboard.ts @@ -0,0 +1,249 @@ +// `swisscode config accounts login ` — adopt a subscription. +// +// The one-time step that turns "an account I pay for" into "an account +// swisscode can select". It creates a directory, records it, and then HANDS THE +// TERMINAL TO THE AGENT so the official `/login` runs, unmodified, in the +// official client. +// +// SWISSCODE NEVER TOUCHES THE OAUTH FLOW. It does not open a browser, does not +// hold a code, does not see a token. It creates an empty directory and execve's +// the real binary at it. Everything after that is between you and Anthropic — +// which is both the honest architecture and the reason this is a launcher +// rather than a credential manager. + +import { existsSync, mkdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' +import { describeIdentity, readSessionIdentity } from './identity.ts' +import { isDefaultConfigDir } from '../agents/claude-code/env.ts' +import type { ConfigStorePort, ProviderAccount, State } from '../../ports/config-store.ts' +import type { AgentRegistryPort } from '../../ports/agent.ts' +import type { ProcessPort } from '../../ports/process.ts' + +type Emit = (line: string) => void + +export type LoginOptions = { + /** account name, as it will appear in config and in `swisscode config accounts` */ + name: string | undefined + /** `--dir `: adopt an existing directory instead of making one */ + dir?: string | undefined + /** `--provider `, defaulting to anthropic — the only one with this flow today */ + provider?: string | undefined + store: ConfigStorePort + agents: AgentRegistryPort + proc: ProcessPort + out: Emit + err: Emit +} + +/** + * Where swisscode keeps the session directories it makes. + * + * Beside `config.json`, under the config directory rather than the state + * directory: unlike a rotation cursor, a session directory is NOT regenerable. + * It holds a login. Losing it costs a `/login` per account, and it belongs + * wherever the user's backups already point. + */ +export function accountsDir(env: Record = process.env): string { + return join( + env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), + 'swisscode', + 'accounts', + ) +} + +/** + * Names that may become a directory. + * + * Stricter than the profile-name grammar on purpose: this string is + * concatenated into a filesystem path, so `..`, separators and leading dots are + * refused outright rather than sanitised. A rejected name is a typo the user + * fixes in one second; a sanitised one is a directory somewhere they did not + * expect. + */ +export function validateAccountName(name: string): { ok: true } | { ok: false; reason: string } { + if (!name.trim()) return { ok: false, reason: 'an account needs a name.' } + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) || name.includes('..')) { + return { + ok: false, + reason: + `"${name}" cannot be used as an account name. Use letters, digits, dot, dash or ` + + 'underscore, starting with a letter or digit — the name becomes a directory.', + } + } + return { ok: true } +} + +/** @returns the process exit code, or does not return at all (execve). */ +export function accountLogin({ + name, + dir, + provider = 'anthropic', + store, + agents, + proc, + out, + err, +}: LoginOptions): number { + if (name === undefined) { + err('swisscode: `config accounts login ` needs a name, e.g. `personal`.') + return 2 + } + const verdict = validateAccountName(name) + if (!verdict.ok) { + err(`swisscode: ${verdict.reason}`) + return 2 + } + + const loaded = store.load() + if (loaded.readOnly) { + err('swisscode: the config file is not writable, so a new account cannot be recorded.') + return 2 + } + const state = loaded.state + const existing = state.providerAccounts?.[name] + + // An adopted directory must be absolute: this process execve's away and the + // agent inherits the cwd, so a relative path would mean something different + // depending on where it is later launched from. + const target = dir + ? isAbsolute(dir) + ? resolve(dir) + : resolve(proc.cwd(), dir) + : join(accountsDir(proc.env()), name) + + // Re-login into an account that already exists is a legitimate thing to want + // (an expired refresh token, a wrong account picked the first time), so this + // is not an error — but silently retargeting an existing account at a + // DIFFERENT directory would abandon a login without saying so. + if (existing?.configDir && resolve(existing.configDir) !== target) { + err( + `swisscode: account "${name}" already uses ${existing.configDir}. Delete it first, or ` + + 'pass `--dir` with that same path to log in again.', + ) + return 2 + } + if (existing && !existing.configDir) { + err( + `swisscode: account "${name}" already authenticates with an API key. An account uses a ` + + 'key or a subscription login, never both — pick another name.', + ) + return 2 + } + + try { + // 0700 because this directory will hold a login. `recursive` also creates + // the parent `accounts/`, and mode applies to every level it creates. + mkdirSync(target, { recursive: true, mode: 0o700 }) + } catch (e) { + err(`swisscode: could not create ${target}: ${(e as { message?: string }).message ?? e}`) + return 2 + } + // An ADOPTED directory keeps whatever permissions it has — narrowing someone + // else's ~/.claude-work under their feet is not this command's business — but + // a permissive one earns a warning, since a login is about to live in it. + // + // NOT for the default directory. Claude Code creates `~/.claude` at 0755 + // itself, adopting it changes nothing about its exposure, and a warning that + // fires on a stock install for something swisscode neither made nor worsened + // is noise that teaches people to skip warnings. `config doctor` is where a + // pre-existing permissions problem belongs. + try { + const mode = statSync(target).mode & 0o777 + if (mode & 0o077 && !isDefaultConfigDir(target, proc.env())) { + err( + `swisscode: warning — ${target} is readable by other users (mode ${mode.toString(8)}). ` + + 'It is about to hold a login. `chmod 700` it.', + ) + } + } catch { + /* stat failing here is not worth failing a login over */ + } + + const account: ProviderAccount = { provider, configDir: target } + const next: State = { + ...state, + providerAccounts: { ...(state.providerAccounts ?? {}), [name]: account }, + } + try { + store.save(next) + } catch (e) { + err(`swisscode: could not record the account: ${(e as { message?: string }).message ?? e}`) + return 2 + } + + const env = proc.env() + const isDefault = isDefaultConfigDir(target, env) + const already = readSessionIdentity(target, { env }) + + if (isDefault && already) { + // The common first step: adopt the login you already have. Nothing to do, + // and nothing to launch — telling someone to `/login` into the account they + // are already using would be busywork that risks replacing it. + out(`Account "${name}" adopted your existing login: ${describeIdentity(already)}.`) + out(` ${target} (Claude Code's default directory)`) + out('') + out('Nothing else to do — this account is ready to use. Add a second one with') + out(` swisscode config accounts login `) + return 0 + } + if (already) { + out(`Account "${name}" already logged in as ${describeIdentity(already)}.`) + out(` ${target}`) + out('Run `/login` inside the session that starts next to switch it to another account.') + } else if (isDefault) { + // Naming the default directory when nobody has ever logged in there. + out(`Account "${name}" recorded, using Claude Code's default directory.`) + } else { + out(`Account "${name}" recorded, using ${target}.`) + } + + // Claude Code is the only agent with this flow — the login being adopted IS a + // Claude subscription — so this does not consult the agent profile. Kilo and + // OpenCode declare `sessionDir: false` for exactly this reason. + const agent = agents.byId('claude-code') + if (!agent) { + err('swisscode: the Claude Code adapter is not in this build, so it cannot be launched.') + return 2 + } + + let bin: string + try { + bin = proc.resolveBinary(agent.binary) + } catch (e) { + // The account is already recorded, which is the useful half. Say so, rather + // than making the user wonder whether anything happened. + err(`swisscode: ${(e as { message?: string }).message ?? 'the Claude Code binary was not found'}`) + err(`swisscode: the account was still recorded. Install the CLI, then run \`/login\` with`) + err(`swisscode: CLAUDE_CONFIG_DIR=${target} claude`) + return 2 + } + + out('') + out('Starting Claude Code in that directory. Run `/login` inside it, then exit.') + out('') + + // Setting the variable to the default path would send the agent to a + // DIFFERENT credential than the one it uses when the variable is unset, so a + // login performed here would not be the login a plain `claude` finds. Same + // rule as the launch path; see `isDefaultConfigDir`. + if (isDefault) delete env.CLAUDE_CONFIG_DIR + else env.CLAUDE_CONFIG_DIR = target + // The same both-variables rule the launch path enforces, for the same reason: + // either one present would authenticate the login flow as somebody else and + // the `/login` would appear to do nothing. + delete env.ANTHROPIC_API_KEY + delete env.ANTHROPIC_AUTH_TOKEN + + proc.replace(bin, [bin], env) + // Reached only on the spawn fallback (Node < 23.11), where `replace` relays + // the child's exit itself. + return 0 +} + +/** + * `config accounts login` needs to know whether a directory has ever been used, + * which is a filesystem question the listing also asks. Shared here so both + * surfaces answer it the same way. + */ +export const dirExists = (p: string): boolean => existsSync(p) diff --git a/src/adapters/claude-session/swap.ts b/src/adapters/claude-session/swap.ts new file mode 100644 index 0000000..393335e --- /dev/null +++ b/src/adapters/claude-session/swap.ts @@ -0,0 +1,220 @@ +// Moving one account's login into one session directory's slot. +// +// THIS IS THE ONLY MODULE IN SWISSCODE THAT WRITES A CREDENTIAL. It is separate +// from `credentials.ts` on purpose: that module's header promises it never +// writes, and that promise is worth more than the two imports saved by merging +// them. Nothing on the read path can damage a login, and that stays true by +// construction rather than by care. +// +// WHAT THIS IS. `/login` writes into ONE GLOBAL SLOT, and every running Claude +// Code re-reads it within 30s — so switching accounts for one terminal switches +// all of them, and an artifact created after the switch lands on whichever +// account happened to win. This writes into ONE directory's slot instead. +// Sessions on other directories are untouched. That is the entire difference, +// and it is why this exists. +// +// THE BLOB IS OPAQUE HERE, and that is a security property rather than +// laziness. `SessionCredential` deliberately drops `refreshToken`, because +// nothing in swisscode may refresh — but a swap that moved only the access +// token would hand the target a login that dies at the next refresh, hours +// later, far from the command that caused it. So this reads and writes the +// stored bytes verbatim, never parsing them into a shape that could be logged, +// inspected or accidentally narrowed. +// +// OFF THE LAUNCH PATH, like the rest of adapters/claude-session. + +import { execFileSync } from 'node:child_process' +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { credentialFilePath, keychainService } from './credentials.ts' +import { configFilePath } from './identity.ts' + +type ReadableEnv = Record + +export type SwapResult = + | { ok: true; source: 'keychain' | 'file'; wroteIdentity: boolean } + | { ok: false; reason: string } + +/** + * Read the stored credential blob verbatim. + * + * Returns the bytes, not a parsed credential — see the note at the top of this + * file. Null means "nothing stored here", which is a normal state. + */ +export function readRawCredential( + configDir: string, + { + env = process.env, + platform = process.platform, + exec = execFileSync, + readFile = (p: string) => readFileSync(p, 'utf8'), + }: { + env?: ReadableEnv + platform?: NodeJS.Platform + exec?: typeof execFileSync + readFile?: (path: string) => string + } = {}, +): { blob: string; source: 'keychain' | 'file' } | null { + if (platform === 'darwin') { + try { + const blob = exec( + '/usr/bin/security', + ['find-generic-password', '-s', keychainService(configDir, env), '-w'], + { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'ignore'] }, + ) as string + if (blob.trim()) return { blob: blob.trim(), source: 'keychain' } + } catch { + /* not found, or the prompt was dismissed; try the file */ + } + } + try { + const blob = readFile(credentialFilePath(configDir)) + if (blob.trim()) return { blob: blob.trim(), source: 'file' } + } catch { + /* nothing stored */ + } + return null +} + +/** + * Remove a session directory's Keychain item, if it has one. + * + * NOT A CLEANUP STEP — it is what makes the file below authoritative. After a + * swap there must be exactly ONE stored credential for the target directory, + * and it must be the one just written. Leaving a stale Keychain item next to a + * fresh file means the answer to "which login does this directory use?" depends + * on a precedence rule inside someone else's binary, which is precisely the + * class of ambiguity this whole feature exists to remove. + * + * The service name is not a secret, so argv is fine here. Missing item (exit + * 44) is a normal, successful outcome. + */ +function dropKeychainItem(service: string, exec: typeof execFileSync): void { + try { + exec('/usr/bin/security', ['delete-generic-password', '-s', service], { + timeout: 20_000, + stdio: ['ignore', 'ignore', 'ignore'], + }) + } catch { + /* nothing stored under that name, which is the common case */ + } +} + +/** + * Copy the `oauthAccount` block so the target reports the right identity. + * + * WITHOUT THIS THE SWAP IS SILENTLY HALF-DONE: the token would be the new + * account's while `/status` and every "logged in as" readout still named the + * old one. A tool whose whole purpose is ending silently-wrong-account + * confusion cannot ship the same confusion in a new place. + * + * Merges into the existing file rather than replacing it — `.claude.json` holds + * project history, MCP servers and onboarding state that have nothing to do + * with which account pays, and are not ours to discard. + */ +function copyIdentity(fromDir: string, intoDir: string, env: ReadableEnv): boolean { + let oauthAccount: unknown + try { + const parsed = JSON.parse(readFileSync(configFilePath(fromDir, env), 'utf8')) as { + oauthAccount?: unknown + } + oauthAccount = parsed.oauthAccount + } catch { + return false + } + if (!oauthAccount || typeof oauthAccount !== 'object') return false + + const target = configFilePath(intoDir, env) + let existing: Record = {} + try { + const parsed: unknown = JSON.parse(readFileSync(target, 'utf8')) + if (parsed && typeof parsed === 'object') existing = parsed as Record + } catch { + /* a directory that has never been used has no file yet; create one */ + } + try { + writeFileSync(target, `${JSON.stringify({ ...existing, oauthAccount }, null, 2)}\n`, { + mode: 0o600, + }) + return true + } catch { + return false + } +} + +export type SwapOptions = { + env?: ReadableEnv + platform?: NodeJS.Platform + exec?: typeof execFileSync +} + +/** + * Write `from`'s login into `into`'s slot. + * + * Both arguments are DIRECTORIES, not account names: this module knows about + * session directories and credentials, and resolving a user's word for an + * account into a path is the composition root's job. + */ +export function swapCredential( + fromDir: string, + intoDir: string, + { env = process.env, platform = process.platform, exec = execFileSync }: SwapOptions = {}, +): SwapResult { + if (resolve(fromDir) === resolve(intoDir)) { + return { ok: false, reason: 'the source and target are the same directory — nothing to do' } + } + + const found = readRawCredential(fromDir, { env, platform, exec }) + if (!found) { + return { + ok: false, + reason: + `no login is stored for ${fromDir}. Run \`swisscode config accounts login\` for that ` + + 'account and complete `/login` inside it first.', + } + } + + // The target directory must exist before anything is written into it — a + // swap into a path that is not there yet is a reasonable thing to ask for. + try { + if (!existsSync(intoDir)) mkdirSync(intoDir, { recursive: true, mode: 0o700 }) + } catch (e) { + return { ok: false, reason: `could not create ${intoDir}: ${(e as Error).message}` } + } + + // THE CREDENTIAL IS WRITTEN AS A FILE, ON EVERY PLATFORM INCLUDING MACOS. + // + // Not the obvious choice, and it was measured rather than reasoned. Writing + // the Keychain needs `/usr/bin/security add-generic-password`, and its two + // ways of taking a secret are both unusable here: + // + // -w puts the token in argv, where `ps` shows it to every user on + // the machine for the life of the process. + // -w (stdin) prompts, and TRUNCATES AT 128 BYTES. Verified: 500 bytes in, + // 128 bytes stored, exit 0, no warning. A real credential is + // ~3.9 kB, so this silently stores a corrupt fragment — the + // first draft of this module shipped exactly that, and the + // unit tests passed because a fake `security` has no buffer. + // + // The file is neither. It is 0600 in a 0700 directory, it holds the blob + // whole, and it is a path Claude Code already reads — verified on macOS by + // pointing the agent at a directory containing only this file, which + // authenticated, against an empty directory, which printed "Not logged in". + try { + const path = credentialFilePath(intoDir) + writeFileSync(path, `${found.blob}\n`, { mode: 0o600 }) + // Explicit, because `mode` only applies when the file is CREATED. Swapping + // twice into the same directory would otherwise keep whatever mode the + // first write happened to land on. + chmodSync(path, 0o600) + } catch (e) { + return { ok: false, reason: `could not write the credential file: ${(e as Error).message}` } + } + + // Only now, once the new credential is safely on disk: remove the item that + // would otherwise compete with it. Doing this first would leave a directory + // with no login at all if the write then failed. + if (platform === 'darwin') dropKeychainItem(keychainService(intoDir, env), exec) + + return { ok: true, source: found.source, wroteIdentity: copyIdentity(fromDir, intoDir, env) } +} diff --git a/src/adapters/providers/composite.ts b/src/adapters/providers/composite.ts new file mode 100644 index 0000000..4cd1a29 --- /dev/null +++ b/src/adapters/providers/composite.ts @@ -0,0 +1,92 @@ +// A ProviderRegistryPort over the shipped presets PLUS the user's own. +// +// This is the hexagon doing what it was drawn for. Nothing in core/ changes, +// no consumer changes, and neither the launch path nor the doctor learns that +// providers can now come from a config file — they all still ask a +// `ProviderRegistryPort` for a `ProviderDescriptor` and get one. Swapping the +// implementation behind the port is the whole mechanism. + +import { PROVIDERS } from './registry.ts' +import type { CustomProvider, State } from '../../ports/config-store.ts' +import type { ProviderDescriptor, ProviderRegistryPort } from '../../ports/provider.ts' + +/** + * A stored provider, as a descriptor. + * + * `extendedContext` and `catalogId` are pinned here rather than merely omitted. + * Leaving them absent would let a future default supply them behind the user's + * back; stating them says the capability is genuinely absent, which is the + * fact — nothing verified those claims for a hand-entered endpoint. + */ +export function toDescriptor(custom: CustomProvider): ProviderDescriptor { + const descriptor: ProviderDescriptor = { + id: custom.id, + label: custom.label, + baseUrl: custom.baseUrl, + credentialEnv: custom.credentialEnv ?? 'ANTHROPIC_AUTH_TOKEN', + defaultModels: custom.defaultModels ?? {}, + catalogId: null, + } + // Assigned conditionally, never as `undefined`: exactOptionalPropertyTypes + // makes "absent" and "present but undefined" different types, and the + // env-builder branches on presence. + if (custom.credentialOptional !== undefined) descriptor.credentialOptional = custom.credentialOptional + if (custom.defaultCredential !== undefined) descriptor.defaultCredential = custom.defaultCredential + if (custom.subagentFollowsOpus !== undefined) { + descriptor.subagentFollowsOpus = custom.subagentFollowsOpus + } + if (custom.env) descriptor.env = custom.env + if (custom.unsetEnv) descriptor.unsetEnv = custom.unsetEnv + if (custom.compat) descriptor.compat = custom.compat + return descriptor +} + +/** The shipped ids, which a user provider may not shadow. */ +export const RESERVED_PROVIDER_IDS: readonly string[] = Object.freeze(PROVIDERS.map((p) => p.id)) + +/** + * Wrap a registry so it also serves the state's custom providers. + * + * Takes a BASE registry rather than reaching for the shipped constant, which is + * what keeps it composable: every consumer already receives a + * `ProviderRegistryPort` through `LaunchDeps`, and tests inject fakes there. A + * version that hard-coded `PROVIDERS` would quietly ignore the fake and make + * those tests assert against the wrong registry. + * + * BASE WINS, unconditionally. A stored provider claiming a base id is dropped + * rather than merged or preferred — validation refuses to create one, so + * reaching this branch means a hand-edited or hostile config file, and the safe + * reading of "openrouter now points somewhere else" is an attempt to redirect a + * credential to a host it was not entered for. + * + * Order puts the user's own first: they are what a picker iterates, and a list + * that buries them under eight presets is a worse list. + */ +export function withCustomProviders( + base: ProviderRegistryPort, + state: State | null | undefined, +): ProviderRegistryPort { + const reserved = new Set(base.all().map((p) => p.id)) + const custom: ProviderDescriptor[] = [] + + for (const [key, value] of Object.entries(state?.providers ?? {})) { + if (!value || typeof value !== 'object') continue + // The map KEY is authoritative. A record whose inner `id` disagrees with + // the key it is filed under would resolve differently depending on which + // one a caller happened to use. + const descriptor = toDescriptor({ ...value, id: key }) + if (reserved.has(descriptor.id)) continue + custom.push(descriptor) + } + + if (custom.length === 0) return base + + const all: readonly ProviderDescriptor[] = Object.freeze([...custom, ...base.all()]) + + return Object.freeze({ + all: () => all, + // Base lookup first, so a shadowing entry that somehow survived above still + // cannot win. + byId: (id: string | null | undefined) => base.byId(id) ?? custom.find((p) => p.id === id) ?? null, + }) satisfies ProviderRegistryPort +} diff --git a/src/adapters/providers/openrouter.ts b/src/adapters/providers/openrouter.ts index 57f56b5..8fab540 100644 --- a/src/adapters/providers/openrouter.ts +++ b/src/adapters/providers/openrouter.ts @@ -18,6 +18,9 @@ export const openrouter = { // Has a queryable catalog, so the wizard offers a browsable picker instead of // asking you to type model ids from memory. catalogId: 'openrouter', + // Verified against the live service: GET {baseUrl}/v1/key reports + // limit / limit_remaining / usage. See adapters/usage/openrouter.ts. + usageId: 'openrouter', // OpenRouter has no notion of the opus/sonnet/haiku tiers, so subagents need // to be pinned explicitly or they fall back to a model that 404s. subagentFollowsOpus: true, diff --git a/src/adapters/store/fs-config-store.ts b/src/adapters/store/fs-config-store.ts index 26fcc07..0d12836 100644 --- a/src/adapters/store/fs-config-store.ts +++ b/src/adapters/store/fs-config-store.ts @@ -8,6 +8,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs' +import { createHash } from 'node:crypto' import { homedir } from 'node:os' import { join } from 'node:path' import type { ConfigModes, ConfigStorePort, LoadResult, State } from '../../ports/config-store.ts' @@ -116,8 +117,16 @@ export function createFsConfigStore({ sawCorrupt = false } - function backupV1() { - const backup = join(CONFIG_DIR, 'config.v1.bak.json') + /** + * Snapshot the file BEFORE a migration overwrites it, named for the version + * it preserves. + * + * Parameterised since v3: the ladder now migrates from 1 OR 2, and a v2 file + * kept as `config.v1.bak.json` would misdescribe itself to anyone reaching + * for it after a bad upgrade — which is the one moment the name matters. + */ + function backupPrevious(fromVersion: number) { + const backup = join(CONFIG_DIR, `config.v${fromVersion}.bak.json`) try { // 'wx' so a second run never clobbers the original snapshot. writeFileSync(backup, readFileSync(CONFIG_PATH, 'utf8'), { flag: 'wx', mode: 0o600 }) @@ -161,11 +170,11 @@ export function createFsConfigStore({ if (result.migratedFrom !== null && !readOnly) { try { ensureDir() - backupV1() + backupPrevious(result.migratedFrom) writeAtomic(CONFIG_PATH, `${JSON.stringify(result.state, null, 2)}\n`) warnings.push( - `migrated config.json to the v${SUPPORTED_VERSION} profile format ` + - `(previous file kept as config.v1.bak.json).`, + `migrated config.json to the v${SUPPORTED_VERSION} format ` + + `(previous file kept as config.v${result.migratedFrom}.bak.json).`, ) } catch (err) { warnings.push( @@ -211,5 +220,24 @@ export function createFsConfigStore({ return out } - return { load, save, path: () => CONFIG_PATH, dir: () => CONFIG_DIR, modes } + /** + * Content hash, not mtime. mtime has coarse and platform-dependent + * granularity, so two writes inside the same tick can share a timestamp and a + * lost update would slip through exactly when writers are most concurrent. + * Hashing the bytes cannot have that failure. The file is a few KB, so the + * cost is irrelevant next to being wrong. + * + * Null when there is no file yet — which is itself a meaningful revision: a + * caller that read "no config" and then saves must still be told if someone + * created one in the meantime. + */ + function revision(): string | null { + try { + return createHash('sha256').update(readFileSync(CONFIG_PATH)).digest('hex').slice(0, 32) + } catch { + return null + } + } + + return { load, save, path: () => CONFIG_PATH, dir: () => CONFIG_DIR, modes, revision } } diff --git a/src/adapters/store/fs-cursor-store.ts b/src/adapters/store/fs-cursor-store.ts new file mode 100644 index 0000000..a2c04c8 --- /dev/null +++ b/src/adapters/store/fs-cursor-store.ts @@ -0,0 +1,100 @@ +// Where a round-robin cursor is remembered between launches. +// +// DELIBERATELY NOT config.json, and deliberately not the config store. The +// launch path writes no config — `test/core/overrides.test.ts` asserts zero +// `store.save` calls across the whole override matrix — and a rotation counter +// is not configuration: nobody edits it, nobody backs it up, and losing it +// costs one repeated account rather than a broken setup. +// +// So it lives in the STATE directory, which is what XDG has for exactly this: +// data a program regenerates without complaint. Keeping it out of the config +// directory also keeps `config.json` free of a field that would churn on every +// launch and show up in every diff a user pastes into a bug report. + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { CursorPort } from '../../core/resolve.ts' + +type ReadableEnv = Record + +/** + * `$XDG_STATE_HOME/swisscode`, or `~/.local/state/swisscode`. + * + * The state spec's fallback is `~/.local/state` on every platform this ships + * for. macOS has no XDG convention of its own and the config store already + * resolves `~/.config` there rather than `~/Library`, so following the same + * rule keeps a user's two swisscode directories siblings instead of scattering + * them by platform. + */ +export function stateDir(env: ReadableEnv = process.env): string { + return join( + env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state'), + 'swisscode', + ) +} + +export type FsCursorStoreOptions = { + env?: ReadableEnv + dir?: string | null +} + +/** + * Cursors for every profile, in one small JSON file. + * + * EVERY OPERATION IS BEST EFFORT. A read that fails yields null, which + * `selectAccount` treats as "start at the beginning" — a normal state, not an + * error. A write that fails is swallowed entirely: the launch has already been + * decided by the time it happens, and failing a launch because a counter could + * not be persisted would trade a working session for a tidy file. + * + * The consequence is stated rather than hidden: if the directory is unwritable, + * rotation silently stops advancing and every launch uses the same account. + * That is visible in the profile banner, which names the account. + */ +export function createFsCursorStore({ + env = process.env, + dir = null, +}: FsCursorStoreOptions = {}): CursorPort { + const DIR = dir ?? stateDir(env) + const PATH = join(DIR, 'cursors.json') + + function readAll(): Record { + try { + const parsed: unknown = JSON.parse(readFileSync(PATH, 'utf8')) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + // Absent, unreadable, or not JSON. All three mean "no cursor yet". + return {} + } + } + + return { + read(profileName: string): number | null { + const value = readAll()[profileName] + // A hand-edited or corrupted entry must not index an array out of range, + // so anything that is not a non-negative integer restarts the rotation. + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null + }, + + advance(profileName: string, next: number): void { + try { + // 0700: the file names PROFILES, which are user-chosen and can carry + // client names, exactly like the binding paths SECURITY.md already + // flags. It holds no credential, but it is nobody else's business. + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + const all = readAll() + all[profileName] = next + // Not atomic, and it does not need to be: the worst outcome of a torn + // write is an unparseable file, which `readAll` treats as "no cursor" + // and the next launch overwrites. Compare the config store, where a + // torn write would destroy an API key and every write is atomic. + writeFileSync(PATH, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 }) + } catch { + /* rotation stops advancing; the launch already succeeded */ + } + }, + } +} diff --git a/src/adapters/store/fs-usage-store.ts b/src/adapters/store/fs-usage-store.ts new file mode 100644 index 0000000..12675c5 --- /dev/null +++ b/src/adapters/store/fs-usage-store.ts @@ -0,0 +1,96 @@ +// The measured-usage snapshot, between runs. +// +// THIS CLOSES A LOOP THAT WAS OPEN. `core/resolve.ts` has read a `UsageSnapshot` +// since the v3 split, but nothing ever wrote one — so the `usage` strategy +// always took its documented fallback and always warned. That was honest, and +// useless. This is the writer, and it fixes OpenRouter's `usage` selection at +// the same time as enabling the subscription one, because the gap was never +// provider-specific. +// +// In the STATE directory beside the rotation cursor, and for the same reason: a +// measurement is regenerable. Losing it costs one refresh, not a broken setup. +// It is also stale by nature — nothing here pretends otherwise, and `checkedAt` +// travels with the numbers so every consumer can say how old they are. +// +// WRITTEN AT CONFIGURATION TIME ONLY — by the doctor and the web UI. The launch +// path reads it and never refreshes it, because refreshing means the network. + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { stateDir } from './fs-cursor-store.ts' +import type { UsageSnapshot } from '../../core/resolve.ts' + +type ReadableEnv = Record + +export type UsageStorePort = { + /** null when nothing has been measured, or the file is unusable */ + read: () => UsageSnapshot | null + /** best effort; a failed write must never fail the command that triggered it */ + write: (snapshot: UsageSnapshot) => void +} + +export type FsUsageStoreOptions = { + env?: ReadableEnv + dir?: string | null +} + +/** + * How long a snapshot is worth reading. + * + * Twelve hours. A 5-hour subscription window means a figure older than that + * describes a window that has since rolled over — routing on it would be + * routing on a number that is not merely stale but about a different period. + * Expired snapshots read as ABSENT rather than as zero, so selection falls back + * to the first account and says so, which is the behaviour that was already + * there and already tested. + */ +export const SNAPSHOT_TTL_MS = 12 * 60 * 60 * 1000 + +export function createFsUsageStore({ + env = process.env, + dir = null, +}: FsUsageStoreOptions = {}): UsageStorePort { + const DIR = dir ?? stateDir(env) + const PATH = join(DIR, 'usage.json') + + return { + read(): UsageSnapshot | null { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(PATH, 'utf8')) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') return null + const o = parsed as { remaining?: unknown; checkedAt?: unknown } + if (typeof o.checkedAt !== 'number' || !Number.isFinite(o.checkedAt)) return null + if (Date.now() - o.checkedAt > SNAPSHOT_TTL_MS) return null + if (!o.remaining || typeof o.remaining !== 'object' || Array.isArray(o.remaining)) return null + + // Filter rather than trust: a hand-edited or half-written file must not + // put a NaN or a string into the comparison that decides which account + // pays. Anything that is not a finite number is simply not a candidate. + const remaining: Record = {} + for (const [name, value] of Object.entries(o.remaining as Record)) { + if (typeof value === 'number' && Number.isFinite(value)) remaining[name] = value + } + return Object.keys(remaining).length > 0 + ? { remaining, checkedAt: o.checkedAt } + : null + }, + + write(snapshot: UsageSnapshot): void { + try { + // 0700/0600: the file names ACCOUNTS and how much of each is left. No + // credential, but it is a map of what you pay for and it is nobody + // else's business — the same reasoning the cursor file carries. + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + writeFileSync(PATH, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) + } catch { + // The measurement already happened and was already displayed. Failing + // the doctor because a cache could not be written would trade a working + // diagnosis for a tidy filesystem. + } + }, + } +} diff --git a/src/adapters/ui/ProfilePicker.tsx b/src/adapters/ui/ProfilePicker.tsx index bd9023c..b8f4cb8 100644 --- a/src/adapters/ui/ProfilePicker.tsx +++ b/src/adapters/ui/ProfilePicker.tsx @@ -2,19 +2,32 @@ import React from 'react' import { Box, Text } from 'ink' import SelectInput from 'ink-select-input' import { TIERS } from '../../core/tiers.ts' -import type { Profile, State } from '../../ports/config-store.ts' +import { resolveProfileRefs } from '../../core/resolve.ts' +import type { ResolvedProfile, State } from '../../ports/config-store.ts' /** * Presence and ORIGIN of the credential, never any part of the value. * A masked key still leaks its length, and this screen gets screen-shared. */ -function credentialLabel(profile: Profile | undefined): string { +function credentialLabel(profile: ResolvedProfile | undefined): string { if (profile?.apiKeyFromEnv) return `key from $${profile.apiKeyFromEnv}` if (profile?.apiKey) return 'key stored' return 'no key' } -function summarize(profile: Profile | undefined, boundPaths: number): string { +/** + * The flattened view of a profile, or undefined when it cannot be resolved. + * + * A profile with a dangling reference still LISTS — it summarises as blank + * rather than vanishing, because this screen is how you reach the editor that + * repairs it. + */ +function resolvedOrUndefined(state: State, name: string): ResolvedProfile | undefined { + const r = resolveProfileRefs(state, name) + return r.ok ? r.resolved : undefined +} + +function summarize(profile: ResolvedProfile | undefined, boundPaths: number): string { const models = TIERS.map((t) => profile?.models?.[t]).filter(Boolean) const distinct = [...new Set(models)] const modelLabel = @@ -65,7 +78,9 @@ export function ProfilePicker({ state, onPick, onNew }: ProfilePickerProps) { {name} - {summarize(state.profiles[name], bindingCounts.get(name) ?? 0)} + + {summarize(resolvedOrUndefined(state, name), bindingCounts.get(name) ?? 0)} + ))} diff --git a/src/adapters/ui/index.tsx b/src/adapters/ui/index.tsx index 5f74013..d2412c2 100644 --- a/src/adapters/ui/index.tsx +++ b/src/adapters/ui/index.tsx @@ -12,11 +12,19 @@ import { createFsCacheStore } from '../store/fs-cache-store.ts' import { createCatalogRegistry } from '../catalog/registry.ts' import { fetchNet } from '../net/fetch-net.ts' import { systemClock } from '../clock/system-clock.ts' +import { resolveProfileRefs } from '../../core/resolve.ts' import { ModelPicker } from './ModelPicker.tsx' import { ConfirmDelete, ProfileActions, ProfilePicker } from './ProfilePicker.tsx' import type { ProfileAction } from './ProfilePicker.tsx' import type { Tier, TierRecord, ProviderDescriptor, ProviderRegistryPort } from '../../ports/provider.ts' -import type { Profile, State, ConfigStorePort } from '../../ports/config-store.ts' +import type { + AgentProfile, + ConfigStorePort, + Profile, + ProviderAccount, + ResolvedProfile, + State, +} from '../../ports/config-store.ts' import type { CatalogRegistryPort } from '../../ports/catalog.ts' export { ModelPicker, ProfilePicker } @@ -137,7 +145,12 @@ export type AppProps = { * here: undefined means "nothing was supplied, work it out", null means * "explicitly start blank". The component branches on `initial !== undefined`. */ - initial?: Profile | null | undefined + /** + * A pre-filled form, used by the tests that drive the wizard directly. + * RESOLVED shape, because that is what the form edits — the wizard mints the + * three stored objects at save time, not before. + */ + initial?: ResolvedProfile | null | undefined profileName?: string | null | undefined /** called exactly once, with the saved profile or null if cancelled */ onResult: (profile: Profile | null) => void @@ -207,12 +220,14 @@ export function App({ // A user with a profile named `null` and no default has that profile silently // loaded here, while `editingName` stays null and finish() saves under a // DIFFERENT, derived name. - const startProfile = - profileName !== null - ? (loadedState.profiles?.[profileName] ?? null) - : initial !== undefined - ? initial - : (loadedState.profiles?.[openDirectly as string] ?? null) + // Resolved, not stored: the form is a view of the flattened account + agent + // profile, so this reads through the same resolution the launch path uses. + const startProfileName = + profileName !== null ? profileName : initial !== undefined ? null : (openDirectly ?? null) + const startResolution = + startProfileName !== null ? resolveProfileRefs(loadedState, startProfileName) : null + const startProfile: ResolvedProfile | null = + startResolution?.ok ? startResolution.resolved : (initial ?? null) // More than one profile and no particular one named: choose first. With // exactly one, open it directly — that is the pre-profiles behaviour and @@ -288,7 +303,12 @@ export function App({ } const loadProfileIntoForm = (name: string) => { - const p = doc.profiles?.[name] ?? null + // Read through the RESOLVED view: since v3 a profile is three objects, and + // the wizard's form is a view of the flattened one. Anything unresolvable + // (a dangling reference) loads as blank rather than throwing, so the wizard + // stays the tool you use to REPAIR a broken profile. + const r = resolveProfileRefs(doc, name) + const p = r.ok ? r.resolved : null setEditingName(name) setProviderId(p?.provider ?? null) setBaseUrl(p?.baseUrl ?? '') @@ -372,7 +392,8 @@ export function App({ // start from that provider's defaults. Compared against the profile // currently loaded in the form, which is not necessarily the one this // wizard opened with. - const stored = editingName ? doc.profiles?.[editingName] : startProfile + const storedResolution = editingName ? resolveProfileRefs(doc, editingName) : null + const stored = storedResolution?.ok ? storedResolution.resolved : startProfile // `byId(id)!` — `id` is the `value` of an item built from `registry.all()` // a few lines below, so it always names a shipped descriptor. This is the // one lookup in the file that genuinely cannot miss; everywhere else byId @@ -400,10 +421,17 @@ export function App({ // STEP-MACHINE INVARIANT (`providerId!`, `provider!`): finish() runs only // from the permissions screen, which is reachable only via the models step, // which is reachable only via chooseProvider(). Both are set. - const profile: Profile = { + // v3 mints THREE objects, all sharing this profile's name — the same 1:1:1 + // shape the v2 migration produces. The wizard deliberately does not express + // multi-account profiles: rotating between accounts is a thing you set up + // once, in the web UI or `config accounts`, not something a first-run + // terminal flow should ask everyone about. + const account: ProviderAccount = { provider: providerId!, ...(provider!.askBaseUrl ? { baseUrl: baseUrl.trim() } : {}), apiKey: apiKey.trim(), + } + const agentProfile: AgentProfile = { models, ...(Object.keys(keptWindows).length > 0 ? { contextWindows: keptWindows } : {}), skipPermissions, @@ -411,8 +439,21 @@ export function App({ // An explicitly named profile keeps its name; a first run derives one from // the provider, exactly as before profiles existed. const name = editingName ?? profileNameFor(doc, providerId) - const next = { + const profile: Profile = { + agentProfile: name, + accounts: [name], + strategy: 'single', + } + // The existing agent selection is preserved: `config agent` writes to the + // agent profile, and re-running the wizard must not silently reset a + // profile back to Claude Code. + const existingAgent = doc.agentProfiles?.[name]?.agent + if (existingAgent !== undefined) agentProfile.agent = existingAgent + + const next: State = { ...doc, + providerAccounts: { ...(doc.providerAccounts ?? {}), [name]: account }, + agentProfiles: { ...(doc.agentProfiles ?? {}), [name]: agentProfile }, profiles: { ...(doc.profiles ?? {}), [name]: profile }, defaultProfile: doc.defaultProfile ?? name, } @@ -718,7 +759,12 @@ export function App({ export type RunUiOptions = { mode?: AppMode | undefined state?: State | null | undefined - initial?: Profile | null | undefined + /** + * A pre-filled form, used by the tests that drive the wizard directly. + * RESOLVED shape, because that is what the form edits — the wizard mints the + * three stored objects at save time, not before. + */ + initial?: ResolvedProfile | null | undefined profileName?: string | null | undefined } diff --git a/src/adapters/usage/anthropic-subscription.ts b/src/adapters/usage/anthropic-subscription.ts new file mode 100644 index 0000000..a5ce617 --- /dev/null +++ b/src/adapters/usage/anthropic-subscription.ts @@ -0,0 +1,176 @@ +// How much of a Claude subscription is left. +// +// This is the adapter that makes `/usage` unnecessary: the same figure, for +// EVERY account at once, without switching into each one to look. +// +// CONFIGURATION-TIME ONLY, like every usage adapter — `test/architecture.test.ts` +// keeps `adapters/usage` off the launch path by name. `usage` selection reads +// the cached snapshot this writes; it never reaches the network itself. + +import { readSessionCredential } from '../claude-session/credentials.ts' +import type { ProviderUsage, ProviderUsagePort, SubscriptionWindow } from '../../ports/provider-usage.ts' + +/** + * The endpoint, and the header that makes it answer. + * + * UNDOCUMENTED. It is what the official client calls, and the beta header is + * not optional — without it the request is refused. Stated plainly here rather + * than buried, because an undocumented endpoint can change and the failure + * should read as "Anthropic changed something", not as "your account is + * broken". Every parse below degrades to null for exactly that reason. + */ +const USAGE_PATH = '/api/oauth/usage' +const OAUTH_BETA = 'oauth-2025-04-20' + +/** The windows a subscription is actually limited by. */ +export type SubscriptionUsage = ProviderUsage & { + fiveHour: SubscriptionWindow + sevenDay: SubscriptionWindow + /** + * Per-model weekly windows, REPORTED BUT NOT RANKED ON. + * + * Both were null on the live Max 20x account this was written against, so + * they are not universally populated — which is itself the reason they are + * kept: a null window must stay null rather than being read as "unlimited" + * or as zero. + * + * They stay out of `remaining` because an exhausted Opus window should not + * disqualify an account for a profile that runs Sonnet. Ranking that + * accurately means knowing which model the launch will use, which is a + * profile-level fact this adapter does not have and should not guess at. + */ + sevenDayOpus: SubscriptionWindow + sevenDaySonnet: SubscriptionWindow + /** true when the account may spend past the window rather than being cut off */ + extraUsage: boolean | null +} + +const num = (v: unknown): number | null => + typeof v === 'number' && Number.isFinite(v) ? v : null +const text = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null) + +/** One `{utilization, resets_at}` block, tolerant of an absent one. */ +function window(raw: unknown): SubscriptionWindow { + if (!raw || typeof raw !== 'object') return { utilization: null, resetsAt: null } + const o = raw as Record + return { utilization: num(o.utilization), resetsAt: text(o.resets_at) } +} + +/** + * Rank on the window that is FURTHEST ALONG, not on an average of the two. + * + * The limits bite independently: an account at 10% of its 5-hour window and 95% + * of its weekly one has almost nothing left, and averaging to 52% would send + * work straight at it. Taking the worse window is the only reading that matches + * how it actually fails. + */ +export function remainingFrom(fiveHour: SubscriptionWindow, sevenDay: SubscriptionWindow): number | null { + const worst = Math.max(fiveHour.utilization ?? -1, sevenDay.utilization ?? -1) + // Neither window published a figure: unknown, NOT full. An account that + // reports nothing must not out-rank one that honestly reported 5% used. + if (worst < 0) return null + return Math.max(0, 100 - worst) +} + +/** + * The subscription-shaped read, with the two windows still attached. + * + * Exported separately from the port implementation below because + * `ProviderUsagePort.fetch` is deliberately narrow — it returns the generic + * `ProviderUsage` that ranking needs — and a caller that wants to SHOW someone + * their 5-hour and 7-day windows would otherwise have to cast the result back + * to a type it already knows it has. + */ +export async function fetchSubscriptionUsage({ + baseUrl, + credential, + sessionDir = null, + timeoutMs = 8000, +}: { + baseUrl: string + credential: string + sessionDir?: string | null + timeoutMs?: number +}): Promise { +// A session account's token is read HERE, at the moment it is needed, and + // never held anywhere else. A key-mode Anthropic account has no + // subscription window at all — it bills per token — so it is not this + // adapter's business. + let token = '' + if (sessionDir) { + const found = readSessionCredential(sessionDir) + if (!found.ok) return null + // An EXPIRED token is reported as unknown rather than refreshed. Asking + // with it would return 401, which is indistinguishable from a revoked + // account; the honest answer is "we cannot say right now". + if (found.expired) return null + token = found.credential.accessToken + } else { + token = credential + } + if (!token) return null + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${baseUrl.replace(/\/+$/, '')}${USAGE_PATH}`, { + headers: { + authorization: `Bearer ${token}`, + 'anthropic-beta': OAUTH_BETA, + accept: 'application/json', + }, + signal: controller.signal, + }) + if (!response.ok) return null + const body: unknown = await response.json() + if (!body || typeof body !== 'object') return null + + const o = body as Record + const fiveHour = window(o.five_hour) + const sevenDay = window(o.seven_day) + const remaining = remainingFrom(fiveHour, sevenDay) + // The port's contract: null when the endpoint ANSWERED but published + // nothing usable. Returning a fully-null object instead would look like a + // successful measurement to every caller that checks for null — and would + // get written into the snapshot as a real reading of "unknown", which is + // not something a cache should carry. + if (remaining === null) return null + + const usage: SubscriptionUsage = { + remaining, + // A subscription window has no credit balance. `limit` is the + // percentage scale the figure lives on, and `used` its complement — + // stated rather than left null so the generic display has something + // true to show. Never null here: the early return above guarantees a + // figure by this point. + limit: 100, + used: 100 - remaining, + unit: 'percent of window remaining', + checkedAt: Date.now(), + fiveHour, + sevenDay, + sevenDayOpus: window(o.seven_day_opus), + sevenDaySonnet: window(o.seven_day_sonnet), + // `is_enabled`, MEASURED — not `enabled`, which is what the field + // looked like it should be called and what the first draft read. The + // difference is silent: the wrong name yields `null` forever, so the + // feature would simply never have reported extra usage for anyone. + extraUsage: + typeof (o.extra_usage as Record | undefined)?.is_enabled === 'boolean' + ? ((o.extra_usage as Record).is_enabled as boolean) + : null, + } + return usage + } catch { + // Down, rate-limited, timed out, or changed shape. A finding to report, + // never an exception to unwind a configuration screen with. + return null + } finally { + clearTimeout(timer) + } +} + +export const anthropicSubscriptionUsage: ProviderUsagePort = { + id: 'anthropic-subscription', + fetch: fetchSubscriptionUsage, +} diff --git a/src/adapters/usage/measure.ts b/src/adapters/usage/measure.ts new file mode 100644 index 0000000..c6c7dc1 --- /dev/null +++ b/src/adapters/usage/measure.ts @@ -0,0 +1,88 @@ +// Measuring every account once, in the one order that respects the Keychain. +// +// Shared by `config accounts usage` and `config doctor`. The loop below looks +// trivial enough to copy into both, which is exactly why it is not: the +// decisions in it — sequential rather than parallel, key accounts reported +// rather than failed, identity read once — are invisible in the shape of the +// code and would drift apart silently between two copies. +// +// CONFIGURATION-TIME ONLY, like everything under adapters/usage. +// `test/architecture.test.ts` keeps this directory off the launch path by name. + +import { readSessionIdentity } from '../claude-session/identity.ts' +import type { SessionIdentity } from '../claude-session/identity.ts' +import { fetchSubscriptionUsage } from './anthropic-subscription.ts' +import type { SubscriptionUsage } from './anthropic-subscription.ts' + +/** Where subscription usage lives. Not a provider baseUrl — this endpoint is + * Anthropic's own, and a profile pointed at a proxy still has its subscription + * window measured here rather than wherever it sends inference. */ +export const ANTHROPIC_BASE_URL = 'https://api.anthropic.com' + +/** The little a measurement needs to know about an account. */ +export type MeasurableAccount = { name: string; configDir?: string } + +export type AccountMeasurement = { + name: string + /** null for a key-mode account: it bills per token and has no window at all */ + configDir: string | null + identity: SessionIdentity | null + usage: SubscriptionUsage | null +} + +export type MeasureOptions = { + baseUrl?: string + /** injected in tests, so measuring costs no network and no Keychain */ + fetchUsage?: typeof fetchSubscriptionUsage + readIdentity?: (dir: string) => SessionIdentity | null +} + +/** + * Measure each account's remaining subscription capacity. + * + * Returns one entry per account IN THE ORDER GIVEN, including the ones that + * could not be measured — a caller rendering a list needs to say "this one + * failed" as much as it needs the figures, and dropping the failures here would + * make that impossible to distinguish from an account that no longer exists. + */ +export async function measureAccounts( + accounts: readonly MeasurableAccount[], + { + baseUrl = ANTHROPIC_BASE_URL, + fetchUsage = fetchSubscriptionUsage, + readIdentity = (dir) => readSessionIdentity(dir), + }: MeasureOptions = {}, +): Promise { + const results: AccountMeasurement[] = [] + // SEQUENTIAL, and not an oversight. Each subscription read can raise a + // Keychain prompt, and three stacked unlock dialogs is a worse experience + // than waiting for three round trips. + for (const account of accounts) { + if (!account.configDir) { + results.push({ name: account.name, configDir: null, identity: null, usage: null }) + continue + } + const identity = readIdentity(account.configDir) + const usage = await fetchUsage({ baseUrl, credential: '', sessionDir: account.configDir }) + results.push({ name: account.name, configDir: account.configDir, identity, usage }) + } + return results +} + +/** + * The account→remaining map the `usage` strategy selects on. + * + * An account that could not be measured is ABSENT rather than zero. Selection + * reads a missing entry as "unknown" and falls back saying so, where a zero + * would read as "exhausted" and route work away from an account that may be + * completely free — the wrong answer, arrived at confidently. + */ +export function remainingMap( + measurements: readonly AccountMeasurement[], +): Record { + const remaining: Record = {} + for (const m of measurements) { + if (m.usage && m.usage.remaining !== null) remaining[m.name] = m.usage.remaining + } + return remaining +} diff --git a/src/adapters/usage/openrouter.ts b/src/adapters/usage/openrouter.ts new file mode 100644 index 0000000..2c255be --- /dev/null +++ b/src/adapters/usage/openrouter.ts @@ -0,0 +1,82 @@ +// OpenRouter's key endpoint, as a usage source. +// +// VERIFIED against the live service before this was written: OpenRouter serves +// `GET {baseUrl}/v1/key` returning a `data` object with `limit`, +// `limit_remaining`, `usage`, and `usage_daily|weekly|monthly`. It composes +// from the descriptor's own base URL (`https://openrouter.ai/api`), so no +// second endpoint constant has to be kept in step with the first. +// +// It is the ONLY usage adapter shipped, and that is deliberate rather than +// unfinished: the other presets were not confirmed to publish anything +// equivalent, and a provider that reports nothing must report nothing rather +// than a plausible zero. That is the same standard REJECTED_PROVIDERS and the +// Ollama work were held to. + +import type { ProviderUsage, ProviderUsagePort } from '../../ports/provider-usage.ts' + +/** Composed from the provider's base URL, not hard-coded, so the two agree. */ +export function keyUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, '')}/v1/key` +} + +function isObjectLike(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +/** + * A finite number, or null. + * + * `null` is a REAL value in this payload — OpenRouter uses it for "no limit" — + * so it must survive as null rather than becoming 0. An uncapped key with + * `remaining: 0` would be ranked last by the `usage` strategy, which is exactly + * backwards. + */ +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null +} + +/** Extract usage from whatever the endpoint returned. Exported for testing. */ +export function parseKeyResponse(body: unknown, checkedAt: number): ProviderUsage | null { + const data = isObjectLike(body) && isObjectLike(body.data) ? body.data : null + if (!data) return null + + const usage: ProviderUsage = { + remaining: num(data.limit_remaining), + limit: num(data.limit), + used: num(data.usage), + // Credits, which OpenRouter prices in dollars — but the field is for + // display and nothing branches on it, so it is not asserted as 'usd'. + unit: 'credits', + checkedAt, + } + // Nothing usable at all is null, not a row of nulls: a caller should be able + // to tell "this provider does not publish usage" from "this account has no + // limit", and only one of those is worth showing. + if (usage.remaining === null && usage.limit === null && usage.used === null) return null + return usage +} + +export function createOpenRouterUsage(now: () => number = () => Date.now()): ProviderUsagePort { + return { + id: 'openrouter', + async fetch({ baseUrl, credential, timeoutMs = 4000 }) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const res = await globalThis.fetch(keyUrl(baseUrl), { + headers: { authorization: `Bearer ${credential}` }, + signal: controller.signal, + }) + if (!res.ok) return null + return parseKeyResponse(await res.json(), now()) + } catch { + // Down, rate-limited, offline, or the shape changed. All are findings + // for the caller to render, none is an exception worth unwinding a + // configuration screen with. + return null + } finally { + clearTimeout(timer) + } + }, + } +} diff --git a/src/adapters/web/api-async.ts b/src/adapters/web/api-async.ts new file mode 100644 index 0000000..1dffcab --- /dev/null +++ b/src/adapters/web/api-async.ts @@ -0,0 +1,119 @@ +// The two API routes that do I/O. +// +// Kept OUT of api.ts deliberately. Everything there is a pure +// (request, deps) -> response function, which is why every refusal in it is +// testable without a socket, a clock or a network. Widening that signature to +// `ApiResponse | Promise` so two endpoints could await would have +// pushed asynchrony into ~20 branches that have no reason to know about it. +// +// So this module owns the I/O routes and answers `null` for anything it does +// not recognise, letting the server fall through to the pure handler. The seam +// is one `if` in server.ts. + +import type { ApiResponse } from './api.ts' +import type { CatalogRegistryPort } from '../../ports/catalog.ts' +import type { DoctorReport } from '../../ports/doctor.ts' + +export type AsyncApiDeps = { + /** + * Runs the real doctor. Injected rather than imported so this module needs no + * ProcessPort of its own, and so a test can drive every branch without + * resolving a binary or reaching a network. + */ + doctor?: (opts: { offline: boolean }) => Promise + /** Built lazily by web-root; absent when no catalog adapters are wired. */ + catalogs?: CatalogRegistryPort + /** + * Measures every account's remaining subscription capacity and caches it. + * + * Injected like `doctor`, and a POST for the same reason: on macOS each + * measurement can raise a Keychain unlock dialog. A GET that a browser might + * prefetch, retry or replay must never be able to do that. + */ + measureUsage?: () => Promise +} + +/** One account, measured. Carries no credential — only what it bought you. */ +export type MeasuredAccount = { + name: string + /** 'key' accounts bill per token and have no window; they are listed, not measured */ + mode: 'session' | 'key' + /** "a@b.c · Max 20x", or null when nobody has logged in there */ + login: string | null + remaining: number | null + fiveHour: { utilization: number | null; resetsAt: string | null } | null + sevenDay: { utilization: number | null; resetsAt: string | null } | null +} + +export type UsageReport = { + accounts: MeasuredAccount[] + /** epoch ms, or null when nothing could be measured and no snapshot was written */ + checkedAt: number | null +} + +const json = (status: number, body: unknown): ApiResponse => ({ status, body }) + +function isObjectLike(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +/** + * @returns the response, or null when this module does not own the route. + */ +export async function handleAsyncApi( + req: { method: string; path: string; body: unknown }, + deps: AsyncApiDeps, +): Promise { + const segments = req.path.replace(/^\/api\/?/, '').split('/').filter(Boolean) + const [resource, ...rest] = segments + + if (resource === 'doctor' && req.method === 'POST') { + if (!deps.doctor) return json(501, { error: 'the doctor is not available in this context' }) + + // DEFAULTS TO OFFLINE, which inverts the CLI. On the command line running + // `config doctor` is an explicit act; a web UI invites clicking, and the + // probes are real billable inference requests. Opting IN to spending money + // is the only defensible default for a button. + const offline = isObjectLike(req.body) ? req.body.offline !== false : true + try { + const report = await deps.doctor({ offline }) + return json(200, { report, offline }) + } catch (err) { + return json(500, { error: (err as { message?: string }).message ?? 'doctor failed' }) + } + } + + if (resource === 'usage' && req.method === 'POST') { + if (!deps.measureUsage) { + return json(501, { error: 'usage measurement is not available in this context' }) + } + try { + return json(200, await deps.measureUsage()) + } catch (err) { + // Same discipline as every other measurement in this codebase: a finding + // to report, not an exception that takes a configuration screen down. + return json(500, { error: (err as { message?: string }).message ?? 'could not measure usage' }) + } + } + + if (resource === 'catalog' && req.method === 'GET') { + const id = rest[0] ? decodeURIComponent(rest[0]) : null + if (!id) return json(400, { error: 'catalog id is required' }) + const catalog = deps.catalogs?.byId(id) + // A provider whose catalogId is null is the common case, not an error — + // most providers publish nothing browsable. + if (!catalog) return json(404, { error: `no catalog named "${id}"` }) + + // `list()` never throws by contract: an offline box with a warm cache still + // gets a working picker, and a cold one gets an empty list plus a reason. + const result = await catalog.list() + return json(200, { + id: catalog.id, + label: catalog.label, + capabilities: catalog.capabilities, + ...result, + }) + } + + return null +} diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts new file mode 100644 index 0000000..f4e98a0 --- /dev/null +++ b/src/adapters/web/api.ts @@ -0,0 +1,602 @@ +// The JSON API behind the web UI. +// +// Deliberately free of node:http: a request is a plain object in and a plain +// 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. + +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 { COMPAT_ENV, CREDENTIAL_ENVS } from '../agents/claude-code/env.ts' +import type { + AgentProfile, + ConfigStorePort, + Profile, + ProviderAccount, + State, +} from '../../ports/config-store.ts' +import type { AgentRegistryPort } from '../../ports/agent.ts' +import type { ProviderRegistryPort } from '../../ports/provider.ts' +import { RESERVED_PROVIDER_IDS, withCustomProviders } from '../providers/composite.ts' + +export type ApiRequest = { + method: string + /** pathname only, already stripped of query and origin */ + path: string + /** parsed JSON body, or null. `unknown` because it is untrusted input. */ + body: unknown +} + +export type ApiResponse = { + status: number + body: unknown +} + +export type ApiDeps = { + store: ConfigStorePort + providers: ProviderRegistryPort + agents: AgentRegistryPort + /** + * Which agent binaries exist ON THIS MACHINE. Injected as a thunk rather than + * a value because it stats the filesystem, and `bootstrap` is the only caller + * that needs it — a profile write should not pay for a PATH walk. + * + * Optional: a caller with no process port (every unit test) simply gets no + * installation facts rather than a fabricated "installed: false", which would + * be a claim nobody checked. + */ + installed?: () => InstalledAgent[] + /** + * Who each session-mode account is logged in as, keyed by account name. + * + * A thunk for the same reason as `installed`: it reads a `.claude.json` per + * session account, and only `bootstrap` wants it. Cheap enough to run on every + * cold start — a file read, no credential, no Keychain prompt, no network — + * which is precisely why identity is separate from usage. Measuring a window + * costs a prompt and is a button; saying who an account IS costs nothing and + * should already be on screen. + * + * Optional, and absent rather than empty when unwired: `{}` would be + * indistinguishable from "every account is logged out". + */ + identities?: () => Record +} + +/** One agent CLI, as found (or not) on this machine. */ +export type InstalledAgent = { + id: string + label: string + installed: boolean + /** resolved absolute path, or null when it was not found */ + path: string | null + /** why resolution failed, verbatim from the process adapter */ + error: string | null +} + +const json = (status: number, body: unknown): ApiResponse => ({ status, body }) +const fail = (status: number, error: string): ApiResponse => json(status, { error }) + +/** + * A provider ACCOUNT as the browser is allowed to see it. + * + * Redaction moved here with the credential. Since v3 the key lives on the + * account rather than on the profile, so this is now the single boundary it + * could cross — which is an improvement: there is one type to get right instead + * of one field on a type that also carried everything else. + * + * The key never crosses — not masked, not truncated, not length-hinted. That is + * the same rule the doctor follows, and it matters more here: a value rendered + * into a DOM can be read by anything that achieves script execution on the page + * and is one careless screenshot from a bug report. + * + * `hasKey` is all the UI needs to render "set / not set" and offer to replace + * it, so editing is write-only. `apiKeyFromEnv` IS sent, because a variable + * NAME is not a secret and the user needs to see which one is read. + */ +export type RedactedAccount = Omit & { hasKey: boolean } + +export function redactAccount(account: ProviderAccount): RedactedAccount { + const { apiKey, ...rest } = account + return { ...rest, hasKey: typeof apiKey === 'string' && apiKey.length > 0 } +} + +export function redactState(state: State): unknown { + return { + ...state, + providerAccounts: Object.fromEntries( + Object.entries(state.providerAccounts ?? {}).map(([n, a]) => [n, redactAccount(a)]), + ), + // Agent profiles and profiles hold no credential at all now, so they pass + // through whole. That is the split paying off: only one of the three shapes + // is security-sensitive, and it is obvious which. + agentProfiles: state.agentProfiles ?? {}, + profiles: state.profiles ?? {}, + } +} + +/** `unknown` -> an indexable object, and nothing more. */ +function isObjectLike(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +function str(v: unknown): string | null { + return typeof v === 'string' && v.length > 0 ? v : null +} + +/** + * Lost-update check. + * + * The client sends the revision it last read. If the file has changed since, + * the write is REFUSED rather than merged: swisscode cannot know which of two + * divergent edits the user meant, and silently keeping one would be exactly the + * kind of confident wrongness the rest of the codebase refuses. + * + * 409 rather than 412 because the client is expected to reload and retry, and + * 409 is what every UI framework's error handling already understands. + */ +function revisionConflict(store: ConfigStorePort, body: unknown): ApiResponse | null { + if (!store.revision) return null + const sent = isObjectLike(body) ? body.revision : undefined + // A client that sends no revision at all is a client that never read the + // config — refuse rather than let it stomp. + if (typeof sent !== 'string' && sent !== null) { + return fail(400, 'write refused: no revision supplied, so a lost update cannot be ruled out') + } + const current = store.revision() + if ((sent ?? null) !== current) { + return json(409, { + error: + 'config.json changed since you loaded it — another swisscode command or window ' + + 'wrote to it. Reload before saving so you do not overwrite that change.', + revision: current, + }) + } + return null +} + +/** Save, and report the new revision so the client can keep editing. */ +function commit(store: ConfigStorePort, state: State, extra: unknown = {}): ApiResponse { + try { + store.save(state) + } catch (err) { + // readOnly (a newer schema on disk) lands here, and it is a refusal the + // user must see verbatim rather than as a generic 500. + return fail(409, (err as { message?: string }).message ?? 'could not write config.json') + } + return json(200, { + ok: true, + revision: store.revision ? store.revision() : null, + ...(isObjectLike(extra) ? extra : {}), + }) +} + +/** + * 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. + */ +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: "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' + } + return account +} + +/** An agent profile submitted by the browser. Holds no credential. */ +export function parseAgentProfile( + input: unknown, + existing: AgentProfile | undefined, +): AgentProfile | string { + if (!isObjectLike(input)) return 'agent profile must be an object' + const agentProfile: AgentProfile = { ...(existing ?? {}) } + + if (typeof input.label === 'string') agentProfile.label = input.label + if (typeof input.agent === 'string') agentProfile.agent = input.agent + if (typeof input.skipPermissions === 'boolean') { + agentProfile.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 + } + agentProfile.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 + } + agentProfile.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 + } + agentProfile.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 + } + agentProfile.contextWindows = windows + } + + return agentProfile +} + +/** + * 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 agentProfile = str(input.agentProfile) ?? existing?.agentProfile + if (!agentProfile) return 'agentProfile 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 ?? {}), agentProfile, 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 handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { + const { store, providers, agents } = deps + const segments = req.path.replace(/^\/api\/?/, '').split('/').filter(Boolean) + const [resource, ...rest] = segments + + // Everything the UI needs for a cold start, in one round trip: state, the + // shipped catalogues of providers and agents, and the revision every + // subsequent write must quote back. + if (resource === 'bootstrap' && req.method === 'GET') { + const loaded = store.load() + return json(200, { + state: redactState(loaded.state), + revision: store.revision ? store.revision() : null, + readOnly: loaded.readOnly, + corrupt: loaded.corrupt, + warnings: loaded.warnings, + configPath: store.path(), + providers: withCustomProviders(providers, loaded.state).all().map((p) => ({ + id: p.id, + label: p.label, + baseUrl: p.baseUrl, + askBaseUrl: Boolean(p.askBaseUrl), + credentialOptional: Boolean(p.credentialOptional), + defaultModels: p.defaultModels, + catalogId: p.catalogId ?? null, + hints: p.hints ?? {}, + })), + agents: agents.all().map((a) => ({ + id: a.id, + label: a.label, + capabilities: a.capabilities, + binary: a.binary.name, + overrideEnv: a.binary.overrideEnv, + })), + tiers: TIERS, + // Everything the CLI can express, so the UI never has to hard-code a + // vocabulary that would then drift from the adapter's table. + compatFlags: Object.entries(COMPAT_ENV).map(([id, e]) => ({ + id, + env: e.env, + value: e.value, + consequence: e.consequence ?? null, + })), + credentialEnvs: CREDENTIAL_ENVS, + // Which of these are actually on this machine. Absent when the caller + // wired no process port; never faked. + installedAgents: deps.installed ? deps.installed() : null, + // Who each session account is logged in as. Same "never faked" rule as + // `installedAgents`: null when unwired, never an empty map that would read + // as "all logged out". + logins: deps.identities ? deps.identities() : null, + // Custom providers are returned SEPARATELY from `providers` even though + // the registry already merges them: the UI has to know which ones it may + // edit, and a merged list cannot say. + customProviders: loaded.state.providers ?? {}, + reservedProviderIds: providers.all().map((p) => p.id), + }) + } + + if (resource === 'profiles') { + const name = rest[0] ? decodeURIComponent(rest[0]) : null + 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.agentProfiles?.[parsed.agentProfile]) { + return fail(400, `no agent profile named "${parsed.agentProfile}"`) + } + 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) + } + + 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) + } + } + + // The two halves a profile references. Same revision discipline, same + // whitelisting; separate routes because they are separate things now, and a + // single endpoint taking a flat blob would re-create exactly the conflation + // v3 exists to undo. + if (resource === 'accounts') { + const name = rest[0] ? decodeURIComponent(rest[0]) : null + if (!name) return fail(400, 'account name is required') + + 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 }, + }) + } + + 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 = Object.entries(loaded.state.profiles ?? {}) + .filter(([, pr]) => (pr.accounts ?? []).includes(name)) + .map(([n]) => n) + + const accounts = { ...loaded.state.providerAccounts } + delete accounts[name] + return commit(store, { ...loaded.state, providerAccounts: accounts }, { + affectedProfiles: affected, + }) + } + } + + if (resource === 'agent-profiles') { + const name = rest[0] ? decodeURIComponent(rest[0]) : null + if (!name) return fail(400, 'agent profile name is required') + + 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.agentProfile : null, + loaded.state.agentProfiles?.[name], + ) + if (typeof parsed === 'string') return fail(400, parsed) + return commit(store, { + ...loaded.state, + agentProfiles: { ...loaded.state.agentProfiles, [name]: parsed }, + }) + } + + if (req.method === 'DELETE') { + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const loaded = store.load() + if (!loaded.state.agentProfiles?.[name]) return fail(404, `no agent profile named "${name}"`) + const affected = Object.entries(loaded.state.profiles ?? {}) + .filter(([, pr]) => pr.agentProfile === name) + .map(([n]) => n) + const agentProfiles = { ...loaded.state.agentProfiles } + delete agentProfiles[name] + return commit(store, { ...loaded.state, agentProfiles }, { affectedProfiles: affected }) + } + } + + if (resource === 'providers') { + const id = rest[0] ? decodeURIComponent(rest[0]) : null + + if (req.method === 'PUT') { + if (!id) return fail(400, 'provider id is required') + 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. + 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, + }) + } + } + + 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 }) + } + + if (resource === 'default' && req.method === 'PUT') { + const conflict = revisionConflict(store, req.body) + 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 }) + } + + if (resource === 'bindings') { + const loaded = store.load() + if (req.method === 'GET') { + return json(200, { bindings: bindingEntries(loaded.state) }) + } + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const path = isObjectLike(req.body) ? str(req.body.path) : null + if (!path) return fail(400, 'path is required') + + if (req.method === 'PUT') { + const profile = isObjectLike(req.body) ? str(req.body.profile) : null + if (!profile) return fail(400, 'profile is required') + const bound = bindPath(loaded.state, path, profile) + // bindPath validates the path is absolute AND that the profile exists, + // and reports which is wrong. Forwarding its reason beats re-deriving one. + if (!bound.ok) return fail(400, bound.reason) + return commit(store, bound.state, { key: bound.key, replaced: bound.replaced }) + } + if (req.method === 'DELETE') { + const unbound = unbindPath(loaded.state, path) + return commit(store, unbound.state, { key: unbound.key, removed: unbound.removed }) + } + } + + return fail(404, `no route for ${req.method} ${req.path}`) +} diff --git a/src/adapters/web/security.ts b/src/adapters/web/security.ts new file mode 100644 index 0000000..74f24ca --- /dev/null +++ b/src/adapters/web/security.ts @@ -0,0 +1,180 @@ +// The gate in front of the web UI's API. +// +// This is the highest-stakes code in the project. The server it guards can read +// and write config.json, which holds API keys in plaintext — and a browser will +// cheerfully issue requests to 127.0.0.1 on behalf of ANY page the user has +// open. "It only listens on localhost" is not a security model; it is the +// reason one is required. +// +// Three attacks decide the design, and each countermeasure below names the one +// it stops. Pure functions, so every branch is testable without a socket. + +/** + * The token, minted per server run and never persisted. + * + * It is injected into index.html at serve time and read back by the SPA, rather + * than being set as a cookie. That choice is the CSRF defence and it is worth + * spelling out: a cookie is attached by the browser AUTOMATICALLY on + * cross-origin requests, so a malicious page could ride it. A value the client + * has to read out of our HTML and echo in a header cannot be ridden, because + * the attacker's page is forbidden by the same-origin policy from reading our + * response body to learn it. + */ +export type WebSecurityOptions = { + token: string + /** the port the server actually bound, needed to validate Host exactly */ + port: number +} + +/** A rejection carries the status AND the reason, so the server can log it. */ +export type SecurityVerdict = { ok: true } | { ok: false; status: number; reason: string } + +const OK: SecurityVerdict = { ok: true } + +/** + * Hosts a browser may legitimately use to reach us. + * + * EXACT MATCH on host:port, not a suffix or a substring test. This is the + * DNS-rebinding defence: an attacker registers evil.example, points it at + * 127.0.0.1, and the victim's browser then sends requests to our server with + * `Host: evil.example`. The connection is genuinely from localhost and the + * socket-level check everyone reaches for first cannot tell the difference. The + * Host header can — nothing legitimate ever arrives claiming to be a name we + * did not bind. + * + * IPv6 loopback is spelled bracketed because that is how a browser sends it. + */ +export function allowedHosts(port: number): string[] { + return [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] +} + +export function checkHost(host: string | undefined, port: number): SecurityVerdict { + if (!host) return { ok: false, status: 400, reason: 'request carried no Host header' } + if (!allowedHosts(port).includes(host.toLowerCase())) { + return { + ok: false, + status: 403, + reason: + `Host "${host}" is not one this server bound. This is what a DNS-rebinding ` + + 'attack looks like: a page on another origin resolving its own hostname to ' + + 'your loopback address.', + } + } + return OK +} + +/** + * Origin, checked only where it exists. + * + * Absent on same-origin GETs in some browsers, so a missing Origin cannot be + * fatal without breaking ordinary navigation. Present-and-wrong, however, is + * unambiguous: some other site is driving this request. + */ +export function checkOrigin(origin: string | undefined, port: number): SecurityVerdict { + if (!origin) return OK + const allowed = allowedHosts(port).map((h) => `http://${h}`) + if (!allowed.includes(origin.toLowerCase())) { + return { ok: false, status: 403, reason: `Origin "${origin}" is not this server` } + } + return OK +} + +/** + * Constant-time comparison. + * + * `a === b` on a secret leaks its prefix through timing. The window is small + * over loopback and the attack is fiddly — and it costs one function to remove + * the question entirely, which is cheaper than being asked to justify keeping + * it. + * + * Length is compared first and non-constant-time deliberately: the length of + * this token is fixed and public (it is minted here), so it is not a secret to + * protect. + */ +export function tokensMatch(a: string | undefined, b: string): boolean { + if (typeof a !== 'string' || a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + return diff === 0 +} + +/** The header the SPA echoes the token in. Custom on purpose — see below. */ +export const TOKEN_HEADER = 'x-swisscode-token' + +export function checkToken(headerValue: string | undefined, token: string): SecurityVerdict { + if (!tokensMatch(headerValue, token)) { + return { + ok: false, + status: 401, + reason: `missing or wrong ${TOKEN_HEADER} header`, + } + } + return OK +} + +/** + * Every API request runs this. Order matters only for which reason gets + * reported first; all three must pass. + * + * The CUSTOM header is doing more work than it looks. A cross-origin page + * cannot set an arbitrary request header without triggering a CORS preflight, + * and this server answers no preflight and sends no + * Access-Control-Allow-Origin. So a hostile page cannot even get the real + * request sent, let alone read the reply — the token check is the belt to that + * pair of braces. + */ +export function guardApiRequest( + headers: { + host?: string | undefined + origin?: string | undefined + token?: string | undefined + }, + { token, port }: WebSecurityOptions, +): SecurityVerdict { + const host = checkHost(headers.host, port) + if (!host.ok) return host + const origin = checkOrigin(headers.origin, port) + if (!origin.ok) return origin + return checkToken(headers.token, token) +} + +/** + * The document request (GET /) is guarded by Host and Origin but NOT by the + * token — the token is what the document is delivering, so requiring it would + * be circular. + * + * That is safe precisely because of what the document contains: markup and a + * token, no user data. An attacker who could somehow cause this response has + * still learned nothing, because they cannot read it cross-origin. + */ +export function guardDocumentRequest( + headers: { host?: string | undefined; origin?: string | undefined }, + { port }: Pick, +): SecurityVerdict { + const host = checkHost(headers.host, port) + if (!host.ok) return host + return checkOrigin(headers.origin, port) +} + +/** + * Headers on every response. + * + * The CSP is strict because this page renders values that came from a config + * file a user may have hand-edited — a profile name is attacker-influenced data + * in the only threat model that matters here (someone else's config pasted in + * from a bug report). `default-src 'self'` with no `unsafe-inline` for scripts + * means an injected string cannot execute. + * + * `Cache-Control: no-store` because the document carries the session token, and + * a token in a disk cache outlives the server that issued it. + */ +export const SECURITY_HEADERS: Readonly> = Object.freeze({ + 'content-security-policy': + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; connect-src 'self'; font-src 'self' data:; " + + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + 'x-frame-options': 'DENY', + 'cache-control': 'no-store', +}) diff --git a/src/adapters/web/server.ts b/src/adapters/web/server.ts new file mode 100644 index 0000000..61dca8f --- /dev/null +++ b/src/adapters/web/server.ts @@ -0,0 +1,273 @@ +// node:http glue for the web UI. The ONLY module here that knows about sockets; +// routing decisions live in api.ts and the gate lives in security.ts, both pure. +// +// Off the launch path by construction: test/architecture.test.ts bans node:http +// there by name, so this is reached only through a dynamic import from +// src/cli.ts — the same treatment the wizard, the config subcommands and the +// doctor already get. + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomBytes } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { extname, join, normalize, resolve, sep } from 'node:path' +import { handleApi, type ApiDeps } from './api.ts' +import { handleAsyncApi, type AsyncApiDeps } from './api-async.ts' +import { + SECURITY_HEADERS, + TOKEN_HEADER, + guardApiRequest, + guardDocumentRequest, +} from './security.ts' + +/** + * A request body cannot be unbounded. Nothing this API accepts is remotely this + * large — it is a denial-of-service bound, not a schema limit, so it is + * deliberately generous rather than tuned. + */ +const MAX_BODY_BYTES = 256 * 1024 + +const MIME: Readonly> = Object.freeze({ + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.png': 'image/png', + '.ico': 'image/x-icon', +}) + +/** Extensions read as bytes rather than utf8. */ +const BINARY_EXT = new Set(['.woff2', '.png', '.ico']) + +export type WebServerOptions = ApiDeps & AsyncApiDeps & { + /** 0 lets the OS choose, which is the default: a fixed port is squattable. */ + port?: number + /** absolute path to the built SPA. When absent, a fallback page is served. */ + assetDir?: string | null +} + +export type RunningServer = { + url: string + port: number + token: string + close: () => Promise +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolveBody, rejectBody) => { + const chunks: Buffer[] = [] + let size = 0 + req.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > MAX_BODY_BYTES) { + rejectBody(new Error('request body too large')) + req.destroy() + return + } + chunks.push(chunk) + }) + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8') + if (!raw) return resolveBody(null) + try { + resolveBody(JSON.parse(raw)) + } catch { + rejectBody(new Error('body is not valid JSON')) + } + }) + req.on('error', rejectBody) + }) +} + +function send(res: ServerResponse, status: number, body: unknown, type = 'application/json'): void { + const payload = type.startsWith('application/json') ? JSON.stringify(body) : String(body) + res.writeHead(status, { + ...SECURITY_HEADERS, + 'content-type': type, + 'content-length': Buffer.byteLength(payload), + }) + res.end(payload) +} + +/** + * Resolve a URL path to a file inside `root`, or null. + * + * The `startsWith(root + sep)` check is the path-traversal defence and it runs + * on the RESOLVED path, after normalize, because `..` segments and encoded + * variants only collapse once resolved. Serving files is the one place this + * server touches the filesystem on a client's say-so, so it gets the check even + * though the asset directory holds nothing secret — the directory above it does. + */ +export function resolveAsset(root: string, urlPath: string): string | null { + const relative = normalize(decodeURIComponent(urlPath)).replace(/^([/\\])+/, '') + const target = resolve(root, relative) + const rootResolved = resolve(root) + if (target !== rootResolved && !target.startsWith(rootResolved + sep)) return null + return existsSync(target) ? target : null +} + +/** Where the fallback page's script is served from. See `fallbackDocument`. */ +export const FALLBACK_SCRIPT_PATH = '/__swisscode/fallback.js' + +/** + * The fallback page's script, as a SEPARATE RESOURCE rather than an inline + * ` +} + +export function startWebServer(options: WebServerOptions): Promise { + const { port = 0, assetDir = null, ...deps } = options + // 32 bytes of CSPRNG. Not a password — nobody types it — so there is no reason + // for it to be short. + const token = randomBytes(32).toString('hex') + + const server = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) send(res, 500, { error: 'internal error' }) + }) + }) + + async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const bound = (server.address() as { port: number } | null)?.port ?? port + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`) + const headers = { + host: req.headers.host, + origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined, + token: req.headers[TOKEN_HEADER] as string | undefined, + } + + if (url.pathname.startsWith('/api/')) { + const verdict = guardApiRequest(headers, { token, port: bound }) + if (!verdict.ok) return send(res, verdict.status, { error: verdict.reason }) + + let body: unknown = null + if (req.method !== 'GET' && req.method !== 'HEAD') { + try { + body = await readBody(req) + } catch (err) { + return send(res, 400, { error: (err as Error).message }) + } + } + const request = { method: req.method ?? 'GET', path: url.pathname, body } + // The I/O routes get first refusal; everything else is the pure handler. + const asyncResult = await handleAsyncApi(request, deps) + const result = asyncResult ?? handleApi(request, deps) + return send(res, result.status, result.body) + } + + const docVerdict = guardDocumentRequest(headers, { port: bound }) + if (!docVerdict.ok) return send(res, docVerdict.status, { error: docVerdict.reason }, 'text/plain') + + // The fallback page's script. Served rather than inlined so it satisfies + // our own `script-src 'self'` — see FALLBACK_SCRIPT. + if (url.pathname === FALLBACK_SCRIPT_PATH) { + return send(res, 200, FALLBACK_SCRIPT, 'text/javascript; charset=utf-8') + } + + // Static assets, when a bundle exists. + if (assetDir && url.pathname !== '/') { + const file = resolveAsset(assetDir, url.pathname) + if (file) { + const ext = extname(file).toLowerCase() + const type = MIME[ext] ?? 'application/octet-stream' + // Binary assets are streamed as-is; text goes through `send` so it + // picks up the security headers with a correct content-length. + if (BINARY_EXT.has(ext)) { + const buf = readFileSync(file) + res.writeHead(200, { + ...SECURITY_HEADERS, + 'content-type': type, + 'content-length': buf.length, + }) + res.end(buf) + return + } + return send(res, 200, readFileSync(file, 'utf8'), type) + } + } + + // The document. Token injected here rather than set as a cookie — see + // security.ts for why that is the CSRF defence and not merely a style. + const indexPath = assetDir ? join(assetDir, 'index.html') : null + const html = + indexPath && existsSync(indexPath) + ? readFileSync(indexPath, 'utf8').replace('__SWISSCODE_TOKEN__', token) + : fallbackDocument(token) + return send(res, 200, html, 'text/html; charset=utf-8') + } + + return new Promise((resolveServer, rejectServer) => { + server.once('error', rejectServer) + // 127.0.0.1 EXPLICITLY, never 0.0.0.0. On a shared machine the difference is + // whether every other user on the box can read this config. + server.listen(port, '127.0.0.1', () => { + const bound = (server.address() as { port: number }).port + resolveServer({ + url: `http://127.0.0.1:${bound}/`, + port: bound, + token, + close: () => + new Promise((done) => { + server.close(() => done()) + }), + }) + }) + }) +} diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index c69d445..6ceb89f 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -27,8 +27,14 @@ import { unbindPath, } from '../core/binding.ts' import { validateProfileName } from '../core/migrate.ts' +import { resolveProfileRefs } from '../core/resolve.ts' import { TIERS } from '../core/tiers.ts' import { DEFAULT_AGENT_ID } from '../adapters/agents/registry.ts' +import { withCustomProviders } from '../adapters/providers/composite.ts' +import { describeIdentity, readSessionIdentity } from '../adapters/claude-session/identity.ts' +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 type { LaunchDeps } from './launch-root.ts' import type { LoadResult, Profile, State } from '../ports/config-store.ts' import type { ProcessPort } from '../ports/process.ts' @@ -66,6 +72,8 @@ export type RunConfigCommandOptions = { const SUBCOMMANDS = Object.freeze([ 'list', 'default', 'agent', 'rm', 'use', 'bind', 'unbind', 'bindings', 'doctor', 'help', + 'accounts', + 'agents', ]) const USAGE = `swisscode config — manage profiles and directory bindings @@ -76,6 +84,15 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config default set the profile used when nothing else applies swisscode config rm delete a profile and any bindings to it + swisscode config accounts provider accounts, and which profiles use each + 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 accounts swap move one account's login into another session + --into directory — narrower than /login, which hits every + running session at once + swisscode config agents agent profiles, and which profiles use each + swisscode config agent list agents and which profile uses each swisscode config agent show which coding CLI launches swisscode config agent set the coding CLI (claude-code|kilo|opencode) @@ -86,6 +103,9 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config bind|unbind aliases for use / use --clear swisscode config bindings [--prune] list bindings; --prune drops dead ones + swisscode config web [--port ] configure swisscode from a browser + [--no-open] + swisscode config doctor [--json] check binary, endpoint, credential, models, tool calling, env conflicts, permissions [--offline] skip every network probe @@ -101,13 +121,23 @@ Per-run overrides (never persisted): export async function runConfigCommand({ command, args = [], - deps, + deps: baseDeps, openUi, out = console.log, err = console.error, }: RunConfigCommandOptions): Promise { const [head, ...rest] = args + // Custom providers live in the config file, so every surface that names a + // provider has to compose the registry after reading it. Done ONCE here and + // shadowed over `deps`, because doing it per-subcommand is exactly how + // `config list` came to report "not in this build" for a provider that + // `config doctor` resolved fine — three call sites, one of them forgotten. + const deps: LaunchDeps = { + ...baseDeps, + registry: withCustomProviders(baseDeps.registry, baseDeps.store.load().state), + } + // `setup` is only ever the first-run wizard. if (command === 'setup') { if (args.length > 0) { @@ -144,6 +174,12 @@ export async function runConfigCommand({ return listBindings({ deps, prune: rest.includes('--prune'), out, err }) case 'doctor': return doctorCommand({ deps, args: rest, out, err }) + case 'web': + return webCommand({ deps, args: rest, out, err }) + case 'accounts': + return accountsCommand({ deps, args: rest, out, err }) + case 'agents': + return listAgentProfiles({ deps, out }) default: break } @@ -236,7 +272,10 @@ function agentCommand({ out('No profiles yet. Run `swisscode config` to make one.') return 0 } - for (const n of names) out(` ${n} → ${state.profiles[n]?.agent ?? DEFAULT_AGENT_ID}`) + for (const n of names) { + const ap = state.agentProfiles?.[state.profiles[n]?.agentProfile ?? ''] + out(` ${n} → ${ap?.agent ?? DEFAULT_AGENT_ID}`) + } return 0 } @@ -250,8 +289,10 @@ function agentCommand({ return 2 } + const agentProfileName = profile.agentProfile + const agentProfile = state.agentProfiles?.[agentProfileName] if (agentId === undefined) { - out(`${profileName} → ${profile.agent ?? DEFAULT_AGENT_ID}`) + out(`${profileName} → ${agentProfile?.agent ?? DEFAULT_AGENT_ID}`) return 0 } @@ -262,12 +303,33 @@ function agentCommand({ return 2 } if (loaded.readOnly) return refuseWrite(err) + if (!agentProfile) { + err( + `swisscode: profile "${profileName}" uses agent profile "${agentProfileName}", which ` + + 'does not exist. Run `swisscode config ' + profileName + '` to repair it.', + ) + return 2 + } + // Written to the AGENT PROFILE, not the profile: since v3 that is where the + // coding CLI lives, and an agent profile may back several profiles — which is + // the point of the split, and worth the reminder in the confirmation line. const next: State = { ...state, - profiles: { ...state.profiles, [profileName]: { ...profile, agent: agentId } }, + agentProfiles: { + ...state.agentProfiles, + [agentProfileName]: { ...agentProfile, agent: agentId }, + }, } deps.store.save(next) - out(`${profileName} now launches ${agentId}.`) + const alsoUsing = Object.entries(state.profiles ?? {}) + .filter(([n, pr]) => n !== profileName && pr.agentProfile === agentProfileName) + .map(([n]) => n) + out( + `${profileName} now launches ${agentId}.` + + (alsoUsing.length + ? ` (shared agent profile "${agentProfileName}" — also used by ${alsoUsing.join(', ')})` + : ''), + ) return 0 } @@ -292,40 +354,71 @@ function listProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { for (const name of names.sort()) { // `!` is noUncheckedIndexedAccess meeting Object.keys: `names` was read off - // this very object three lines up, so every key is present. Asserted once - // at the binding rather than at each of the seven reads below. + // this very object three lines up, so every key is present. const p = state.profiles[name]! const isDefault = state.defaultProfile === name - const provider = deps.registry.byId(p.provider) + + // Show what the profile RESOLVES to, not what it references. A list of key + // names would make the reader do the dereference in their head, and the + // question this command answers is "what happens if I launch this". + const resolution = resolveProfileRefs(state, name) + const resolved = resolution.ok ? resolution.resolved : null + const provider = resolved ? deps.registry.byId(resolved.provider) : null + const flags = [ isDefault ? 'default' : null, - provider ? null : 'unknown provider', + resolution.ok ? null : 'broken', + resolved && !provider ? 'unknown provider' : null, // The subcommand always wins positionally, so such a profile can only be // selected with --cc-profile. SUBCOMMANDS.includes(name) ? 'shadowed' : null, ].filter(Boolean) out(`${isDefault ? '*' : ' '} ${name}${flags.length ? ` (${flags.join(', ')})` : ''}`) - out(` provider ${p.provider}${provider ? '' : ' — not in this build'}`) - if (p.baseUrl) out(` baseUrl ${p.baseUrl}`) + + if (!resolution.ok) { + out(` ${resolution.reason}`) + continue + } + const r = resolved! + + // The three-way structure, named. Which account pays is the most + // consequential line here, so it is first and it is never elided. + // + // LIVE accounts only. Counting a dangling reference as "+1 more" would + // promise a rotation partner that does not exist — confidently wrong about + // billing, which is the one thing this listing must not be. + const others = (p.accounts ?? []).filter( + (a) => a !== r.accountName && state.providerAccounts?.[a], + ) + out( + ` account ${r.accountName} → ${r.provider}` + + (provider ? '' : ' — not in this build') + + (others.length ? ` (+${others.length} more, ${p.strategy ?? 'single'})` : ''), + ) + // Resolution warnings say what was skipped and why; swallowing them here + // would leave a stale reference invisible until someone read the JSON. + for (const w of resolution.warnings) out(` ⚠ ${w}`) + out(` agent ${r.agentProfileName} → ${r.agent ?? DEFAULT_AGENT_ID}`) + if (r.baseUrl) out(` baseUrl ${r.baseUrl}`) // Presence and ORIGIN only. Never a prefix, never a suffix, never a length: // a masked key is still a fingerprint, and this output gets pasted into bug // reports. - out(` key ${credentialOrigin(p)}`) + out(` key ${credentialOrigin(r)}`) // What will actually be sent, not just what is stored: an absent tier // inherits the provider default at launch, and printing "—" for something // that resolves to a real model is how a config gets debugged twice. const models = TIERS.map((t) => { - const pinned = p.models?.[t] + const pinned = r.models?.[t] if (pinned) return `${t}=${pinned}` const inherited = provider?.defaultModels?.[t] return inherited ? `${t}=${inherited}*` : `${t}=—` }).join(' ') out(` models ${models}`) - if (TIERS.some((t) => !p.models?.[t] && provider?.defaultModels?.[t])) { + if (TIERS.some((t) => !r.models?.[t] && provider?.defaultModels?.[t])) { out(' * inherited from the provider preset') } - if (p.skipPermissions) out(' perms --dangerously-skip-permissions by default') + if (r.skipPermissions) out(' perms --dangerously-skip-permissions by default') for (const key of bindingsByProfile.get(name) ?? []) out(` bound ${key}`) } @@ -336,9 +429,9 @@ function listProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { return 0 } -function credentialOrigin(profile: Profile): string { - if (profile.apiKeyFromEnv) return `read from $${profile.apiKeyFromEnv} at launch` - if (profile.apiKey) return 'stored in config.json (0600)' +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' } @@ -478,7 +571,15 @@ function showBinding({ if (known) { // `!` — `known` is a hasOwnProperty check on this exact key, which tsc // does not treat as a narrowing but which is a real runtime proof. - out(`effective profile "${info.match.name}" (${state.profiles[info.match.name]!.provider})`) + // Report the ACCOUNT the binding resolves to, not a stored provider id: + // since v3 the profile holds neither, and "which account pays here" is + // the question `use --show` exists to answer. + const bound = resolveProfileRefs(state, info.match.name) + out( + `effective profile "${info.match.name}" (` + + (bound.ok ? `${bound.resolved.accountName} → ${bound.resolved.provider}` : 'broken') + + ')', + ) } else { out(`effective profile "${state.defaultProfile ?? 'none'}" — the binding is dangling, so it is ignored`) } @@ -650,3 +751,370 @@ function refuseWrite(err: Emit): number { ) return 2 } + +/** + * `swisscode config web` — the browser UI, and the singleton. + * + * The server module is imported LAZILY even from here, which is already a lazy + * module. config-root is reached for every `config` subcommand, including + * `list` and `doctor`, and none of those should pay for loading an HTTP server. + * + * Returns a promise that never settles on success: the server owns the process + * until Ctrl-C. That is the one place in swisscode where a command deliberately + * does not exit, and it is why this is opt-in rather than a background daemon. + */ +async function webCommand({ + deps, + args, + out, + err, +}: { + deps: LaunchDeps + args: string[] + out: Emit + err: Emit +}): Promise { + const portFlag = args.indexOf('--port') + let port = 0 + if (portFlag !== -1) { + const raw = args[portFlag + 1] + const parsed = Number(raw) + if (!raw || !Number.isInteger(parsed) || parsed < 0 || parsed > 65535) { + err(`swisscode: --port needs a number between 0 and 65535; got "${raw ?? ''}".`) + return 2 + } + port = parsed + } + + const { runWeb } = await import('./web-root.ts') + try { + const server = await runWeb({ + deps, + port, + noOpen: args.includes('--no-open'), + out, + }) + // Resolve only when the server closes, so the command holds the terminal. + await new Promise((resolve) => { + const stop = () => { + void server.close().then(resolve) + } + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + }) + return 0 + } catch (e) { + err(`swisscode: ${(e as { message?: string }).message ?? 'could not start the web UI'}`) + return 2 + } +} + +/** + * `swisscode config accounts [login …]`. + * + * Dispatch only. The login flow lives in `adapters/claude-session/onboard.ts` + * because it is Claude-Code-shaped — it knows about session directories and + * about `/login` — and this file is a composition root, not a home for + * agent-specific behaviour. + */ +/** + * `swisscode config accounts usage` — measure every account, once. + * + * The command that replaces switching into each account to run `/usage`. It is + * also the ONLY thing that writes the snapshot the `usage` selection strategy + * reads: measuring is configuration-time by construction, because the launch + * path may not reach the network. + */ +async function refreshUsage({ + deps, + out, + err, +}: { + deps: LaunchDeps + out: Emit + err: Emit +}): Promise { + const { state } = deps.store.load() + const accounts = Object.entries(state.providerAccounts ?? {}).map(([name, account]) => ({ + name, + ...(account.configDir ? { configDir: account.configDir } : {}), + })) + if (accounts.length === 0) { + out('No provider accounts yet. Run `swisscode config accounts login `.') + return 0 + } + + // The loop itself lives in adapters/usage/measure.ts, shared with the doctor. + // This function's job is the rendering below. + const measurements = await measureAccounts(accounts) + for (const m of measurements) { + if (!m.configDir) { + // A key-mode account bills per token; it has no window to be out of. + out(` ${m.name} — key account, no subscription window`) + continue + } + out(` ${m.name} (${describeIdentity(m.identity)})`) + if (!m.usage || m.usage.remaining === null) { + out(' usage — could not be measured (not logged in, expired, or endpoint refused)') + 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)}`) + } + + const remaining = remainingMap(measurements) + if (Object.keys(remaining).length === 0) { + err('swisscode: nothing could be measured, so the cached snapshot was left alone.') + return 1 + } + // Best effort, like every write in the state directory: the figures above are + // already on screen, and failing the command because a cache could not be + // written would trade a working answer for a tidy filesystem. + deps.usage?.write({ remaining, checkedAt: Date.now() }) + out('') + out('Cached. Profiles using the `usage` strategy will select on these figures.') + return 0 +} + +/** + * `swisscode config accounts swap --into `. + * + * The narrow version of `/login`. `/login` writes one global slot that every + * running Claude Code re-reads, so switching for one terminal switches all of + * them; this writes ONE directory's slot and leaves the others alone. + * + * Refuses to overwrite a DIFFERENT account's login without `--yes`. Everything + * else here is recoverable by re-running the command; that one loses a stored + * token, and while `/login` can always put it back, silently discarding + * someone's credential is not a thing a tool should do on a typo. + */ +function swapAccount({ + deps, + args, + out, + err, +}: { + deps: LaunchDeps + args: string[] + out: Emit + err: Emit +}): number { + const intoIndex = args.indexOf('--into') + const into = intoIndex >= 0 ? args[intoIndex + 1] : undefined + const yes = args.includes('--yes') + const positional = args.filter( + (a, i) => !a.startsWith('--') && args[i - 1] !== '--into', + ) + const sourceName = positional[0] + + if (!sourceName || !into) { + err('swisscode: usage: swisscode config accounts swap --into ') + return 2 + } + + const { state } = deps.store.load() + const accounts = state.providerAccounts ?? {} + + const source = accounts[sourceName] + if (!source) { + err(`swisscode: no account named "${sourceName}".`) + return 2 + } + if (!source.configDir) { + err( + `swisscode: "${sourceName}" is a key account. Only a session account has a login to move — ` + + 'a key is already shared by every profile that names it.', + ) + return 2 + } + + // The target may be an account name or a bare directory. An account name wins + // when both could match, because that is the vocabulary the rest of these + // commands use and a directory can always be spelled unambiguously. + const targetAccount = accounts[into] + const targetDir = targetAccount?.configDir ?? into + if (targetAccount && !targetAccount.configDir) { + err(`swisscode: "${into}" is a key account, so it has no session directory to swap into.`) + return 2 + } + + const sourceIdentity = readSessionIdentity(source.configDir) + const targetIdentity = readSessionIdentity(targetDir) + // Compare by accountUuid where both publish one — an email can change, and + // two directories holding the same login is exactly what a repeated swap + // produces and must stay idempotent. + const differs = + targetIdentity !== null && + (sourceIdentity?.accountUuid && targetIdentity.accountUuid + ? sourceIdentity.accountUuid !== targetIdentity.accountUuid + : describeIdentity(sourceIdentity) !== describeIdentity(targetIdentity)) + if (differs && !yes) { + err(`swisscode: ${targetDir} currently holds a different login:`) + err(` there now ${describeIdentity(targetIdentity)}`) + err(` moving in ${describeIdentity(sourceIdentity)}`) + err('Re-run with --yes to overwrite it. `/login` can always put the old one back.') + return 2 + } + + const result = swapCredential(source.configDir, targetDir, {}) + if (!result.ok) { + err(`swisscode: ${result.reason}`) + return 1 + } + + out(`Moved ${describeIdentity(sourceIdentity)} into ${targetDir}`) + if (!result.wroteIdentity) { + // The token moved but the identity did not, which is the half-done state + // this command exists to avoid — so it is said plainly rather than left for + // the user to discover through a confusing `/status`. + out('') + out(' note: the token moved but `.claude.json` could not be updated, so `/status` in that') + out(' directory may still name the previous account until the agent rewrites it.') + } + out('') + out(' A session already running there picks this up within ~30 seconds — the agent re-reads') + out(' its credential on a timer, and swisscode cannot reach into a running process.') + out(' Anything that session creates after that belongs to the new account.') + out(' Sessions using any other directory are untouched, which is the point of doing it this') + out(' way rather than with `/login`.') + 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, + out, + err, +}: { + deps: LaunchDeps + args: string[] + out: Emit + err: Emit +}): number | Promise { + const [sub, ...rest] = args + if (sub === undefined) return listAccounts({ deps, out }) + if (sub === 'usage') return refreshUsage({ deps, out, err }) + if (sub === 'swap') return swapAccount({ deps, args: rest, out, err }) + if (sub !== 'login') { + err(`swisscode: unknown accounts subcommand "${sub}". Try \`swisscode config accounts\`.`) + return 2 + } + + const flag = (name: string): string | undefined => { + const i = rest.indexOf(name) + return i >= 0 ? rest[i + 1] : undefined + } + const positional = rest.filter((a, i) => !a.startsWith('--') && !rest[i - 1]?.startsWith('--')) + + const options = { + name: positional[0], + store: deps.store, + agents: deps.agents, + proc: deps.proc, + out, + err, + } + const dir = flag('--dir') + const provider = flag('--provider') + return accountLogin({ + ...options, + ...(dir !== undefined ? { dir } : {}), + ...(provider !== undefined ? { provider } : {}), + }) +} + +/** + * `swisscode config accounts` — who pays, and who uses them. + * + * The reverse index is the point. 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. + */ +function listAccounts({ deps, out }: { deps: LaunchDeps; out: Emit }): number { + const { state } = deps.store.load() + const names = Object.keys(state.providerAccounts ?? {}).sort() + if (names.length === 0) { + out('No provider accounts yet. Run `swisscode config` to make one.') + return 0 + } + + for (const name of names) { + // `!` — 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) + + out(` ${name}${a.label ? ` (${a.label})` : ''}`) + out(` provider ${a.provider}${provider ? '' : ' — not in this build'}`) + if (a.baseUrl) out(` baseUrl ${a.baseUrl}`) + 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 + // three accounts is this?" is the question, and a path does not answer it. + // Reads `.claude.json` only; no credential, no prompt, no network. + // Read ONCE — `.claude.json` is a 200 kB file on a well-used account. + const identity = readSessionIdentity(a.configDir) + out(` login ${describeIdentity(identity)}`) + out(` session ${a.configDir}`) + if (!identity) { + out(` run \`swisscode config accounts login ${name}\` and \`/login\` inside`) + } + } else { + // Presence and ORIGIN only, exactly as `config list` does: a masked key is + // still a fingerprint and this output gets pasted into bug reports. + out(` key ${credentialOrigin(a)}`) + } + out(` used by ${usedBy.length > 0 ? usedBy.join(', ') : '— nothing'}`) + } + return 0 +} + +/** + * `swisscode config agents` — what runs, and who uses it. + * + * Named for the concept rather than the CLI: `config agent ` + * already existed and still edits which coding CLI a profile launches. This + * lists the agent PROFILES, which is the thing that can now be shared. + */ +function listAgentProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { + const { state } = deps.store.load() + const names = Object.keys(state.agentProfiles ?? {}).sort() + if (names.length === 0) { + out('No agent profiles yet. Run `swisscode config` to make one.') + return 0 + } + + for (const name of names) { + const ap = state.agentProfiles[name]! + const usedBy = Object.entries(state.profiles ?? {}) + .filter(([, p]) => p.agentProfile === name) + .map(([n]) => n) + + out(` ${name}${ap.label ? ` (${ap.label})` : ''}`) + out(` agent ${ap.agent ?? DEFAULT_AGENT_ID}`) + const pinned = TIERS.filter((t) => ap.models?.[t]).map((t) => `${t}=${ap.models![t]}`) + out(` models ${pinned.length > 0 ? pinned.join(' ') : '— provider defaults'}`) + if (ap.skipPermissions) out(' perms --dangerously-skip-permissions by default') + const flags = Object.entries(ap.compat ?? {}).filter(([, on]) => on).map(([f]) => f) + if (flags.length > 0) out(` compat ${flags.join(', ')}`) + // Shared setups are the reason this concept exists, so say when one is. + out( + ` used by ${usedBy.length > 0 ? usedBy.join(', ') : '— nothing'}` + + (usedBy.length > 1 ? ' (shared)' : ''), + ) + } + return 0 +} diff --git a/src/composition/doctor-root.ts b/src/composition/doctor-root.ts index 78a77b5..9b116a4 100644 --- a/src/composition/doctor-root.ts +++ b/src/composition/doctor-root.ts @@ -13,6 +13,7 @@ import { buildEnvPlan } from '../adapters/agents/claude-code/env.ts' import { claudeCode } from '../adapters/agents/claude-code/index.ts' import { applyOverrides } from '../core/overrides.ts' import { resolveProfile } from '../core/profile.ts' +import { resolveProfileRefs } from '../core/resolve.ts' import { buildIntent } from '../core/intent.ts' import { sanitizeUrlForDisplay, urlCredentials } from '../core/url-safety.ts' import { @@ -32,7 +33,15 @@ import { summarize, } from '../adapters/agents/claude-code/doctor.ts' import { bindingEntries, pruneBindings } from '../core/binding.ts' +import { withCustomProviders } from '../adapters/providers/composite.ts' import { createProbe } from '../adapters/doctor/probe.ts' +import { + describeIdentity, + readSessionIdentity, + sessionDirLooksInitialised, +} from '../adapters/claude-session/identity.ts' +import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' +import type { MeasureOptions } from '../adapters/usage/measure.ts' import type { LaunchDeps } from './launch-root.ts' import { createOllamaIntrospect, interpretOllamaContext } from '../adapters/doctor/ollama.ts' import type { @@ -59,6 +68,16 @@ export type RunDoctorOptions = { probe?: AnthropicMessagesProbePort | null /** Injected in tests, like `probe`. Only consulted for an Ollama profile. */ ollama?: OllamaIntrospectPort | null + /** + * Injected in tests, and REQUIRED for the suite to stay offline. + * + * Unlike `probe` and `ollama`, which the tests inject to observe what was + * asked, this one exists because the usage refresh below reaches + * api.anthropic.com by default. A doctor test with a `usage`-strategy profile + * and no `--offline` would otherwise make a real request against whatever + * credential the machine happens to hold. + */ + usageFetch?: MeasureOptions['fetchUsage'] | null } /** @@ -76,8 +95,9 @@ export async function runDoctor({ now = () => Date.now(), probe = null, ollama = null, + usageFetch = null, }: RunDoctorOptions): Promise { - const { store, registry, agents, proc } = deps + const { store, registry: baseRegistry, agents, proc } = deps const ambient = proc.env() const loaded = store.load() const notes = [] @@ -90,7 +110,16 @@ export async function runDoctor({ } const selection = resolveProfile(loaded.state, { cwd, platform: process.platform }) - const profile = selection.profile ? applyOverrides(selection.profile, selection.overrides) : null + // The doctor resolves exactly as the launch path does, or it would diagnose a + // configuration nobody runs. A broken reference surfaces as a check below + // rather than as an exception here, which is the whole point of a doctor. + const resolution = selection.name ? resolveProfileRefs(loaded.state, selection.name) : null + const resolutionError = resolution && !resolution.ok ? resolution.reason : null + const profile = + resolution?.ok ? applyOverrides(resolution.resolved, selection.overrides) : null + // Same composition as the launch path: a doctor that could not see a custom + // provider would report "unknown provider" for a profile that launches fine. + const registry = withCustomProviders(baseRegistry, loaded.state) const provider = profile ? registry.byId(profile.provider) : null const plan = profile ? buildEnvPlan(profile, provider, ambient) : null // The doctor validates the selected agent's binary; the endpoint probe below @@ -135,6 +164,8 @@ export async function runDoctor({ .filter((b) => !existsSync(b.key)) .map((b) => b.key) + if (resolutionError) notes.push(resolutionError) + const checks = staticChecks({ loaded, selection, @@ -154,6 +185,45 @@ export async function runDoctor({ ) } + // A session directory nobody has logged into. + // + // THE FAILURE THIS CATCHES IS EXPENSIVE BECAUSE IT IS LATE. Resolution + // succeeds, the env plan is correct, `execve` replaces the process — and the + // first sign of trouble is the agent's own login prompt, by which point + // swisscode no longer exists to explain itself. Nothing earlier in the launch + // can catch it, because the launch path deliberately never opens the + // directory it points at. So it belongs here, in the one command whose job is + // to look. + // + // Costs a file read, no credential and no network, so it runs under + // `--offline` like the rest of the static checks. + if (profile?.configDir) { + const dir = profile.configDir + const identity = readSessionIdentity(dir) + if (identity) { + checks.push( + makeCheck('session', 'session login', OK, `${describeIdentity(identity)} — ${dir}`), + ) + } else { + // "Never used" and "used but logged out" get different sentences because + // they are different problems: one is onboarding you have not done, the + // other is a login that lapsed. Both take the same fix, but a user who is + // told the right one stops guessing. + const used = sessionDirLooksInitialised(dir, { exists: existsSync }) + checks.push( + makeCheck( + 'session', + 'session login', + WARN, + used + ? `${dir} has been used but carries no login — nobody has completed \`/login\` there` + : `${dir} has never been used by the agent`, + { fix: `swisscode config accounts login ${profile.accountName}` }, + ), + ) + } + } + // live probes const spec = plan ? probeSpec(profile, provider, plan) : null // Redaction also covers a credential carried as https://user:pass@host userinfo, @@ -304,6 +374,87 @@ export async function runDoctor({ } } + // Refresh the usage snapshot. + // + // The doctor is where `core/resolve.ts` ALREADY SENDS PEOPLE: when a `usage` + // profile has no snapshot, `selectAccount` falls back to the first account and + // prints "Run `swisscode config doctor` to refresh usage." Until this block + // existed that sentence was a lie — the doctor had no idea what a snapshot + // was, so the advice sent users in a circle. + // + // Scoped to the accounts a `usage` profile actually names, rather than every + // account on the machine. Each measurement can raise a Keychain prompt, and + // prompting for figures that nothing will ever read is a cost with no answer + // attached. + const usageAccountNames = [ + ...new Set( + Object.values(loaded.state.profiles ?? {}) + .filter((p) => p.strategy === 'usage') + .flatMap((p) => p.accounts ?? []), + ), + ] + if (usageAccountNames.length === 0) { + checks.push( + makeCheck( + 'usage-snapshot', + 'usage snapshot', + SKIP, + 'skipped: no profile selects its account by remaining usage', + ), + ) + } else if (offline) { + checks.push(makeCheck('usage-snapshot', 'usage snapshot', SKIP, 'skipped (--offline)')) + } else { + const measurements = await measureAccounts( + usageAccountNames.map((name) => { + const account = loaded.state.providerAccounts?.[name] + return { name, ...(account?.configDir ? { configDir: account.configDir } : {}) } + }), + usageFetch ? { fetchUsage: usageFetch } : {}, + ) + const remaining = remainingMap(measurements) + const measured = Object.keys(remaining) + if (measured.length === 0) { + checks.push( + makeCheck( + 'usage-snapshot', + 'usage snapshot', + WARN, + 'nothing could be measured, so the cached snapshot was left alone', + { fix: 'swisscode config accounts usage' }, + ), + ) + } else { + // Best effort, like every write to the state directory. The figures are + // already in the report; failing a diagnostic because its cache could not + // be written would trade a working answer for a tidy filesystem. + deps.usage?.write({ remaining, checkedAt: now() }) + checks.push( + makeCheck( + 'usage-snapshot', + 'usage snapshot', + OK, + measured.map((n) => `${n} ${remaining[n]}% left`).join(', '), + ), + ) + // Name what was NOT measured. A partial snapshot still selects, and it + // selects among the accounts that answered — so an account missing from + // it silently stops being a candidate, which is worth one line. + const missed = usageAccountNames.filter((n) => !(n in remaining)) + if (missed.length > 0) { + checks.push( + makeCheck( + 'usage-unmeasured', + 'usage snapshot', + WARN, + `not measured: ${missed.join(', ')} — selection will pass over them until they answer`, + { fix: 'swisscode config accounts usage' }, + ), + ) + } + } + } + // repairs const repairs = [] if (fix && !loaded.readOnly) { diff --git a/src/composition/launch-root.ts b/src/composition/launch-root.ts index 1dc64b2..6ee253c 100644 --- a/src/composition/launch-root.ts +++ b/src/composition/launch-root.ts @@ -10,12 +10,16 @@ import { isInsecureRemoteBaseUrl } from '../core/url-safety.ts' import { materializeEnv } from '../core/env-plan.ts' import { applyOverrides, retargetProvider } from '../core/overrides.ts' import { resolveProfile } from '../core/profile.ts' +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 { createNodeProcess, detectRecursion } from '../adapters/process/node-process.ts' import { registry as providerRegistry } from '../adapters/providers/registry.ts' +import { withCustomProviders } from '../adapters/providers/composite.ts' import { DEFAULT_AGENT_ID, registry as agentRegistry } from '../adapters/agents/registry.ts' import type { ProfileSelection } from '../core/profile.ts' -import type { ConfigStorePort, LoadResult, Profile } from '../ports/config-store.ts' +import type { ConfigStorePort, LoadResult, ResolvedProfile } from '../ports/config-store.ts' import type { ProviderDescriptor, ProviderRegistryPort } from '../ports/provider.ts' import type { EnvMap, ProcessPort } from '../ports/process.ts' import type { ProfileOverrides } from '../ports/config-store.ts' @@ -44,6 +48,22 @@ export type LaunchDeps = { registry: ProviderRegistryPort agents: AgentRegistryPort proc: ProcessPort + /** + * Where a round-robin cursor is remembered. OPTIONAL, and genuinely so: a + * profile using the default `single` strategy never consults it, and a + * caller with no cursor store degrades to "always the first account" with a + * warning rather than failing. Deliberately NOT the config store — the launch + * path writes no config, and a rotation counter is not configuration. + */ + cursor?: CursorPort + /** + * The last measured usage snapshot. READ-ONLY here, and optional for the same + * reason the cursor is: only the `usage` strategy consults it, and a caller + * without one degrades to the first account WITH A WARNING rather than + * failing. Nothing on the launch path may refresh it — that means the + * network, which this path is banned from reaching. + */ + usage?: UsageStorePort } export function defaultDeps(): LaunchDeps { @@ -52,6 +72,8 @@ export function defaultDeps(): LaunchDeps { registry: providerRegistry, agents: agentRegistry, proc: createNodeProcess(), + cursor: createFsCursorStore(), + usage: createFsUsageStore(), } } @@ -114,7 +136,8 @@ export type LaunchPlan = { needsSetup: false loaded: LoadResult selection: ProfileSelection - profile: Profile + /** the flattened account + agent profile this launch resolved to */ + profile: ResolvedProfile /** * null is a REAL state, not a defect: a profile naming a provider this build * does not know still launches, provided it carries a baseUrl of its own. @@ -146,9 +169,11 @@ export type PlannedLaunch = LaunchNeedsSetup | LaunchPlan export function planLaunch({ store, - registry, + registry: baseRegistry, agents, proc, + cursor, + usage, passthrough = [], skipOverride = null, positional = null, @@ -167,6 +192,13 @@ export function planLaunch({ } const loaded = store.load() + + // Custom providers live in the config file, so the registry cannot be + // resolved until it has been read. Composed here rather than in defaultDeps + // for that reason — and around the INJECTED registry, so a test's fake + // registry still governs the shipped half. + const registry = withCustomProviders(baseRegistry, loaded.state) + const sel = resolveProfile(loaded.state, { cwd: safeCwd(proc), platform: process.platform, @@ -185,10 +217,26 @@ export function planLaunch({ // A matched positional profile name is CONSUMED — claude never sees it. const args = sel.consumedPositional ? passthrough.slice(1) : passthrough + // v3: a profile is three objects. Dereference the agent profile and select + // one account BEFORE anything else, because every step below operates on the + // flattened view — which is deliberately the shape v2 stored, so nothing + // downstream of here changed when the schema split. + const resolution = resolveProfileRefs(loaded.state, sel.name ?? '', { + cursor: cursor ?? null, + // READ, never refreshed. Measuring here would mean an HTTP request to a + // billing endpoint on every launch, which is the thing the whole + // snapshot-and-cache arrangement exists to avoid. A stale or missing + // snapshot degrades to the first account, loudly — see `selectAccount`. + usage: usage?.read() ?? null, + now: Date.now(), + }) + if (!resolution.ok) throw new LaunchError(resolution.reason) + const resolutionWarnings = resolution.warnings + // Pipeline, in order: binding overrides, then provider retarget, then the // remaining CLI overrides. Retargeting first means --cc-base-url can still - // correct a base URL borrowed from another profile. - let profile = applyOverrides(sel.profile, sel.overrides) + // correct a base URL adopted from another account. + let profile = applyOverrides(resolution.resolved, sel.overrides) let borrowedFrom = null if (overrides.provider) { @@ -225,6 +273,12 @@ export function planLaunch({ // unknown id is fatal; a stored unknown agent falls back to the default, so a // config written by a newer swisscode still launches something. const warnings: EnvWarning[] = [] + // Resolution can degrade loudly — a rotation with no cursor store, a `usage` + // strategy with no snapshot, a dangling account reference. Which account pays + // is exactly the thing that must never change quietly. + for (const message of resolutionWarnings) { + warnings.push({ severity: 'medium', code: 'account-selection', message }) + } const requestedAgentId = profile.agent ?? DEFAULT_AGENT_ID let agent = agents.byId(requestedAgentId) if (!agent) { diff --git a/src/composition/web-root.ts b/src/composition/web-root.ts new file mode 100644 index 0000000..3d93f7a --- /dev/null +++ b/src/composition/web-root.ts @@ -0,0 +1,193 @@ +// Composition root for `swisscode config web`. +// +// LAZY, like the UI bundle, the config subcommands and the doctor: reached only +// through a dynamic import, so the launch path's static closure never grows to +// carry an HTTP server. test/architecture.test.ts bans node:http there by name. +// +// This is also the SINGLETON. The port bind is the mutex — there is no lockfile +// and no stale-PID reasoning, because the OS already refuses to bind a port +// twice and cleans up when the process dies. A lockfile would have to +// reimplement that badly. + +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { existsSync } from 'node:fs' +import { spawn } from 'node:child_process' +import { startWebServer, type RunningServer } from '../adapters/web/server.ts' +import { createCatalogRegistry } from '../adapters/catalog/registry.ts' +import { createFsCacheStore } from '../adapters/store/fs-cache-store.ts' +import { fetchNet } from '../adapters/net/fetch-net.ts' +import { systemClock } from '../adapters/clock/system-clock.ts' +import { configDir } from '../adapters/store/fs-config-store.ts' +import { describeIdentity, readSessionIdentity } from '../adapters/claude-session/identity.ts' +import type { LaunchDeps } from './launch-root.ts' + +export type RunWebOptions = { + deps: LaunchDeps + port?: number + /** print the URL rather than opening a browser */ + noOpen?: boolean + out?: (line: string) => void +} + +/** + * Where the built SPA lives, or null when it has not been built. + * + * Resolved relative to THIS module's own location rather than process.cwd(), + * because `swisscode` is run from the user's project directory and the assets + * live next to the compiled code in the installed package. + */ +export function assetDir(): string | null { + const here = dirname(fileURLToPath(import.meta.url)) + // dist/composition/web-root.js -> dist/web + const candidate = join(here, '..', 'web') + return existsSync(join(candidate, 'index.html')) ? candidate : null +} + +export async function runWeb({ + deps, + port = 0, + noOpen = false, + out = console.log, +}: RunWebOptions): Promise { + let server: RunningServer + try { + server = await startWebServer({ + store: deps.store, + providers: deps.registry, + agents: deps.agents, + // Resolved through the SAME ProcessPort the launcher uses, so "installed" + // in the UI means exactly what it means at launch — including the + // SWISSCODE_*_BIN overrides and the self-alias guard. A second + // implementation here could disagree with the thing it describes. + installed: () => + deps.agents.all().map((agent) => { + try { + return { + id: agent.id, + label: agent.label, + installed: true, + path: deps.proc.resolveBinary(agent.binary), + error: null, + } + } catch (err) { + return { + id: agent.id, + label: agent.label, + installed: false, + path: null, + error: (err as { message?: string }).message ?? null, + } + } + }), + // Same catalogs the Ink picker uses, so the browser and the terminal + // browse an identical list from an identical 24h cache. + catalogs: createCatalogRegistry({ + net: fetchNet, + cache: createFsCacheStore({ dir: configDir(), clock: systemClock }), + clock: systemClock, + }), + // The doctor is imported LAZILY: it reaches the network and pulls in the + // probe, and a user who only edits a profile should not load it at all. + doctor: async ({ offline }) => { + const { runDoctor } = await import('./doctor-root.ts') + const run = await runDoctor({ deps, offline }) + return run.report + }, + // Who each session account is logged in as. A file read apiece, so it is + // safe on every cold start — see the note on ApiDeps.identities. + identities: () => { + const { state } = deps.store.load() + const logins: Record = {} + for (const [name, account] of Object.entries(state.providerAccounts ?? {})) { + if (!account.configDir) continue + logins[name] = describeIdentity(readSessionIdentity(account.configDir)) + } + return logins + }, + // Lazy for the same reason as the doctor, and one step further: this one + // can raise a Keychain prompt, so it loads only when someone asks for it. + measureUsage: async () => { + const { measureAccounts, remainingMap } = await import('../adapters/usage/measure.ts') + const { state } = deps.store.load() + const measurements = await measureAccounts( + Object.entries(state.providerAccounts ?? {}).map(([name, account]) => ({ + name, + ...(account.configDir ? { configDir: account.configDir } : {}), + })), + ) + const remaining = remainingMap(measurements) + // Write only when something answered. Clearing a usable snapshot + // because the endpoint hiccupped would drop every `usage` profile back + // to "first account" for no reason. + const checkedAt = Object.keys(remaining).length > 0 ? Date.now() : null + if (checkedAt !== null) deps.usage?.write({ remaining, checkedAt }) + return { + checkedAt, + accounts: measurements.map((m) => ({ + name: m.name, + mode: m.configDir ? ('session' as const) : ('key' as const), + login: m.configDir ? describeIdentity(m.identity) : null, + remaining: m.usage?.remaining ?? null, + fiveHour: m.usage?.fiveHour ?? null, + sevenDay: m.usage?.sevenDay ?? null, + })), + } + }, + port, + assetDir: assetDir(), + }) + } catch (err) { + const code = (err as { code?: string }).code + if (code === 'EADDRINUSE') { + // The singleton speaking. A second instance is not an error condition to + // recover from — it means the thing the user asked for is already + // running, and the useful response is to say where. + throw new Error( + `port ${port} is already in use — swisscode's web UI may already be running there. ` + + 'Open it, or start this one on a different port with `--port `.', + ) + } + throw err + } + + out(`swisscode: web UI on ${server.url}`) + out(' the URL carries no token; the page fetches its own. Close with Ctrl-C.') + if (!assetDir()) { + out(' note: no UI bundle found — serving the fallback page. Run `npm run build`.') + } + if (!noOpen) openBrowser(server.url, out) + return server +} + +/** + * Open the default browser, best effort. + * + * BEST EFFORT IS THE CONTRACT, not a shortcut. This is a side effect on someone + * else's desktop, over which swisscode has no authority: there may be no + * browser, no display, no session (ssh, a container, CI). Every one of those is + * a normal state, and none of them is a reason to fail a command whose actual + * job — serving on a URL that was already printed — has succeeded. + * + * So it is detached and unref'd (the child must not hold the process open or + * inherit the terminal), errors are swallowed to a single line, and the URL is + * printed FIRST so the flow works identically whether or not this does anything. + */ +function openBrowser(url: string, out: (line: string) => void): void { + const command = + process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' + try { + const child = spawn(command, [url], { + stdio: 'ignore', + detached: true, + // `start` is a cmd builtin rather than an executable. + ...(process.platform === 'win32' ? { shell: true } : {}), + }) + // Failure arrives asynchronously (ENOENT on a box with no xdg-open), so it + // needs a handler or it becomes an unhandled 'error' event and kills us. + child.on('error', () => out(' (could not open a browser — copy the URL above)')) + child.unref() + } catch { + out(' (could not open a browser — copy the URL above)') + } +} diff --git a/src/core/env-plan.ts b/src/core/env-plan.ts index d07cc48..288953e 100644 --- a/src/core/env-plan.ts +++ b/src/core/env-plan.ts @@ -6,7 +6,7 @@ // or CLAUDE_CODE_* string — that is the whole point of the split. import type { EnvPlan } from '../ports/agent.ts' -import type { Profile } from '../ports/config-store.ts' +import type { ResolvedProfile } from '../ports/config-store.ts' import type { EnvMap } from '../ports/process.ts' /** @@ -66,7 +66,7 @@ export function definedEntriesOf( * Neutral: it produces the VALUE; which variable carries it is the adapter's call. */ export function resolveCredential( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, ambientEnv: EnvMap | null | undefined, ): string { if (profile?.apiKeyFromEnv) return ambientEnv?.[profile.apiKeyFromEnv] ?? '' diff --git a/src/core/intent.ts b/src/core/intent.ts index 890331b..4beadc3 100644 --- a/src/core/intent.ts +++ b/src/core/intent.ts @@ -13,7 +13,7 @@ import { TIERS } from './tiers.ts' import { definedEntriesOf, resolveCredential } from './env-plan.ts' import type { LaunchIntent } from '../ports/agent.ts' -import type { Profile } from '../ports/config-store.ts' +import type { ResolvedProfile } from '../ports/config-store.ts' import type { ProviderDescriptor, Tier, TierRecord } from '../ports/provider.ts' import type { EnvMap } from '../ports/process.ts' @@ -30,7 +30,7 @@ export type IntentOptions = { } export function buildIntent( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, ambientEnv: EnvMap = {}, opts: IntentOptions = {}, @@ -57,5 +57,8 @@ export function buildIntent( } // Under exactOptionalPropertyTypes, only present it when there is one. if (profile?.contextWindows) intent.contextWindows = profile.contextWindows + // Session mode. Neutral here — which variable expresses it is the adapter's + // business, and core/ may not name an agent's dialect in emitted code. + if (profile?.configDir) intent.sessionDir = profile.configDir return intent } diff --git a/src/core/migrate.ts b/src/core/migrate.ts index 08f6516..7fe7f28 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -8,9 +8,17 @@ import { pickTiers } from './tiers.ts' import { isAbsolutePath, normalizeBindingKey } from './binding.ts' -import type { BindingValue, ConfigV1, MigrateResult, Settings, State } from '../ports/config-store.ts' +import type { + BindingValue, + ConfigV1, + ConfigV2, + CustomProvider, + MigrateResult, + Settings, + State, +} from '../ports/config-store.ts' -export const SUPPORTED_VERSION = 2 +export const SUPPORTED_VERSION = 3 /** Enforced at creation, never at parse — a hand-edited file is still read. */ export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ @@ -58,9 +66,27 @@ type V2Draft = { settings: Settings } +/** + * The v3 SHAPE, before validation. Same reasoning as `V2Draft`: the three maps + * are `Record` because rule M1 carries unrecognized keys + * through unchecked, and saying so keeps that step visible. + */ +type V3Draft = { + version: number + providerAccounts: Record> + agentProfiles: Record> + profiles: Record> + defaultProfile: string | null + bindings: Record + settings: Settings + providers?: Record +} + export function emptyState(): State { return { version: SUPPORTED_VERSION, + providerAccounts: {}, + agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, @@ -123,7 +149,10 @@ export function fromV1(raw: ConfigV1): V2Draft { const name = NAME_RE.test(raw.provider) ? raw.provider : 'default' return { - version: SUPPORTED_VERSION, + // VERSION 2, not SUPPORTED_VERSION. This output is fed straight into + // `fromV2` by the ladder below; stamping it with the current version would + // make the chain silently skip the v2->v3 step the moment a v4 exists. + version: 2, profiles: { [name]: profile }, defaultProfile: name, bindings: {}, @@ -131,6 +160,82 @@ export function fromV1(raw: ConfigV1): V2Draft { } } +/** + * The v2 keys that move to a `ProviderAccount`, and those that move to an + * `AgentProfile`. Anything in NEITHER list is an unrecognized key and rides + * along on the agent profile — rule M1, inherited from v1→v2. + */ +const V2_ACCOUNT_KEYS = Object.freeze(['provider', 'baseUrl', 'apiKey', 'apiKeyFromEnv']) +const V2_AGENT_KEYS = Object.freeze([ + 'agent', 'models', 'skipPermissions', 'env', 'compat', 'contextWindows', +]) + +/** + * v2 -> v3. Lossless, deterministic, and it repairs nothing. + * + * Each v2 profile `N` becomes THREE objects, all named `N`: an account, an + * agent profile, and a profile referencing both. Bindings and defaultProfile + * keep pointing at profile names, so `swisscode ` and every directory + * binding survive untouched — which is the whole reason the split is named this + * way round. + * + * ACCOUNTS ARE NOT DEDUPED, even when two profiles obviously share a key. + * Merging them would be clever, irreversible, and wrong the first time two + * accounts happen to hold the same value for different reasons. One account per + * profile is lossless and obvious; consolidating is a thing a person can do + * later, in the UI, on purpose. + */ +export function fromV2(raw: ConfigV2): V3Draft { + const providerAccounts: Record> = {} + const agentProfiles: Record> = {} + const profiles: Record> = {} + + for (const [name, p] of Object.entries(raw.profiles ?? {})) { + if (!isPlainObject(p)) continue + + const account: Record = {} + for (const k of V2_ACCOUNT_KEYS) if (p[k] !== undefined) account[k] = p[k] + // `provider` is required on an account. A v2 profile without one was + // already broken — carry it through as-is rather than inventing a default, + // so the resolution error names the real problem instead of a guess. + providerAccounts[name] = account + + const agentProfile: Record = {} + for (const k of V2_AGENT_KEYS) if (p[k] !== undefined) agentProfile[k] = p[k] + // M1: unrecognized keys ride along. They land on the agent profile because + // that is where v2's own extras (anything not a credential) belonged. + for (const [k, v] of Object.entries(p)) { + if (!V2_ACCOUNT_KEYS.includes(k) && !V2_AGENT_KEYS.includes(k) && k !== 'label') { + agentProfile[k] = v + } + } + agentProfiles[name] = agentProfile + + const profile: Record = { + agentProfile: name, + accounts: [name], + strategy: 'single', + } + // The label described the pairing, so it stays on the pairing. + if (p.label !== undefined) profile.label = p.label + profiles[name] = profile + } + + const draft: V3Draft = { + version: SUPPORTED_VERSION, + providerAccounts, + agentProfiles, + profiles, + defaultProfile: typeof raw.defaultProfile === 'string' ? raw.defaultProfile : null, + bindings: raw.bindings ?? {}, + settings: raw.settings ?? {}, + } + // Custom providers are already a top-level concept and are unrelated to the + // split; they pass through untouched. + if (raw.providers !== undefined) draft.providers = raw.providers + return draft +} + /** Fill defaults, drop junk, resolve the default profile. Idempotent. */ export function normalize(raw: unknown): { state: State; warnings: string[] } { const warnings: string[] = [] @@ -158,6 +263,28 @@ export function normalize(raw: unknown): { state: State; warnings: string[] } { state.profiles = profiles } + // The two new v3 maps, validated exactly as `profiles` is: an entry that is + // not an object is dropped with a warning rather than reaching a consumer + // that will read fields off a number. + for (const key of ['providerAccounts', 'agentProfiles'] as const) { + if (!isPlainObject(state[key])) { + if (state[key] !== undefined) { + warnings.push(`config.json: \`${key}\` is not an object; ignoring it.`) + } + state[key] = {} + } else { + const kept: Record> = {} + for (const [name, entry] of Object.entries(state[key])) { + if (!isPlainObject(entry)) { + warnings.push(`config.json: ${key} entry "${name}" is not an object; ignoring it.`) + continue + } + kept[name] = entry + } + state[key] = kept + } + } + if (!isPlainObject(state.bindings)) { state.bindings = {} } else { @@ -242,6 +369,8 @@ export function migrate(raw: unknown): MigrateResult { if (version > SUPPORTED_VERSION) { const salvage = { version, + providerAccounts: isPlainObject(raw.providerAccounts) ? raw.providerAccounts : {}, + agentProfiles: isPlainObject(raw.agentProfiles) ? raw.agentProfiles : {}, profiles: isPlainObject(raw.profiles) ? raw.profiles : {}, defaultProfile: typeof raw.defaultProfile === 'string' ? raw.defaultProfile : null, bindings: isPlainObject(raw.bindings) ? raw.bindings : {}, @@ -252,6 +381,11 @@ export function migrate(raw: unknown): MigrateResult { return { state, migratedFrom: null, corrupt: false, readOnly: true, warnings } } + if (version === 2) { + const { state, warnings } = normalize(fromV2(raw as unknown as ConfigV2)) + return { state, migratedFrom: 2, corrupt: false, readOnly: false, warnings } + } + if (version === 1) { // Known bug: `version === 1` is not `isV1(raw)` — `raw` is only a // `Record` here. `detectVersion` returns 1 down TWO paths: @@ -261,7 +395,10 @@ export function migrate(raw: unknown): MigrateResult { // reaches `fromV1`, where `raw.provider` is undefined, `NAME_RE.test(undefined)` // coerces to the string "undefined" and MATCHES, and rule M1 nests the // entire file inside a single profile named "undefined". - const { state, warnings } = normalize(fromV1(raw as unknown as ConfigV1)) + // CHAINED, not parallel. fromV1 produces a v2 draft, which fromV2 then + // splits — so a 0.1.0 file reaches v3 in a single read and there is exactly + // one implementation of each step to keep correct. + const { state, warnings } = normalize(fromV2(fromV1(raw as unknown as ConfigV1) as unknown as ConfigV2)) return { state, migratedFrom: 1, corrupt: false, readOnly: false, warnings } } diff --git a/src/core/overrides.ts b/src/core/overrides.ts index 47c6612..413a50d 100644 --- a/src/core/overrides.ts +++ b/src/core/overrides.ts @@ -1,28 +1,30 @@ -// Per-run profile overrides. +// Per-run overrides, applied to the RESOLVED profile. // -// INVARIANT: this path never writes. Overrides produce a modified copy that -// lives for exactly one launch; the only writers in the codebase are the wizard -// and the `config *` subcommands. test/core/overrides.test.ts asserts zero -// store writes across a matrix of override shapes. +// INVARIANT: this path never writes CONFIG. Overrides produce a modified copy +// that lives for exactly one launch; the only config writers in the codebase +// are the wizard and the `config *` subcommands. test/core/overrides.test.ts +// asserts zero `store.save` calls across a matrix of override shapes. // -// The `--cc-*` PARSER that produces these override objects belongs to the UX -// phase. The merge itself is pure and lands here now. +// (A round-robin cursor is not config and does not go through the store — see +// core/resolve.ts and adapters/store/fs-cursor-store.ts.) import { TIERS, isTier } from './tiers.ts' -import type { Profile, ProfileOverrides, State } from '../ports/config-store.ts' +import type { ProfileOverrides, ResolvedProfile, State } from '../ports/config-store.ts' import type { ProviderDescriptor, Tier } from '../ports/provider.ts' import type { EnvMap } from '../ports/process.ts' /** - * Returns a NEW profile; the input is never mutated. + * Returns a NEW resolved profile; the input is never mutated. * - * `profile` is a plain `Profile`, not nullable: both call sites (launch-root - * and doctor-root) refuse to proceed when selection produced no profile, so the - * `?? {}` below is defence rather than a supported mode. A `Profile | null` - * signature would push a phantom null through every consumer downstream. + * Operates on the flattened view rather than on stored objects, which is what + * keeps `--cc-*` from having to know that a profile is now three objects: an + * override changes what THIS LAUNCH does, not what is filed where. */ -export function applyOverrides(profile: Profile, overrides: ProfileOverrides = {}): Profile { - const next = structuredClone(profile ?? {}) +export function applyOverrides( + profile: ResolvedProfile, + overrides: ProfileOverrides = {}, +): ResolvedProfile { + const next = structuredClone(profile ?? {}) as ResolvedProfile if (typeof overrides.provider === 'string') next.provider = overrides.provider if (typeof overrides.agent === 'string') next.agent = overrides.agent @@ -49,7 +51,7 @@ export function applyOverrides(profile: Profile, overrides: ProfileOverrides = { } if (overrides.env && typeof overrides.env === 'object') { - // Merged AFTER profile.env, and '' still means UNSET. + // Merged AFTER the agent profile's env, and '' still means UNSET. next.env = { ...(next.env ?? {}), ...overrides.env } } @@ -59,11 +61,21 @@ export function applyOverrides(profile: Profile, overrides: ProfileOverrides = { /** * Never send a credential to a host it was not entered for. * - * Order: keep the key when the provider is unchanged; otherwise borrow the - * credential and base URL from another profile that already uses the target - * provider; otherwise accept one already present in the ambient env; otherwise - * refuse. There is no "just send the key we have" branch — that is how a z.ai - * token ends up POSTed to OpenRouter. + * SINCE v3 THIS IS A LOOKUP, NOT A SEARCH — and that change is most of why the + * schema was split. The v2 version had to rummage through every other profile + * hoping to find one that happened to hold a key for the target provider, and + * then copy the key, endpoint and models back out of it. Credentials were + * trapped inside profiles, so retargeting meant scavenging. + * + * Now an account names exactly one provider, so "which credential may go to + * this host" is `providerAccounts` filtered by `provider`. The safety rule is + * unchanged and is now STRUCTURAL rather than a copying discipline. + * + * Order: keep the account when the provider is unchanged; otherwise adopt an + * account that already belongs to the target provider; otherwise accept a + * credential already present in the ambient env; otherwise refuse. There is no + * "just send the key we have" branch — that is how a z.ai token ends up POSTed + * to OpenRouter. * * MODEL IDS ARE DROPPED for the same reason the credential is. `glm-5.2` was * chosen for z.ai; forwarding it to OpenRouter is a guaranteed 404 wearing the @@ -72,35 +84,37 @@ export function applyOverrides(profile: Profile, overrides: ProfileOverrides = { * because it is merged after this runs. */ export function retargetProvider( - profile: Profile, + profile: ResolvedProfile, targetProviderId: string | null | undefined, state: State | null | undefined, descriptor: ProviderDescriptor | null | undefined, ambientEnv: EnvMap = {}, -): { ok: true; profile: Profile; borrowedFrom: string | null } | { ok: false; reason: string } { +): { + ok: true + profile: ResolvedProfile + borrowedFrom: string | null +} | { ok: false; reason: string } { if (!targetProviderId || profile?.provider === targetProviderId) { return { ok: true, profile, borrowedFrom: null } } - const next = structuredClone(profile ?? {}) + const next = structuredClone(profile ?? {}) as ResolvedProfile next.provider = targetProviderId delete next.baseUrl delete next.models // Keyed by model id from the old provider's catalog, so meaningless here. delete next.contextWindows - for (const [name, candidate] of Object.entries(state?.profiles ?? {})) { - if (candidate?.provider !== targetProviderId) continue - if (!candidate.apiKey && !candidate.apiKeyFromEnv) continue + // The lookup the old scavenge was imitating. + for (const [name, account] of Object.entries(state?.providerAccounts ?? {})) { + if (account?.provider !== targetProviderId) continue + if (!account.apiKey && !account.apiKeyFromEnv) continue delete next.apiKey delete next.apiKeyFromEnv - if (candidate.apiKey) next.apiKey = candidate.apiKey - if (candidate.apiKeyFromEnv) next.apiKeyFromEnv = candidate.apiKeyFromEnv - if (candidate.baseUrl) next.baseUrl = candidate.baseUrl - // That profile is already configured FOR this provider, so its models are - // the best available answer — better than the descriptor defaults. - if (candidate.models) next.models = structuredClone(candidate.models) - if (candidate.contextWindows) next.contextWindows = structuredClone(candidate.contextWindows) + if (account.apiKey) next.apiKey = account.apiKey + if (account.apiKeyFromEnv) next.apiKeyFromEnv = account.apiKeyFromEnv + if (account.baseUrl) next.baseUrl = account.baseUrl + next.accountName = name return { ok: true, profile: next, borrowedFrom: name } } @@ -122,9 +136,9 @@ export function retargetProvider( return { ok: false, reason: - `no credential for provider "${targetProviderId}". The current profile's key was ` + - `entered for "${profile?.provider}" and will not be sent somewhere else. Add a ` + - `profile for "${targetProviderId}"` + + `no account for provider "${targetProviderId}". The current account's key was ` + + `entered for "${profile?.provider}" and will not be sent somewhere else. Add an ` + + `account with \`swisscode config accounts\`` + (credentialEnv ? `, or set ${credentialEnv} in your environment.` : '.'), } } diff --git a/src/core/provider-def.ts b/src/core/provider-def.ts new file mode 100644 index 0000000..ec66837 --- /dev/null +++ b/src/core/provider-def.ts @@ -0,0 +1,263 @@ +// Validation for USER-DEFINED providers. +// +// This module exists because a guarantee changed hands. Shipped provider +// descriptors are constants in source, and test/registry.test.ts proves things +// about them: base URLs carry no /v1, third-party endpoints clear +// ANTHROPIC_API_KEY, no model id is hand-typed with [1m], every compat flag is +// real. A provider that arrives from config.json is out of reach of every one +// of those tests, and adding a form to type one in does not make the checks +// unnecessary — it makes them RUNTIME checks. +// +// So each rule below is the runtime twin of a test in registry.test.ts, and +// says which failure it prevents. Pure: no I/O, no registry lookups beyond the +// reserved-id list it is handed. + +import { TIERS, isTier } from './tiers.ts' +import type { CustomProvider } from '../ports/config-store.ts' + +/** + * `errors` block the save. `warnings` do not — they describe a configuration + * that is legal and probably wrong, which is a distinction the user is entitled + * to make for themselves (a plain-http endpoint on a trusted LAN, say). + */ +export type ProviderValidation = { + ok: boolean + errors: string[] + warnings: string[] +} + +/** Same grammar as a profile name: predictable in a URL and in argv. */ +const ID_RE = /^[a-z0-9][a-z0-9._-]*$/ + +const EXTENDED_MARKER = '[1m]' + +function isObjectLike(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +function isLoopback(hostname: string): boolean { + const h = hostname.toLowerCase().replace(/^\[|\]$/g, '') + return h === 'localhost' || h === '127.0.0.1' || h === '::1' || h.endsWith('.localhost') +} + +export type ValidateOptions = { + /** ids of shipped presets, which a user provider may not shadow */ + reservedIds: readonly string[] + /** known compat flag names, so a typo is caught rather than silently inert */ + knownCompatFlags: readonly string[] + /** + * Which variable spellings may carry the credential. + * + * INJECTED, like `knownCompatFlags`, and for the reason the architecture test + * enforces: those spellings are Claude Code dialect, and core/ may not name an + * ANTHROPIC_* variable in emitted code. Writing the list inline here compiled + * fine and failed that test immediately — which is the check working. + */ + credentialEnvs: readonly string[] +} + +export function validateCustomProvider( + input: unknown, + { reservedIds, knownCompatFlags, credentialEnvs }: ValidateOptions, +): ProviderValidation { + const errors: string[] = [] + const warnings: string[] = [] + const push = (c: boolean, msg: string): void => { + if (c) errors.push(msg) + } + + if (!isObjectLike(input)) { + return { ok: false, errors: ['a provider must be an object'], warnings } + } + + // id + const id = typeof input.id === 'string' ? input.id : '' + push(!ID_RE.test(id), 'id must start with a letter or digit and use only a-z, 0-9, . _ -') + // Shadowing a shipped preset is refused rather than allowed to win or lose by + // registration order: `openrouter` meaning something different on one machine + // is precisely the confusion this tool exists to remove. + push( + reservedIds.includes(id), + `"${id}" is a shipped provider; pick another id rather than shadowing it`, + ) + + push(typeof input.label !== 'string' || !input.label.trim(), 'label is required') + + // base URL — the /v1 trap is the single most common way to build a preset + // that 404s, and it has its own test for the shipped ones. + const rawUrl = typeof input.baseUrl === 'string' ? input.baseUrl.trim() : '' + if (!rawUrl) { + errors.push('baseUrl is required') + } else { + let url: URL | null = null + try { + url = new URL(rawUrl) + } catch { + errors.push(`baseUrl "${rawUrl}" is not a valid URL`) + } + if (url) { + push( + url.protocol !== 'http:' && url.protocol !== 'https:', + 'baseUrl must be http:// or https://', + ) + push( + /\/v1\/?$/.test(url.pathname), + 'baseUrl ends in /v1 — that is the OpenAI-compatible route. Claude Code appends ' + + '/v1/messages itself, so this would request /v1/v1/messages and 404.', + ) + if (url.protocol === 'http:' && !isLoopback(url.hostname)) { + warnings.push( + `credentials sent to ${url.origin} travel in cleartext to a non-loopback host, ` + + 'where anyone on the network path can read them', + ) + } + if (url.username || url.password) { + warnings.push( + 'baseUrl carries credentials in its userinfo; they are stored in config.json and ' + + 'printed in some diagnostics. Prefer the credential field.', + ) + } + } + } + + // credential + if (input.credentialEnv !== undefined) { + push( + typeof input.credentialEnv !== 'string' || + !credentialEnvs.includes(input.credentialEnv as string), + `credentialEnv must be one of ${credentialEnvs.join(' or ')}`, + ) + } + if (input.defaultCredential !== undefined) { + push(typeof input.defaultCredential !== 'string', 'defaultCredential must be a string') + // A placeholder ships in the clear by definition; if it were secret it + // would belong on the profile as a key, not on the provider. + if (typeof input.defaultCredential === 'string' && input.defaultCredential.length > 24) { + warnings.push( + 'defaultCredential looks like a real secret. It is stored as provider data and is ' + + 'not redacted anywhere — put real keys on the profile instead.', + ) + } + } + + // models + if (input.defaultModels !== undefined) { + if (!isObjectLike(input.defaultModels)) { + errors.push('defaultModels must be an object keyed by tier') + } else { + for (const [tier, value] of Object.entries(input.defaultModels)) { + push(!isTier(tier), `"${tier}" is not a model tier (${TIERS.join(', ')})`) + push(typeof value !== 'string', `defaultModels.${tier} must be a string`) + push( + typeof value === 'string' && value.endsWith(EXTENDED_MARKER), + `defaultModels.${tier} carries ${EXTENDED_MARKER}. That suffix is derived from a ` + + 'verified capability, never typed in — a model id the endpoint does not recognise ' + + 'fails hard.', + ) + } + } + } + + // env / unsetEnv — descriptors use the explicit split, and '' as a sentinel is + // banned there precisely so "set to empty" and "remove" stay distinguishable. + if (input.env !== undefined) { + if (!isObjectLike(input.env)) { + errors.push('env must be an object of string values') + } else { + for (const [k, v] of Object.entries(input.env)) { + push(typeof v !== 'string', `env.${k} must be a string`) + push( + v === '', + `env.${k} is an empty string. Use unsetEnv to remove a variable; '' as a sentinel is ` + + 'a profile-level convention that does not apply to a provider.', + ) + } + } + } + if (input.unsetEnv !== undefined) { + push( + !Array.isArray(input.unsetEnv) || input.unsetEnv.some((v) => typeof v !== 'string'), + 'unsetEnv must be an array of variable names', + ) + } + + // compat — a misspelled flag is a silent no-op, which is the failure the + // named-boolean design exists to prevent. + if (input.compat !== undefined) { + if (!isObjectLike(input.compat)) { + errors.push('compat must be an object of booleans') + } else { + for (const [flag, value] of Object.entries(input.compat)) { + push( + !knownCompatFlags.includes(flag), + `"${flag}" is not a compat flag. Known flags: ${knownCompatFlags.join(', ')}.`, + ) + push(typeof value !== 'boolean', `compat.${flag} must be a boolean`) + } + } + } + + if (input.subagentFollowsOpus !== undefined) { + push(typeof input.subagentFollowsOpus !== 'boolean', 'subagentFollowsOpus must be a boolean') + } + + // Refused outright rather than ignored: silently dropping a field the user + // typed would leave them believing a capability is active. + push( + 'extendedContext' in input, + 'extendedContext cannot be declared on a custom provider. The [1m] suffix asserts a ' + + 'verified capability; set the profile’s contextWindows instead, which drives ' + + 'auto-compaction without claiming a wider window.', + ) + push( + 'catalogId' in input, + 'catalogId cannot be set on a custom provider — a catalog needs a shipped adapter that ' + + 'understands the upstream response shape.', + ) + + return { ok: errors.length === 0, errors, warnings } +} + +/** + * Narrow a validated object to `CustomProvider`, dropping anything unknown. + * + * Whitelisted rather than spread: an unrecognised key written into config.json + * could be read as meaningful by a future swisscode, so what a user typed and + * what gets stored must be the same known set. + */ +export function toCustomProvider(input: Record): CustomProvider { + const out: CustomProvider = { + id: String(input.id), + label: String(input.label), + baseUrl: String(input.baseUrl).trim(), + } + if (typeof input.credentialEnv === 'string') { + out.credentialEnv = input.credentialEnv as NonNullable + } + if (typeof input.credentialOptional === 'boolean') out.credentialOptional = input.credentialOptional + if (typeof input.defaultCredential === 'string') out.defaultCredential = input.defaultCredential + if (typeof input.subagentFollowsOpus === 'boolean') { + out.subagentFollowsOpus = input.subagentFollowsOpus + } + if (isObjectLike(input.defaultModels)) { + const models: Partial> = {} + for (const [tier, v] of Object.entries(input.defaultModels)) { + if (isTier(tier) && typeof v === 'string') models[tier] = v + } + out.defaultModels = models 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 + out.env = env + } + if (Array.isArray(input.unsetEnv)) { + out.unsetEnv = input.unsetEnv.filter((v): v is string => typeof v === 'string') + } + if (isObjectLike(input.compat)) { + const compat: Record = {} + for (const [k, v] of Object.entries(input.compat)) if (typeof v === 'boolean') compat[k] = v + out.compat = compat as NonNullable + } + return out +} diff --git a/src/core/resolve.ts b/src/core/resolve.ts new file mode 100644 index 0000000..120b5db --- /dev/null +++ b/src/core/resolve.ts @@ -0,0 +1,241 @@ +// Turn a selected profile into the one account + agent profile a launch uses. +// +// `core/profile.ts` answers WHICH profile (positional / flag / binding / +// default). This answers what that profile actually resolves to, which since v3 +// means dereferencing an agent profile and choosing among one or more accounts. +// +// Pure, and pure on purpose: strategy selection is the step that decides WHICH +// ACCOUNT PAYS, so it must be exhaustively testable without a store, a clock or +// a network. +// +// EVERY STRATEGY RESOLVES ONCE, BEFORE execve. swisscode ceases to exist at +// handoff, so there is no per-request rotation and no mid-session failover here +// — those need a process in the data path, which is the proxy this tool is not. + +import type { + ProviderAccount, + ResolvedProfile, + SelectionStrategy, + State, +} from '../ports/config-store.ts' + +/** + * What the launch path needs, or why it cannot be produced. + * + * A discriminated union rather than a nullable result: on the error branch + * there is genuinely no resolved profile, and `reason` is a message that names + * the fix — the same contract `LaunchError` messages hold themselves to. + */ +export type Resolution = + | { ok: true; resolved: ResolvedProfile; warnings: string[] } + | { ok: false; reason: string } + +/** + * Where a rotation cursor is remembered between launches. + * + * Injected rather than read here, because this module is pure and because the + * cursor deliberately does NOT live in config.json — the launch path writes no + * config, and a rotation counter is not configuration. See + * `adapters/store/fs-cursor-store.ts`. + * + * `read` returning null (no cursor yet, unreadable file, whatever) is a normal + * state and simply starts the rotation at zero. + */ +export type CursorPort = { + read: (profileName: string) => number | null + /** best effort; a failed write must never block a launch */ + advance: (profileName: string, next: number) => void +} + +/** + * A cached usage snapshot, refreshed at CONFIGURATION time (doctor, web UI). + * + * Never fetched here. The launch path may not reach the network, so `usage` + * selection reads what was last measured and reports how old it is rather than + * pretending to know the current figure. + */ +export type UsageSnapshot = { + /** account name -> remaining capacity, higher is more */ + remaining: Record + /** epoch ms when it was measured */ + checkedAt: number +} + +export type ResolveOptions = { + cursor?: CursorPort | null + usage?: UsageSnapshot | null + /** for reporting snapshot age; injected so this stays pure */ + now?: number +} + +/** Flatten an account and an agent profile into the shape a launch consumes. */ +function flatten( + accountName: string, + account: ProviderAccount, + agentProfileName: string, + state: State, +): ResolvedProfile { + const agentProfile = state.agentProfiles?.[agentProfileName] ?? {} + const resolved: ResolvedProfile = { + accountName, + agentProfileName, + provider: account.provider, + } + // Assigned conditionally throughout: `exactOptionalPropertyTypes` makes + // "absent" and "present but undefined" different types, and the env builder + // branches on presence — `apiKey: undefined` would not mean the same thing as + // no apiKey at all. + if (account.baseUrl !== undefined) resolved.baseUrl = account.baseUrl + if (account.apiKey !== undefined) resolved.apiKey = account.apiKey + if (account.apiKeyFromEnv !== undefined) resolved.apiKeyFromEnv = account.apiKeyFromEnv + if (account.configDir !== undefined) resolved.configDir = account.configDir + + if (agentProfile.agent !== undefined) resolved.agent = agentProfile.agent + if (agentProfile.models !== undefined) resolved.models = agentProfile.models + if (agentProfile.skipPermissions !== undefined) { + resolved.skipPermissions = agentProfile.skipPermissions + } + if (agentProfile.env !== undefined) resolved.env = agentProfile.env + if (agentProfile.compat !== undefined) resolved.compat = agentProfile.compat + if (agentProfile.contextWindows !== undefined) { + resolved.contextWindows = agentProfile.contextWindows + } + return resolved +} + +/** + * Pick one account name from the profile's list. + * + * Returns the choice AND any warning the choice deserves, because two of the + * three strategies can silently degrade — a rotation with no cursor store, or + * `usage` with no snapshot — and degrading quietly about WHICH ACCOUNT PAYS is + * the failure this whole codebase is arranged to prevent. + */ +export function selectAccount( + profileName: string, + accounts: string[], + strategy: SelectionStrategy, + { cursor = null, usage = null, now = 0 }: ResolveOptions = {}, +): { name: string; warnings: string[] } { + const warnings: string[] = [] + // `accounts[0]` is checked by the caller before this runs; the `?? ''` is for + // `noUncheckedIndexedAccess` and is unreachable. + const first = accounts[0] ?? '' + if (accounts.length === 1 || strategy === 'single') return { name: first, warnings } + + if (strategy === 'round-robin') { + if (!cursor) { + warnings.push( + `profile "${profileName}" rotates between accounts, but no cursor store is ` + + `available, so it used "${first}". Every launch will use the same account.`, + ) + return { name: first, warnings } + } + const previous = cursor.read(profileName) ?? -1 + const index = (previous + 1) % accounts.length + cursor.advance(profileName, index) + return { name: accounts[index] ?? first, warnings } + } + + // 'usage' + if (!usage) { + warnings.push( + `profile "${profileName}" selects by remaining capacity, but nothing has measured ` + + `it yet, so it used "${first}". Run \`swisscode config doctor\` to refresh usage.`, + ) + return { name: first, warnings } + } + const known = accounts.filter((a) => typeof usage.remaining[a] === 'number') + if (known.length === 0) { + warnings.push( + `profile "${profileName}" selects by remaining capacity, but none of its accounts ` + + `reports any, so it used "${first}".`, + ) + return { name: first, warnings } + } + // Highest remaining wins; ties keep the earlier-listed account, so the order + // the user wrote is the tiebreak rather than object-key order. + let best = known[0] ?? first + for (const name of known) { + if ((usage.remaining[name] ?? -1) > (usage.remaining[best] ?? -1)) best = name + } + const ageMinutes = Math.max(0, Math.round((now - usage.checkedAt) / 60_000)) + warnings.push( + `selected "${best}" by remaining capacity, measured ${ageMinutes} minute(s) ago. ` + + 'swisscode cannot check this at launch, so the figure is as fresh as the last check.', + ) + return { name: best, warnings } +} + +/** + * Resolve a profile by name. + * + * Every failure names the fix rather than reporting a shape mismatch: a config + * that dangles is something a person has to repair, and "profile X references + * agent profile Y, which does not exist" is the whole diagnosis. + */ +export function resolveProfileRefs( + state: State, + profileName: string, + options: ResolveOptions = {}, +): Resolution { + const profile = state.profiles?.[profileName] + if (!profile) return { ok: false, reason: `profile "${profileName}" does not exist.` } + + const agentProfileName = profile.agentProfile + if (!agentProfileName || !state.agentProfiles?.[agentProfileName]) { + return { + ok: false, + reason: + `profile "${profileName}" uses agent profile "${agentProfileName ?? '—'}", which does ` + + 'not exist. Run `swisscode config ' + profileName + '` to repair it.', + } + } + + const accounts = Array.isArray(profile.accounts) ? profile.accounts.filter(Boolean) : [] + if (accounts.length === 0) { + return { + ok: false, + reason: + `profile "${profileName}" has no provider account, so there is nothing to ` + + 'authenticate with. Run `swisscode config ' + profileName + '` to add one.', + } + } + + // Dangling account references are dropped with a warning rather than being + // fatal: a profile with three accounts and one stale reference should still + // launch on the other two. + const warnings: string[] = [] + const live = accounts.filter((name) => { + if (state.providerAccounts?.[name]) return true + warnings.push( + `profile "${profileName}" references account "${name}", which no longer exists; skipping it.`, + ) + return false + }) + + if (live.length === 0) { + return { + ok: false, + reason: + `profile "${profileName}" references ${accounts.length} provider account(s), none of ` + + 'which exist any more. Run `swisscode config accounts` to see what is left.', + } + } + + const strategy = profile.strategy ?? 'single' + const picked = selectAccount(profileName, live, strategy, options) + warnings.push(...picked.warnings) + + // Present: `live` was filtered on exactly this lookup. + const account = state.providerAccounts?.[picked.name] + if (!account) { + return { ok: false, reason: `account "${picked.name}" disappeared during resolution.` } + } + + return { + ok: true, + resolved: flatten(picked.name, account, agentProfileName, state), + warnings, + } +} diff --git a/src/ports/agent.ts b/src/ports/agent.ts index aaac770..f887e60 100644 --- a/src/ports/agent.ts +++ b/src/ports/agent.ts @@ -11,7 +11,7 @@ // Type-only, like every port: `export {}` at runtime. import type { ExtendedContext, ProviderDescriptor, Tier, TierRecord } from './provider.ts' -import type { Profile } from './config-store.ts' +import type { ResolvedProfile } from './config-store.ts' import type { AgentBinarySpec, EnvMap } from './process.ts' /** @@ -56,6 +56,19 @@ export type LaunchIntent = { skipPermissions: boolean extendedContext?: ExtendedContext | null contextWindows?: Record + /** + * A directory holding a login the agent already performed, or absent. + * + * The NEUTRAL half of session-mode authentication. An account can present a + * key, or it can point at somewhere the agent keeps its own credential — a + * Claude Code subscription being the shipped case. Which environment variable + * carries it is the adapter's business, exactly as it is for the credential. + * + * Spelled "session directory" rather than after any agent's variable because + * core/ builds this intent, and test/architecture.test.ts forbids core/ from + * naming an agent's dialect in emitted code. + */ + sessionDir?: string | null } /** @@ -74,6 +87,15 @@ export type AgentCapabilities = { skipPermissions: boolean extendedContextSuffix: boolean compatFlags: boolean + /** + * Can this CLI be pointed at an existing login directory? + * + * Claude Code can (CLAUDE_CONFIG_DIR). Kilo and OpenCode take a credential + * inline and have no equivalent, so an account in session mode gives them + * nothing to authenticate with — which they must WARN about rather than + * launch unauthenticated, the same rule tier-collapse already follows. + */ + sessionDir: boolean } /** @@ -84,7 +106,7 @@ export type AgentCapabilities = { */ export type TranslateInput = { intent: LaunchIntent - profile: Profile + profile: ResolvedProfile provider: ProviderDescriptor | null passthrough: string[] ambient: EnvMap diff --git a/src/ports/config-store.ts b/src/ports/config-store.ts index ee01da8..60f1807 100644 --- a/src/ports/config-store.ts +++ b/src/ports/config-store.ts @@ -7,7 +7,7 @@ // an adapter obligation (fs-config-store re-asserts both on every write) and // `ConfigModes` below is how `config doctor` reads it back to check. -import type { ClaudeCodeCompatFlags } from './claude-code.ts' +import type { ClaudeCodeCompatFlags, ClaudeCodeCredentialEnv } from './claude-code.ts' import type { Tier } from './provider.ts' /** @@ -20,23 +20,73 @@ import type { Tier } from './provider.ts' * carry an explicit env/unsetEnv split instead); this is the profile side, * where a user needs a way to say "clear whatever my shell put there". */ -export type Profile = { - /** id from the provider registry */ +/** + * WHO PAYS. A provider, a credential, and where to send it. + * + * First-class because a credential is not a property of one launch + * configuration — the same OpenRouter key can back a dozen profiles, and an + * Anthropic subscription is a thing you HAVE rather than a thing a profile + * owns. Before v3 these fields lived on the profile, which is why + * `core/overrides.ts` had to go scavenging through other profiles for a key + * when `--cc-provider` retargeted: a lookup written as a search, because there + * was nothing to look up. + * + * The credential-safety rule becomes STRUCTURAL here rather than a discipline: + * an account names exactly one provider, so a key cannot reach a host it was + * not entered for without someone rewriting the account. + */ +export type ProviderAccount = { + /** id from the provider registry (shipped preset or custom) */ provider: string - /** - * id from the agent registry — which coding CLI to launch. Absent means the - * default, 'claude-code', so every profile written before this field existed - * keeps launching Claude Code with no migration. A profile naming an agent - * this build does not know still launches Claude Code (launch-root refuses - * only an explicit --cc-agent for an unknown id). - */ - agent?: string label?: string + /** overrides the descriptor's endpoint */ baseUrl?: string /** inline; the file is 0600 */ apiKey?: string /** read from the ambient env instead, so no secret sits in the file */ apiKeyFromEnv?: string + /** + * SESSION MODE: a directory holding a login the agent already performed. + * + * The third way to authenticate, and the odd one out — there is no secret + * here at all, only a path to somewhere the agent keeps its own credential. + * A Claude Code subscription works this way: you log in once with the + * official OAuth flow inside this directory, and every launch that names the + * account points the agent back at it. + * + * MUTUALLY EXCLUSIVE with `apiKey`/`apiKeyFromEnv`. An account carrying both + * is refused rather than resolved by precedence: "which credential did this + * actually use" is exactly the question that must never have a subtle answer. + * + * Neutral on purpose. Which environment variable expresses it belongs to the + * agent adapter — see `LaunchIntent.sessionDir` in ports/agent.ts — because + * core/ may not name an agent's dialect, and this port is read by core. + */ + configDir?: string +} + +/** + * WHAT RUNS. A coding CLI plus how it should behave. + * + * Independent of who pays, so one setup ("Claude Code, yolo, glm on every + * tier") can be pointed at several accounts, and one account can back several + * setups. + * + * `models` stays the FOUR Claude Code tiers rather than becoming agent-shaped. + * That is deliberate and unchanged from v2: agents with fewer slots collapse it + * and warn (see `collapsedTierWarning` in adapters/agents/shared.ts), which is + * a documented, tested behaviour. Reshaping it is a separate decision that + * should not ride along with a schema migration. + */ +export type AgentProfile = { + /** + * id from the agent registry — which coding CLI to launch. Absent means the + * default, 'claude-code'. An agent profile naming an agent this build does + * not know still launches the default (launch-root refuses only an explicit + * --cc-agent for an unknown id). + */ + agent?: string + label?: string /** BARE ids. '' means UNSET the tier. Partial: pinning no models is valid. */ models?: Partial> skipPermissions?: boolean @@ -58,6 +108,76 @@ export type Profile = { contextWindows?: Record } +/** + * One account and one agent profile, flattened — what a launch actually needs. + * + * THIS IS THE SEAM THAT KEEPS THE v3 SPLIT CHEAP. It is deliberately the same + * shape v2's `Profile` had, so everything downstream of resolution — the agent + * adapters, `buildEnvPlan`, `buildIntent`, the golden maps — consumes exactly + * what it always did and needed no change when the stored schema split in + * three. The refactor lands entirely upstream of here. + * + * Produced only by `core/resolve.ts`. Never stored, never written: it is a view + * over two persisted objects, and giving it a name is what stops consumers + * reaching back into the store to re-derive it. + */ +export type ResolvedProfile = { + /** which account was selected, so callers can report it */ + accountName: string + agentProfileName: string + // ── from the provider account ── + provider: string + baseUrl?: string + apiKey?: string + apiKeyFromEnv?: string + /** session mode: a directory holding a login the agent already performed */ + configDir?: string + // ── from the agent profile ── + agent?: string + models?: Partial> + skipPermissions?: boolean + env?: Record + compat?: ClaudeCodeCompatFlags + contextWindows?: Record +} + +/** + * How a profile picks among its accounts. + * + * Every one of these resolves ONCE, before `execve`. swisscode ceases to exist + * at handoff, so there is no such thing here as per-request rotation, live + * failover, or reacting to a 429 — those require sitting in the data path, + * which is the proxy this tool is not. + * + * single the first account. No state, no surprises. + * round-robin advances per LAUNCH, via a cursor kept outside config.json. + * usage picks by remaining capacity from a CACHED snapshot, refreshed + * at configuration time (doctor / web UI). Never a live call: + * the launch path may not reach the network. Falls back to + * `single` and says so when there is no snapshot. + */ +export type SelectionStrategy = 'single' | 'round-robin' | 'usage' + +/** + * THE PAIRING. What `swisscode ` actually names. + * + * Holds no credential and no agent settings of its own — only references, plus + * the rule for choosing among them. + */ +export type Profile = { + label?: string + /** key into `State.agentProfiles` */ + agentProfile: string + /** + * keys into `State.providerAccounts`, in preference order. Never empty in a + * valid config; an empty list is a resolution error that names the fix rather + * than a silent fallback to some other account. + */ + accounts: string[] + /** absent means `single` */ + strategy?: SelectionStrategy +} + /** Per-run overrides carried on a directory binding. */ export type ProfileOverrides = { baseUrl?: string @@ -84,12 +204,53 @@ export type Settings = { * The v2 config shape: named profiles. This is what `State` means everywhere * else in the codebase, and `SUPPORTED_VERSION` is 2. */ +/** + * A provider the USER defined, stored in config.json. + * + * Deliberately a SUBSET of `ProviderDescriptor`, and the omissions are the + * point. Shipped descriptors are guarded by test/registry.test.ts — no + * hand-typed [1m], no /v1 suffix, extendedContext cross-checked against + * defaultModels, compat flags proven real. None of those tests can reach a + * value that arrives from a config file, so anything they cannot guard is + * either validated at runtime (core/provider-def.ts) or not offered at all. + * + * NOT offered: + * catalogId a catalog needs a shipped adapter that knows the upstream + * JSON shape; there is nothing to point an id at. + * extendedContext the [1m] claim is exactly the one that must be VERIFIED. + * An id carrying a suffix the endpoint does not recognise + * fails hard, and one that silently ignores it is a 200K + * window wearing a 1M label. A profile's `contextWindows` + * already covers the honest need (auto-compaction) without + * asserting a capability nobody checked. + */ +export type CustomProvider = { + id: string + label: string + baseUrl: string + credentialEnv?: ClaudeCodeCredentialEnv + credentialOptional?: boolean + defaultCredential?: string + defaultModels?: Partial> + env?: Record + unsetEnv?: string[] + compat?: ClaudeCodeCompatFlags + subagentFollowsOpus?: boolean +} + export type State = { version: number + /** who pays */ + providerAccounts: Record + /** what runs */ + agentProfiles: Record + /** the pairing — what `swisscode ` and every binding refer to */ profiles: Record defaultProfile: string | null bindings: Record settings: Settings + /** user-defined providers, alongside the shipped presets */ + providers?: Record } /** @@ -116,21 +277,56 @@ export type ConfigV1 = { [key: string]: unknown } -/** The version ladder, as a type. Only these two shapes have ever shipped. */ -export type ConfigV2 = State +/** + * The v2 profile: provider, credential, agent and agent settings on ONE object. + * + * Kept as a type because `fromV2` still has to read it. This is the shape v3 + * splits into `ProviderAccount` + `AgentProfile` + `Profile`, and writing it + * down is what lets that migration be checked rather than guessed at. + * + * The index signature is rule M1 again, inherited from v1: unrecognized keys + * ride along rather than being dropped, so a key written by a newer swisscode + * survives a round-trip through an older one. + */ +export type ProfileV2 = { + provider: string + agent?: string + label?: string + baseUrl?: string + apiKey?: string + apiKeyFromEnv?: string + models?: Partial> + skipPermissions?: boolean + env?: Record + compat?: ClaudeCodeCompatFlags + contextWindows?: Record + [key: string]: unknown +} + +/** The version ladder, as types. Three shapes have shipped. */ +export type ConfigV2 = { + version: number + profiles: Record + defaultProfile: string | null + bindings: Record + settings: Settings + providers?: Record +} + +export type ConfigV3 = State /** * What the migration ladder reports. * * `migratedFrom` is the ONLY thing that authorizes a write on load. Filling in * a missing `settings` key is not a migration and must not cause a launch that - * merely read the file to touch the disk. It is `1 | null` rather than - * `number | null` because v1 is the only version that has ever been migrated - * FROM — a newer-than-supported file is `readOnly` instead. + * merely read the file to touch the disk. It enumerates the versions that can + * be migrated FROM — a newer-than-supported file is `readOnly` instead — so the + * store can name the backup after the version it is preserving. */ export type MigrateResult = { state: State - migratedFrom: 1 | null + migratedFrom: 1 | 2 | null /** the file existed but could not be understood */ corrupt: boolean /** the file is a NEWER schema; every write path is disabled */ @@ -164,6 +360,24 @@ export type ConfigStorePort = { /** returns the path written; THROWS if the file is readOnly */ save: (state: State) => string path: () => string + /** + * An opaque token for the file's current CONTENT, or null when there is no + * file yet. Optional in the same genuine sense as `modes`: a store that cannot + * derive one is still a valid store, and callers degrade rather than break. + * + * Exists for LOST-UPDATE detection, which only became reachable when a + * long-lived editor arrived. Every writer until now was a single short-lived + * command, so last-writer-wins was indistinguishable from correct. A web UI + * sitting open in a tab while `swisscode config work` runs in a terminal makes + * silent clobbering ordinary, and the clobbered field could be an API key. + * + * Honest about what it is: a check, not a lock. The window between comparing + * and writing is not closed by this — closing it needs an exclusive lock this + * project does not otherwise want. It reliably catches the interleaving people + * actually hit (read, think, write minutes later) and does not pretend to + * serialize concurrent writers. + */ + revision?: () => string | null /** * OPTIONAL, and genuinely so. The JSDoc contract this replaces did not list * `modes` at all, but composition/doctor-root.js probes for it diff --git a/src/ports/provider-usage.ts b/src/ports/provider-usage.ts new file mode 100644 index 0000000..6b963ac --- /dev/null +++ b/src/ports/provider-usage.ts @@ -0,0 +1,101 @@ +// Port: what a provider can tell you about your own remaining capacity. +// +// This is the "tools the AI provider gives us" half of the `usage` selection +// strategy, and it is CONFIGURATION-TIME ONLY. The launch path may not reach +// the network — test/architecture.test.ts bans fetch and node:http there by +// name — so nothing here is ever called during a launch. The doctor and the web +// UI call it, cache what it says, and `core/resolve.ts` reads that cache. +// +// Modelled on the Ollama introspection port in ports/doctor.ts: a small +// contract, an adapter only for providers whose endpoint has been VERIFIED +// against the live service, and `null` everywhere the fact is unknown. A +// provider that publishes nothing reports nothing rather than zero. +// +// Type-only, like every port: `export {}` at runtime. + +/** + * Remaining capacity for one account. + * + * EVERY FIELD IS NULLABLE and none is defaulted, because providers disagree + * about what they publish and a missing figure is not a zero. An account on a + * plan with no cap has a real `null` limit, which is a different fact from an + * endpoint that declines to say. + * + * `remaining` is what `usage` selection ranks on. When it is null the account + * is simply not a candidate — better to skip it than to invent a number and + * route real money by it. + */ +export type ProviderUsage = { + /** credits or budget left, in the provider's own unit; null = unknown or uncapped */ + remaining: number | null + /** the cap `remaining` counts down from; null = unknown or uncapped */ + limit: number | null + /** spent so far, if published */ + used: number | null + /** + * What the numbers are denominated in — 'usd', 'credits', 'tokens'. Free + * text because it is for DISPLAY only; nothing branches on it, and inventing + * an enum would force every future provider into one of today's guesses. + */ + unit: string | null + /** epoch ms when this was measured, so a consumer can say how stale it is */ + checkedAt: number +} + +/** + * A snapshot across accounts, which is what gets cached and what `resolve.ts` + * consumes. Keyed by ACCOUNT name, not provider: two accounts on the same + * provider have separate balances, and telling them apart is the entire reason + * this strategy exists. + */ +export type UsageSnapshot = { + remaining: Record + checkedAt: number +} + +/** + * One provider's usage endpoint. + * + * NEVER REJECTS, for the same reason the doctor's probe does not: a provider + * that is down, rate-limiting, or has changed its API is a finding to report, + * not an exception to unwind a configuration screen with. + */ +export type ProviderUsagePort = { + id: string + /** null when the endpoint answered but published nothing usable */ + fetch: (req: { + baseUrl: string + /** + * The key, for a key-mode account. EMPTY for a session account — the token + * is not swisscode's to hold, so an adapter that needs one reads it from + * `sessionDir` itself, at the moment it asks and no earlier. + */ + credential: string + /** + * A session directory, for a subscription account. Present because the two + * account modes get their credential from genuinely different places, and + * collapsing that into one string would mean reading a token out of the + * Keychain on every code path that merely wants to name an account. + */ + sessionDir?: string | null + timeoutMs?: number + }) => Promise +} + +/** + * What a subscription account has left, beyond the generic shape above. + * + * Subscriptions are not denominated in credits: the thing that bites is a + * PERCENTAGE OF A ROLLING WINDOW, and there are two windows that bite + * independently. A single `remaining` number cannot express "fine for the next + * five hours, nearly out for the week", which is exactly the state a user wants + * to route around. + */ +export type SubscriptionWindow = { + /** 0–100; how much of this window is spent */ + utilization: number | null + /** ISO 8601, when the window rolls over */ + resetsAt: string | null +} + +export {} diff --git a/src/ports/provider.ts b/src/ports/provider.ts index 29fa383..65d4855 100644 --- a/src/ports/provider.ts +++ b/src/ports/provider.ts @@ -137,6 +137,15 @@ export type ProviderDescriptor = { extendedContext?: ExtendedContext /** id of a ModelCatalogPort, or null when this provider publishes no catalog */ catalogId?: string | null + /** + * id of a ProviderUsagePort, or absent when this provider publishes no + * remaining-capacity endpoint — which is most of them. + * + * Absent means UNKNOWN, never zero. Only wire this for a provider whose + * endpoint has been verified against the live service; a speculative entry + * would route real money by a number nobody checked. + */ + usageId?: string | null subagentFollowsOpus?: boolean hints?: ProviderHints } diff --git a/test/adapters/agents/kilo.test.ts b/test/adapters/agents/kilo.test.ts index ef9df8f..1ddba9a 100644 --- a/test/adapters/agents/kilo.test.ts +++ b/test/adapters/agents/kilo.test.ts @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { kilo, KILO_CONFIG_ENV, KILO_ALLOW_ALL } from '../../../src/adapters/agents/kilo/index.ts' import type { LaunchIntent, TranslateInput } from '../../../src/ports/agent.ts' +import { makeProfile } from '../../support/fixtures.ts' function intent(over: Partial = {}): LaunchIntent { return { @@ -15,7 +16,7 @@ function intent(over: Partial = {}): LaunchIntent { } function run(i: LaunchIntent, passthrough: string[] = []) { - const input: TranslateInput = { intent: i, profile: { provider: 'zai' }, provider: null, passthrough, ambient: {} } + const input: TranslateInput = { intent: i, profile: makeProfile({ provider: 'zai' }), provider: null, passthrough, ambient: {} } const t = kilo.translate(input) const raw = t.plan.set[KILO_CONFIG_ENV] return { t, config: raw ? JSON.parse(raw) : null } diff --git a/test/adapters/agents/opencode.test.ts b/test/adapters/agents/opencode.test.ts index 1b2b6f7..092fa88 100644 --- a/test/adapters/agents/opencode.test.ts +++ b/test/adapters/agents/opencode.test.ts @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { opencode, OPENCODE_CONFIG_ENV, OPENCODE_AUTO_FLAG } from '../../../src/adapters/agents/opencode/index.ts' import type { LaunchIntent, TranslateInput } from '../../../src/ports/agent.ts' +import { makeProfile } from '../../support/fixtures.ts' function intent(over: Partial = {}): LaunchIntent { return { @@ -15,7 +16,7 @@ function intent(over: Partial = {}): LaunchIntent { } function run(i: LaunchIntent, passthrough: string[] = []) { - const input: TranslateInput = { intent: i, profile: { provider: 'zai' }, provider: null, passthrough, ambient: {} } + const input: TranslateInput = { intent: i, profile: makeProfile({ provider: 'zai' }), provider: null, passthrough, ambient: {} } const t = opencode.translate(input) const raw = t.plan.set[OPENCODE_CONFIG_ENV] return { t, config: raw ? JSON.parse(raw) : null } diff --git a/test/adapters/anthropic-subscription-usage.test.ts b/test/adapters/anthropic-subscription-usage.test.ts new file mode 100644 index 0000000..4e6c0ba --- /dev/null +++ b/test/adapters/anthropic-subscription-usage.test.ts @@ -0,0 +1,201 @@ +// Subscription usage: parsing, ranking, and refusing to guess. +// +// The payload below is the REAL one, captured from +// GET https://api.anthropic.com/api/oauth/usage on a live Max 20x account, with +// only the figures adjusted per test. Every field name here was measured — one +// of them (`is_enabled`) was wrong in the first draft, and a wrong field name +// fails silently forever, which is why the fixture is real rather than invented. +import test from 'node:test' +import assert from 'node:assert/strict' +import { + anthropicSubscriptionUsage, + remainingFrom, + type SubscriptionUsage, +} from '../../src/adapters/usage/anthropic-subscription.ts' + +const REAL_PAYLOAD = { + five_hour: { + utilization: 37, + resets_at: '2026-07-22T15:10:00.342470+00:00', + limit_dollars: null, + used_dollars: null, + remaining_dollars: null, + }, + seven_day: { + utilization: 73, + resets_at: '2026-07-25T17:00:00.425040+00:00', + limit_dollars: null, + used_dollars: null, + remaining_dollars: null, + }, + // Both null on the live account — a real state, not an oversight. + seven_day_opus: null, + seven_day_sonnet: null, + extra_usage: { + is_enabled: false, + monthly_limit: null, + used_credits: null, + utilization: null, + currency: null, + decimal_places: null, + disabled_reason: null, + }, + limits: [ + { kind: 'session', group: 'session', percent: 37, severity: 'normal', is_active: false }, + ], +} + +/** Stand in for the network without touching it. */ +function withFetch(handler: (url: string, init: RequestInit) => Response, run: () => T): T { + const original = globalThis.fetch + globalThis.fetch = ((url: string, init: RequestInit) => + Promise.resolve(handler(url, init))) as typeof fetch + try { + return run() + } finally { + globalThis.fetch = original + } +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + +const fetchUsage = (body: unknown, status = 200) => + withFetch( + () => json(body, status), + () => + anthropicSubscriptionUsage.fetch({ + baseUrl: 'https://api.anthropic.com', + credential: 'tok', + }), + ) + +test('ranking takes the WORSE window, never the average', () => { + // The failure this prevents: an account 10% into its 5-hour window and 95% + // into its weekly one averages to 52% and looks healthy, when it has almost + // nothing left. The limits bite independently. + assert.equal(remainingFrom({ utilization: 10, resetsAt: null }, { utilization: 95, resetsAt: null }), 5) + assert.equal(remainingFrom({ utilization: 37, resetsAt: null }, { utilization: 73, resetsAt: null }), 27) +}) + +test('an account that publishes nothing is UNKNOWN, not full', () => { + // Returning 100 here would make a silent account out-rank one that honestly + // reported 5% used — routing real money by the absence of information. + assert.equal(remainingFrom({ utilization: null, resetsAt: null }, { utilization: null, resetsAt: null }), null) +}) + +test('utilisation over 100 floors at zero rather than going negative', () => { + assert.equal(remainingFrom({ utilization: 130, resetsAt: null }, { utilization: 0, resetsAt: null }), 0) +}) + +test('the real payload parses, with both windows and the reset times', async () => { + const u = (await fetchUsage(REAL_PAYLOAD)) as SubscriptionUsage | null + assert.ok(u) + assert.equal(u.remaining, 27) // 100 - max(37, 73) + assert.equal(u.limit, 100) + assert.equal(u.used, 73) + assert.equal(u.fiveHour.utilization, 37) + assert.equal(u.sevenDay.utilization, 73) + assert.equal(u.sevenDay.resetsAt, '2026-07-25T17:00:00.425040+00:00') +}) + +test('extra usage reads `is_enabled` — the name that is actually in the payload', () => { + // A wrong field name here fails SILENTLY and forever: it yields null, which + // is indistinguishable from an endpoint that did not publish the field. + assert.equal(Object.keys(REAL_PAYLOAD.extra_usage).includes('is_enabled'), true) + assert.equal(Object.keys(REAL_PAYLOAD.extra_usage).includes('enabled'), false) +}) + +test('extra usage parses to a real boolean, not null', async () => { + const off = (await fetchUsage(REAL_PAYLOAD)) as SubscriptionUsage + assert.equal(off.extraUsage, false) + const on = (await fetchUsage({ + ...REAL_PAYLOAD, + extra_usage: { ...REAL_PAYLOAD.extra_usage, is_enabled: true }, + })) as SubscriptionUsage + assert.equal(on.extraUsage, true) +}) + +test('null per-model windows stay null — never zero, never unlimited', async () => { + const u = (await fetchUsage(REAL_PAYLOAD)) as SubscriptionUsage + assert.equal(u.sevenDayOpus.utilization, null) + assert.equal(u.sevenDaySonnet.utilization, null) +}) + +test('a per-model window does NOT drag the ranking down', async () => { + // An exhausted Opus window must not disqualify an account for a profile that + // runs Sonnet. It is reported; it is not ranked on. + const u = (await fetchUsage({ + ...REAL_PAYLOAD, + seven_day_opus: { utilization: 99, resets_at: null }, + })) as SubscriptionUsage + assert.equal(u.remaining, 27, 'ranking still comes from the two universal windows') + assert.equal(u.sevenDayOpus.utilization, 99, 'but it is still reported') +}) + +test('an HTTP error is a null finding, never a throw', async () => { + for (const status of [401, 403, 429, 500]) { + assert.equal(await fetchUsage(REAL_PAYLOAD, status), null, `HTTP ${status} should read as null`) + } +}) + +test('a changed payload shape degrades to null instead of inventing a figure', async () => { + assert.equal(await fetchUsage({}), null) + assert.equal(await fetchUsage({ five_hour: 'nonsense', seven_day: null }), null) + assert.equal(await fetchUsage(null), null) +}) + +test('a network failure is a null finding', async () => { + const result = await withFetch( + () => { + throw new Error('ECONNREFUSED') + }, + () => + anthropicSubscriptionUsage.fetch({ baseUrl: 'https://api.anthropic.com', credential: 'tok' }), + ) + assert.equal(result, null) +}) + +test('it sends the OAuth beta header, without which the endpoint refuses', async () => { + let seen: RequestInit | undefined + await withFetch( + (_url, init) => { + seen = init + return json(REAL_PAYLOAD) + }, + () => + anthropicSubscriptionUsage.fetch({ baseUrl: 'https://api.anthropic.com', credential: 'tok' }), + ) + const headers = seen?.headers as Record + assert.equal(headers['anthropic-beta'], 'oauth-2025-04-20') + assert.equal(headers.authorization, 'Bearer tok') +}) + +test('it hits the right path, tolerating a trailing slash on the base url', async () => { + let url = '' + await withFetch( + (u) => { + url = u + return json(REAL_PAYLOAD) + }, + () => + anthropicSubscriptionUsage.fetch({ + baseUrl: 'https://api.anthropic.com/', + credential: 'tok', + }), + ) + assert.equal(url, 'https://api.anthropic.com/api/oauth/usage') +}) + +test('with no credential and no session it asks nothing at all', async () => { + let called = false + const result = await withFetch( + () => { + called = true + return json(REAL_PAYLOAD) + }, + () => anthropicSubscriptionUsage.fetch({ baseUrl: 'https://api.anthropic.com', credential: '' }), + ) + assert.equal(result, null) + assert.equal(called, false, 'no token means no request, not an anonymous one') +}) diff --git a/test/adapters/claude-session-credentials.test.ts b/test/adapters/claude-session-credentials.test.ts new file mode 100644 index 0000000..c0e5abf --- /dev/null +++ b/test/adapters/claude-session-credentials.test.ts @@ -0,0 +1,196 @@ +// Reading a session's OAuth token. +// +// The unhashed branch is verified against the REAL Keychain item on a live +// machine: `Claude Code-credentials`, read successfully, subscriptionType +// 'max'. These tests pin the derivation and — more importantly — the refusals. +import test from 'node:test' +import assert from 'node:assert/strict' +import { + credentialFilePath, + keychainService, + readSessionCredential, +} from '../../src/adapters/claude-session/credentials.ts' + +const HOME = '/home/u' +const env = { HOME } +const DEFAULT_DIR = `${HOME}/.claude` +const CUSTOM_DIR = `${HOME}/.claude-work` + +/** The envelope Claude Code actually stores. */ +const envelope = (over: Record = {}) => + JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-oat-TOKEN', + refreshToken: 'sk-ant-ort-REFRESH', + expiresAt: 4_000_000_000_000, + scopes: ['user:inference'], + subscriptionType: 'max', + ...over, + }, + }) + +const fail = (status: number) => () => { + const e = new Error('security failed') as Error & { status: number } + e.status = status + throw e +} + +test('the DEFAULT directory uses the unhashed service name', () => { + // Verified against the real Keychain: this exact string is the live item. + assert.equal(keychainService(DEFAULT_DIR, env), 'Claude Code-credentials') +}) + +test('a custom directory hashes the path into the name, and does so stably', () => { + const name = keychainService(CUSTOM_DIR, env) + assert.match(name, /^Claude Code-credentials-[0-9a-f]{8}$/) + assert.equal(name, keychainService(CUSTOM_DIR, env), 'derivation must be deterministic') + assert.notEqual(name, keychainService(`${HOME}/.claude-other`, env)) +}) + +test('the hash covers the string that would be WRITTEN to the variable', () => { + // Not a re-normalised spelling of it. Normalising here but not in the env + // lowering would produce a name that is right in every test and wrong on + // every machine, because the agent hashes what it was actually given. + assert.notEqual( + keychainService(CUSTOM_DIR, env), + keychainService(`${CUSTOM_DIR}/`, env), + 'a different string is a different keychain item, and pretending otherwise reads the wrong account', + ) +}) + +test('the credential file has NO home/dir asymmetry, unlike .claude.json', () => { + assert.equal(credentialFilePath(DEFAULT_DIR), `${DEFAULT_DIR}/.credentials.json`) + assert.equal(credentialFilePath(CUSTOM_DIR), `${CUSTOM_DIR}/.credentials.json`) +}) + +test('a keychain hit is parsed, and the refresh token is NOT carried', () => { + const r = readSessionCredential(DEFAULT_DIR, { + env, + platform: 'darwin', + keychain: () => envelope(), + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.credential.accessToken, 'sk-ant-oat-TOKEN') + assert.equal(r.credential.subscriptionType, 'max') + assert.equal(r.credential.source, 'keychain') + assert.equal(r.expired, false) + // Nothing in swisscode may refresh, so nothing carries a refresh token — + // it is not in the type and must not be in the value. + assert.equal('refreshToken' in r.credential, false) + assert.doesNotMatch(JSON.stringify(r.credential), /REFRESH/) +}) + +test('an expired token is REPORTED, never refreshed', () => { + // Refreshing needs Anthropic's own OAuth client id — the impersonation line + // this design stays behind. The agent refreshes it itself on next use. + const r = readSessionCredential(DEFAULT_DIR, { + env, + platform: 'darwin', + keychain: () => envelope({ expiresAt: 500 }), + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.expired, true) + assert.equal(r.credential.accessToken, 'sk-ant-oat-TOKEN', 'still identifies the account') +}) + +test('a dismissed prompt is DENIED, not "never logged in"', () => { + // These send the user to fix completely different things, so conflating them + // would send them to fix the wrong one. + const r = readSessionCredential(DEFAULT_DIR, { + env, + platform: 'darwin', + keychain: fail(1), + readFile: () => { + throw new Error('ENOENT') + }, + }) + assert.equal(r.ok, false) + assert.equal(r.kind, 'denied') + assert.match(r.reason, /nothing is wrong with the account/) +}) + +test('keychain exit 44 means the item is simply absent', () => { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'darwin', + keychain: fail(44), + readFile: () => { + throw new Error('ENOENT') + }, + }) + assert.equal(r.ok, false) + assert.equal(r.kind, 'absent') + assert.match(r.reason, /accounts login/) +}) + +test('off macOS it reads the file, and says so', () => { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'linux', + keychain: () => assert.fail('the keychain must not be consulted off macOS'), + readFile: (p) => { + assert.equal(p, `${CUSTOM_DIR}/.credentials.json`) + return envelope() + }, + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.credential.source, 'file') +}) + +test('on macOS a missing keychain item still falls through to the file', () => { + // Some macOS setups end up with a file too. Trying both is what works, rather + // than what the platform table says should. + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'darwin', + keychain: fail(44), + readFile: () => envelope(), + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.credential.source, 'file') +}) + +test('a bare (unwrapped) envelope is tolerated', () => { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'linux', + readFile: () => JSON.stringify({ accessToken: 'sk-bare', expiresAt: null }), + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.credential.accessToken, 'sk-bare') + assert.equal(r.credential.expiresAt, null) +}) + +test('garbage never becomes a credential', () => { + for (const body of ['', 'not json', '{}', '{"claudeAiOauth":{}}', '{"claudeAiOauth":{"accessToken":""}}', 'null']) { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'linux', + readFile: () => body, + }) + assert.equal(r.ok, false, `${JSON.stringify(body)} must not parse as a credential`) + } +}) + +test('a failure reason never contains a token', () => { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'darwin', + keychain: () => envelope(), + now: 1_000, + }) + assert.ok(r.ok) + // …and the negative case: nothing on the failure branch echoes input. + const bad = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'linux', + readFile: () => envelope({ accessToken: '' }), + }) + assert.equal(bad.ok, false) + assert.doesNotMatch(bad.reason, /sk-ant|TOKEN|REFRESH/) +}) diff --git a/test/adapters/claude-session-identity.test.ts b/test/adapters/claude-session-identity.test.ts new file mode 100644 index 0000000..37224a5 --- /dev/null +++ b/test/adapters/claude-session-identity.test.ts @@ -0,0 +1,154 @@ +// Reading who a session directory is logged in as. +// +// Every payload below is shaped after the REAL `~/.claude.json` on a live Max +// 20x account, including the fields that turned out to be null. That is the +// point of the file: the obvious-looking fields are the empty ones. +import test from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { + configFilePath, + describeIdentity, + readSessionIdentity, +} from '../../src/adapters/claude-session/identity.ts' + +const HOME = '/home/u' +const env = { HOME } + +/** A reader over an in-memory filesystem, so no test touches a real dot-file. */ +const reader = (files: Record) => (p: string) => { + const content = files[p] + if (content === undefined) throw new Error(`ENOENT: ${p}`) + return content +} + +// Verbatim field names and values from the live account, minus the identifying +// parts. `seatTier` and `userRateLimitTier` really are null on a Max 20x plan. +const REAL_MAX_ACCOUNT = { + oauthAccount: { + accountUuid: 'c0bdf393-0000-0000-0000-000000000000', + emailAddress: 'someone@example.com', + organizationUuid: '023a272a-0000-0000-0000-000000000000', + hasExtraUsageEnabled: false, + billingType: 'stripe_subscription', + ccOnboardingFlags: {}, + claudeCodeTrialEndsAt: null, + seatTier: null, + displayName: 'Someone', + organizationRole: 'admin', + workspaceRole: null, + organizationName: "someone@example.com's Organization", + organizationType: 'claude_max', + organizationRateLimitTier: 'default_claude_max_20x', + userRateLimitTier: null, + }, +} + +test('the default directory keeps its config file OUTSIDE itself', () => { + // ~/.claude.json is a SIBLING of ~/.claude, not a child. Getting this + // backwards reads nothing and reports every account as logged out. + assert.equal(configFilePath(join(HOME, '.claude'), env), join(HOME, '.claude.json')) +}) + +test('a custom directory keeps it INSIDE itself', () => { + // Confirmed by running the agent against a throwaway dir: it created + // /.claude.json and left ~/.claude.json untouched. + assert.equal(configFilePath('/srv/work', env), '/srv/work/.claude.json') +}) + +test('a trailing slash does not turn the default directory into a custom one', () => { + assert.equal(configFilePath(`${HOME}/.claude/`, env), join(HOME, '.claude.json')) +}) + +test('a missing custom config file does NOT fall back to the home one', () => { + // The failure this prevents: reporting the DEFAULT account's identity for a + // directory that is not it — the silently-wrong-account bug, in the surface + // whose entire job is to tell the accounts apart. + const files = { [join(HOME, '.claude.json')]: JSON.stringify(REAL_MAX_ACCOUNT) } + assert.equal(readSessionIdentity('/srv/work', { env, readFile: reader(files) }), null) +}) + +test('the plan comes from organizationRateLimitTier, because the obvious fields are null', () => { + const files = { '/srv/work/.claude.json': JSON.stringify(REAL_MAX_ACCOUNT) } + const id = readSessionIdentity('/srv/work', { env, readFile: reader(files) }) + assert.ok(id) + assert.equal(id.plan, 'Max 20x') + assert.equal(id.email, 'someone@example.com') + assert.equal(id.extraUsage, false) +}) + +test('an unrecognised tier is shown verbatim rather than hidden', () => { + // A plan Anthropic ships next year should read as its raw id, not as blank. + const files = { + '/srv/work/.claude.json': JSON.stringify({ + oauthAccount: { emailAddress: 'a@b.c', organizationRateLimitTier: 'default_claude_ultra' }, + }), + } + const id = readSessionIdentity('/srv/work', { env, readFile: reader(files) }) + assert.equal(id?.plan, 'default_claude_ultra') +}) + +test('a fresh, never-logged-in directory reads as null', () => { + // The real shape: the agent creates .claude.json on first run with machineID, + // userID and projects — and no oauthAccount at all. + const files = { + '/srv/work/.claude.json': JSON.stringify({ + firstStartTime: '2026-01-01', + machineID: 'abc', + userID: 'def', + projects: {}, + }), + } + assert.equal(readSessionIdentity('/srv/work', { env, readFile: reader(files) }), null) +}) + +test('a corrupt or unreadable file reads as null rather than throwing', () => { + for (const content of ['', 'not json', 'null', '[]', '{"oauthAccount": 42}']) { + const files = { '/srv/work/.claude.json': content } + assert.equal( + readSessionIdentity('/srv/work', { env, readFile: reader(files) }), + null, + `${JSON.stringify(content)} should read as null`, + ) + } + assert.equal(readSessionIdentity('/srv/work', { env, readFile: reader({}) }), null) +}) + +test('an oauthAccount with nothing recognisable in it is not an identity', () => { + const files = { '/srv/work/.claude.json': JSON.stringify({ oauthAccount: { foo: 'bar' } }) } + assert.equal(readSessionIdentity('/srv/work', { env, readFile: reader(files) }), null) +}) + +test('the description names the account a user would recognise', () => { + assert.equal(describeIdentity(null), 'not logged in') + assert.equal(describeIdentity({ email: 'a@b.c', plan: 'Max 20x' }), 'a@b.c · Max 20x') + // No email: the org name is a poor substitute but better than nothing. On a + // personal plan it is literally "'s Organization", which is why it is + // last rather than first. + assert.equal(describeIdentity({ organizationName: 'Acme' }), 'Acme') + assert.equal(describeIdentity({}), 'logged in') +}) + +test('reading an identity never reveals a credential', () => { + // `.claude.json` holds far more than oauthAccount — project histories, and on + // some setups auth-adjacent bookkeeping. Only the identity fields come out. + const files = { + '/srv/work/.claude.json': JSON.stringify({ + ...REAL_MAX_ACCOUNT, + projects: { '/secret/client-work': { history: ['do not leak me'] } }, + claudeCodeFirstTokenDate: '2026-01-01', + }), + } + const id = readSessionIdentity('/srv/work', { env, readFile: reader(files) }) + const serialised = JSON.stringify(id) + assert.doesNotMatch(serialised, /secret|leak|history/) + assert.deepEqual(Object.keys(id ?? {}).sort(), [ + 'accountUuid', + 'displayName', + 'email', + 'extraUsage', + 'organizationName', + 'organizationUuid', + 'plan', + ]) +}) diff --git a/test/adapters/claude-session-onboard.test.ts b/test/adapters/claude-session-onboard.test.ts new file mode 100644 index 0000000..f4f820c --- /dev/null +++ b/test/adapters/claude-session-onboard.test.ts @@ -0,0 +1,226 @@ +// `config accounts login` — adopting a subscription. +// +// The thing under test is mostly REFUSAL. Creating a directory is trivial; the +// value is in what it declines to do quietly — retarget an existing login, +// mix a key with a session, or hand the agent a polluted environment that would +// make `/login` appear to do nothing. +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { accountLogin, validateAccountName } from '../../src/adapters/claude-session/onboard.ts' +import { registry as agentRegistry } from '../../src/adapters/agents/registry.ts' +import type { State } from '../../src/ports/config-store.ts' + +function harness(state: Partial = {}, envOver: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'swisscode-onboard-')) + const out: string[] = [] + const err: string[] = [] + const saved: State[] = [] + const replaced: { bin: string; argv: string[]; env: Record }[] = [] + + const store = { + load: () => ({ + state: { version: 3, profiles: {}, bindings: {}, ...state } as State, + warnings: [], + readOnly: false, + }), + save: (s: State) => { + saved.push(s) + }, + } + const proc = { + env: () => ({ HOME: root, XDG_CONFIG_HOME: join(root, 'config'), ...envOver }), + cwd: () => root, + resolveBinary: () => '/usr/local/bin/claude', + replace: (bin: string, argv: string[], env: Record) => { + replaced.push({ bin, argv, env }) + }, + } + const run = (args: Parameters[0]) => accountLogin(args) + return { root, out, err, saved, replaced, store, proc, run } +} + +const base = (h: ReturnType) => ({ + store: h.store as never, + agents: agentRegistry, + proc: h.proc as never, + out: (l: string) => h.out.push(l), + err: (l: string) => h.err.push(l), +}) + +test('a name that would escape the accounts directory is refused, not sanitised', () => { + // A rejected name costs a second to fix. A sanitised one puts a login + // somewhere the user did not ask for and cannot find. + for (const bad of ['../evil', 'a/b', '.hidden', '', 'x/../../y']) { + assert.equal(validateAccountName(bad).ok, false, `${JSON.stringify(bad)} should be refused`) + } + for (const good of ['personal', 'work-2', 'a.b_c', 'x1']) { + assert.equal(validateAccountName(good).ok, true, `${good} should be allowed`) + } +}) + +test('it creates the session directory at 0700 and records the account', () => { + const h = harness() + const code = h.run({ ...base(h), name: 'personal' }) + assert.equal(code, 0) + + const dir = join(h.root, 'config', 'swisscode', 'accounts', 'personal') + assert.equal(statSync(dir).mode & 0o777, 0o700, 'a directory about to hold a login must be 0700') + assert.equal(h.saved.at(-1)?.providerAccounts?.personal?.configDir, dir) + assert.equal(h.saved.at(-1)?.providerAccounts?.personal?.provider, 'anthropic') + assert.equal(h.saved.at(-1)?.providerAccounts?.personal?.apiKey, undefined) +}) + +test('it hands the agent a CLEAN environment, or /login would appear to do nothing', () => { + // With either credential variable set, the agent authenticates as whoever + // that key belongs to and the login flow never runs — the user sees a + // working session and assumes it worked. + const h = harness({}, { ANTHROPIC_API_KEY: 'sk-ant-stale', ANTHROPIC_AUTH_TOKEN: 'stale' }) + h.run({ ...base(h), name: 'personal' }) + + const launched = h.replaced.at(-1) + assert.ok(launched) + assert.equal(launched.bin, '/usr/local/bin/claude') + assert.equal( + launched.env.CLAUDE_CONFIG_DIR, + join(h.root, 'config', 'swisscode', 'accounts', 'personal'), + ) + assert.equal(launched.env.ANTHROPIC_API_KEY, undefined) + assert.equal(launched.env.ANTHROPIC_AUTH_TOKEN, undefined) +}) + +test('it refuses to retarget an existing account at a different directory', () => { + // Silently repointing would abandon a working login with no way back to it. + const h = harness({ + providerAccounts: { personal: { provider: 'anthropic', configDir: '/existing/place' } }, + }) + const code = h.run({ ...base(h), name: 'personal' }) + assert.equal(code, 2) + assert.equal(h.saved.length, 0, 'nothing may be written on refusal') + assert.match(h.err.join('\n'), /already uses \/existing\/place/) +}) + +test('logging in again at the SAME directory is allowed — tokens do expire', () => { + const root = mkdtempSync(join(tmpdir(), 'swisscode-relogin-')) + const existing = join(root, 'place') + const h = harness({ + providerAccounts: { personal: { provider: 'anthropic', configDir: existing } }, + }) + const code = h.run({ ...base(h), name: 'personal', dir: existing }) + assert.equal(code, 0) + assert.equal(h.replaced.length, 1) +}) + +test('it refuses to give a key-mode account a session as well', () => { + const h = harness({ providerAccounts: { personal: { provider: 'anthropic', apiKey: 'sk-x' } } }) + const code = h.run({ ...base(h), name: 'personal' }) + assert.equal(code, 2) + assert.equal(h.saved.length, 0) + assert.match(h.err.join('\n'), /key or a subscription login, never both/) +}) + +test('a relative --dir is resolved against the cwd, since this process execves away', () => { + const h = harness() + h.run({ ...base(h), name: 'personal', dir: 'sub/dir' }) + assert.equal(h.saved.at(-1)?.providerAccounts?.personal?.configDir, join(h.root, 'sub', 'dir')) +}) + +test('a read-only config refuses before creating anything', () => { + const h = harness() + const store = { ...h.store, load: () => ({ ...h.store.load(), readOnly: true }) } + const code = h.run({ ...base(h), store: store as never, name: 'personal' }) + assert.equal(code, 2) + assert.equal(h.replaced.length, 0, 'nothing may be launched when the account cannot be recorded') +}) + +test('a missing binary still records the account, and says how to finish by hand', () => { + // The account is the durable half. Losing it because the CLI is not on PATH + // would make the user redo the part that succeeded. + const h = harness() + const proc = { + ...h.proc, + resolveBinary: () => { + throw new Error('claude was not found on PATH') + }, + } + const code = h.run({ ...base(h), proc: proc as never, name: 'personal' }) + assert.equal(code, 2) + assert.equal(h.saved.length, 1, 'the account is still recorded') + assert.match(h.err.join('\n'), /CLAUDE_CONFIG_DIR=/) +}) + +test('it reports when the adopted directory is already logged in', () => { + const h = harness() + const dir = join(h.root, 'adopted') + mkdirSync(dir, { recursive: true, mode: 0o700 }) + writeFileSync( + join(dir, '.claude.json'), + JSON.stringify({ + oauthAccount: { emailAddress: 'a@b.c', organizationRateLimitTier: 'default_claude_max_5x' }, + }), + ) + h.run({ ...base(h), name: 'adopted', dir }) + assert.match(h.out.join('\n'), /already logged in as a@b\.c {2}· {2}Max 5x/) +}) + +test('an adopted world-readable directory earns a warning rather than a silent chmod', () => { + const h = harness() + const dir = join(h.root, 'loose') + mkdirSync(dir, { recursive: true, mode: 0o755 }) + h.run({ ...base(h), name: 'loose', dir }) + assert.match(h.err.join('\n'), /readable by other users/) + // …and it still proceeds: narrowing someone else's directory under their feet + // is not this command's call to make. + assert.equal(h.replaced.length, 1) +}) + +test('adopting the DEFAULT directory takes the existing login and launches nothing', () => { + // The trap this guards: Claude Code keys its credential on whether + // CLAUDE_CONFIG_DIR is SET, not on its value, so `--dir ~/.claude` must not + // send the user through a second /login — and must not launch at all, since + // logging in again would replace the login being adopted. + const root = mkdtempSync(join(tmpdir(), 'swisscode-default-')) + const home = join(root, 'home') + mkdirSync(join(home, '.claude'), { recursive: true }) + writeFileSync( + join(home, '.claude.json'), // a SIBLING of ~/.claude, not a child + JSON.stringify({ + oauthAccount: { emailAddress: 'me@example.com', organizationRateLimitTier: 'default_claude_max_20x' }, + }), + ) + const h = harness({}, { HOME: home }) + const code = h.run({ ...base(h), name: 'default', dir: join(home, '.claude') }) + + assert.equal(code, 0) + assert.equal(h.replaced.length, 0, 'adopting an existing login must not start a login flow') + assert.match(h.out.join('\n'), /adopted your existing login: me@example\.com {2}· {2}Max 20x/) + assert.equal(h.saved.at(-1)?.providerAccounts?.default?.configDir, join(home, '.claude')) +}) + +test('the default directory earns no permissions warning — swisscode did not create it', () => { + // Claude Code makes ~/.claude at 0755. A warning that fires on a stock + // install, for something swisscode neither made nor worsened, is noise. + const root = mkdtempSync(join(tmpdir(), 'swisscode-defperm-')) + const home = join(root, 'home') + mkdirSync(join(home, '.claude'), { recursive: true, mode: 0o755 }) + const h = harness({}, { HOME: home }) + h.run({ ...base(h), name: 'default', dir: join(home, '.claude') }) + assert.doesNotMatch(h.err.join('\n'), /readable by other users/) +}) + +test('the recorded config is exactly one account and nothing else', () => { + const h = harness({ profiles: { p: { agentProfile: 'a', accounts: [] } } } as Partial) + h.run({ ...base(h), name: 'personal' }) + const saved = h.saved.at(-1)! + assert.deepEqual(Object.keys(saved.providerAccounts ?? {}), ['personal']) + assert.deepEqual(Object.keys(saved.profiles ?? {}), ['p'], 'existing config is preserved') +}) + +test('the created directory is empty — swisscode writes no credential into it', () => { + const h = harness() + h.run({ ...base(h), name: 'personal' }) + const dir = join(h.root, 'config', 'swisscode', 'accounts', 'personal') + assert.deepEqual(readdirSync(dir), [], 'the agent creates its own files; swisscode creates none') +}) diff --git a/test/adapters/claude-session-swap.test.ts b/test/adapters/claude-session-swap.test.ts new file mode 100644 index 0000000..2768edb --- /dev/null +++ b/test/adapters/claude-session-swap.test.ts @@ -0,0 +1,260 @@ +// The only code in swisscode that writes a credential. +// +// Every test here drives it through an injected `exec`, so nothing touches the +// real Keychain. The two properties worth the most are pinned first: the secret +// never reaches argv, and the blob crosses VERBATIM — a swap that dropped +// `refreshToken` would hand the target a login that dies hours later, far from +// the command that caused it. +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readRawCredential, swapCredential } from '../../src/adapters/claude-session/swap.ts' +import { keychainService } from '../../src/adapters/claude-session/credentials.ts' + +const fresh = () => mkdtempSync(join(tmpdir(), 'swisscode-swap-')) + +/** The shape Claude Code actually stores, refresh token and all. */ +const BLOB = JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-oat-ACCESS', + refreshToken: 'sk-ant-ort-REFRESH', + expiresAt: 4102444800000, + subscriptionType: 'max', + }, +}) + +/** A fake `security` that remembers what it was asked to do. */ +function fakeSecurity(items: Record = {}) { + const calls: { args: string[]; input?: string }[] = [] + const exec = ((file: string, args: string[], opts?: { input?: string }) => { + calls.push({ args, ...(opts?.input === undefined ? {} : { input: opts.input }) }) + assert.equal(file, '/usr/bin/security', 'PATH must never decide which binary handles a secret') + if (args[0] === 'find-generic-password') { + const service = args[args.indexOf('-s') + 1]! + const found = items[service] + if (found === undefined) { + const e = new Error('not found') as Error & { status: number } + e.status = 44 + throw e + } + return found + } + if (args[0] === 'delete-generic-password') { + const service = args[args.indexOf('-s') + 1]! + if (!(service in items)) { + const e = new Error('not found') as Error & { status: number } + e.status = 44 + throw e + } + delete items[service] + return '' + } + throw new Error(`unexpected security verb ${args[0]}`) + }) as never + return { exec, calls, items } +} + +const env = { HOME: '/home/u', USER: 'u' } + +test('THE SECRET NEVER REACHES ARGV', async () => { + // A password in argv is readable via `ps` by every user on the machine for as + // long as the process lives. For a token worth a subscription that window is + // not acceptable, and this is the assertion that keeps it shut. + const from = fresh() + const into = fresh() + const fake = fakeSecurity({ [keychainService(from, env)]: BLOB }) + + const result = swapCredential(from, into, { env, platform: 'darwin', exec: fake.exec }) + assert.equal(result.ok, true) + + for (const call of fake.calls) { + for (const arg of call.args) { + assert.doesNotMatch(arg, /ACCESS|REFRESH|sk-ant/, `secret found in argv: ${arg}`) + } + } +}) + +test('THE BLOB IS NEVER TRUNCATED — the bug that made this a file write', async () => { + // REGRESSION, and a shipped-in-a-first-draft one. `security add-generic-password + // -w` reading from stdin truncates at 128 bytes: 500 in, 128 stored, exit 0, + // no warning. A real credential is ~3.9 kB, so the keychain path silently + // stored a corrupt fragment — and the unit tests passed, because a fake + // `security` has no buffer. Only reading it back off a real machine caught it. + const from = fresh() + const into = fresh() + const big = JSON.stringify({ + claudeAiOauth: { accessToken: `sk-ant-oat-${'A'.repeat(2000)}`, refreshToken: 'sk-ant-ort-REFRESH' }, + }) + assert.ok(big.length > 128, 'the fixture must exceed the limit it is testing for') + const fake = fakeSecurity({ [keychainService(from, env)]: big }) + + swapCredential(from, into, { env, platform: 'darwin', exec: fake.exec }) + const written = readFileSync(join(into, '.credentials.json'), 'utf8').trim() + assert.equal(written.length, big.length, 'the credential was truncated') + assert.equal(written, big) +}) + +test('the blob crosses VERBATIM, refresh token included', async () => { + // The swap moves opaque bytes. Moving only the access token would produce a + // login that authenticates now and dies at the first refresh — the worst + // possible failure shape, because it happens far from the cause. + const from = fresh() + const into = fresh() + const fake = fakeSecurity({ [keychainService(from, env)]: BLOB }) + + swapCredential(from, into, { env, platform: 'darwin', exec: fake.exec }) + const written = readFileSync(join(into, '.credentials.json'), 'utf8').trim() + assert.equal(written, BLOB) + assert.match(written, /sk-ant-ort-REFRESH/) +}) + +test('a competing keychain item is REMOVED so the file is authoritative', async () => { + // After a swap there must be exactly one stored credential for the target, + // and it must be the one just written. A stale keychain item beside a fresh + // file makes "which login does this directory use?" depend on a precedence + // rule inside someone else's binary. + const from = fresh() + const into = fresh() + const intoSvc = keychainService(into, env) + const fake = fakeSecurity({ [keychainService(from, env)]: BLOB, [intoSvc]: 'STALE' }) + + swapCredential(from, into, { env, platform: 'darwin', exec: fake.exec }) + const deletion = fake.calls.find((c) => c.args[0] === 'delete-generic-password') + assert.deepEqual(deletion?.args, ['delete-generic-password', '-s', intoSvc]) + // …and it happens AFTER the file exists, never before: a failed write must + // not leave the directory with no login at all. + assert.ok(readFileSync(join(into, '.credentials.json'), 'utf8').includes('sk-ant-oat-ACCESS')) +}) + +test('the SOURCE login is left exactly as it was', async () => { + // The whole point. `/login` writes one global slot that every running session + // re-reads; this touches the target and nothing else. + const from = fresh() + const into = fresh() + const fromSvc = keychainService(from, env) + const fake = fakeSecurity({ [fromSvc]: BLOB }) + + swapCredential(from, into, { env, platform: 'darwin', exec: fake.exec }) + assert.equal(fake.items[fromSvc], BLOB, 'the source login was modified') + assert.notEqual(keychainService(into, env), fromSvc, 'two dirs must hash to two services') + for (const call of fake.calls) { + if (call.args[0] === 'delete-generic-password') { + assert.notEqual(call.args[2], fromSvc, 'the source item must never be deleted') + } + } +}) + +test('swapping into the same directory is refused rather than silently rewritten', async () => { + const dir = fresh() + const fake = fakeSecurity({ [keychainService(dir, env)]: BLOB }) + const result = swapCredential(dir, dir, { env, platform: 'darwin', exec: fake.exec }) + assert.equal(result.ok, false) + assert.match((result as { reason: string }).reason, /same directory/) + assert.equal(fake.calls.length, 0, 'nothing should have been asked of the keychain') +}) + +test('a source with no login is refused, and nothing is written', async () => { + const fake = fakeSecurity({}) + const into = fresh() + const result = swapCredential(fresh(), into, { env, platform: 'darwin', exec: fake.exec }) + assert.equal(result.ok, false) + assert.match((result as { reason: string }).reason, /no login is stored/) + assert.equal( + fake.calls.filter((c) => c.args[0] === 'add-generic-password').length, + 0, + 'a failed read must never lead to a write', + ) +}) + +test('an unwritable target is refused, and says nothing changed', async () => { + const from = fresh() + const fake = fakeSecurity({ [keychainService(from, env)]: BLOB }) + // A path whose parent cannot be created. + const result = swapCredential(from, '/proc/nonexistent/swisscode-target', { + env, + platform: 'darwin', + exec: fake.exec, + }) + assert.equal(result.ok, false) + assert.equal( + fake.calls.filter((c) => c.args[0] === 'delete-generic-password').length, + 0, + 'a failed write must never drop the target\'s existing login', + ) +}) + +test('the identity block is COPIED, or the swap is only half done', async () => { + // Without this the token is the new account's while `/status` still names the + // old one — the same silently-wrong-account confusion this whole feature + // exists to end, reintroduced one layer down. + const from = fresh() + const into = fresh() + writeFileSync( + join(from, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'new@acct.com' }, projects: { a: 1 } }), + ) + writeFileSync( + join(into, '.claude.json'), + JSON.stringify({ oauthAccount: { emailAddress: 'old@acct.com' }, projects: { b: 2 } }), + ) + const fake = fakeSecurity({ [keychainService(from, env)]: BLOB }) + + const result = swapCredential(from, into, { env, platform: 'darwin', exec: fake.exec }) + assert.equal(result.ok && result.wroteIdentity, true) + const after = JSON.parse(readFileSync(join(into, '.claude.json'), 'utf8')) + assert.equal(after.oauthAccount.emailAddress, 'new@acct.com') + // …without discarding what else lived in the file. Project history, MCP + // servers and onboarding state have nothing to do with who pays. + assert.deepEqual(after.projects, { b: 2 }, 'the target\'s own history was overwritten') +}) + +test('a target that has never been used gets a directory and a file', async () => { + const from = fresh() + const into = join(fresh(), 'brand-new') + writeFileSync(join(from, '.claude.json'), JSON.stringify({ oauthAccount: { emailAddress: 'a@b.c' } })) + const fake = fakeSecurity({ [keychainService(from, env)]: BLOB }) + + const result = swapCredential(from, into, { env, platform: 'darwin', exec: fake.exec }) + assert.equal(result.ok, true) + assert.equal(statSync(into).mode & 0o777, 0o700, 'a directory holding a login is 0700') + assert.equal(JSON.parse(readFileSync(join(into, '.claude.json'), 'utf8')).oauthAccount.emailAddress, 'a@b.c') +}) + +test('off macOS the credential is a file, and it is 0600', async () => { + const from = fresh() + const into = fresh() + writeFileSync(join(from, '.credentials.json'), BLOB) + const result = swapCredential(from, into, { env, platform: 'linux' }) + assert.equal(result.ok, true) + assert.equal((result as { source: string }).source, 'file') + const path = join(into, '.credentials.json') + assert.equal(statSync(path).mode & 0o777, 0o600) + assert.match(readFileSync(path, 'utf8'), /sk-ant-ort-REFRESH/) +}) + +test('readRawCredential prefers the keychain but falls back to the file', async () => { + const dir = fresh() + writeFileSync(join(dir, '.credentials.json'), BLOB) + // No keychain item: the file answers. + const viaFile = readRawCredential(dir, { env, platform: 'darwin', exec: fakeSecurity({}).exec }) + assert.equal(viaFile?.source, 'file') + + const fake = fakeSecurity({ [keychainService(dir, env)]: '{"claudeAiOauth":{"accessToken":"KC"}}' }) + const viaKeychain = readRawCredential(dir, { env, platform: 'darwin', exec: fake.exec }) + assert.equal(viaKeychain?.source, 'keychain') + assert.match(viaKeychain!.blob, /KC/) +}) + +test('an identity that cannot be copied is REPORTED, not swallowed', async () => { + // The token still moved; the caller has to be able to say the swap was + // partial rather than claim a clean result. + const from = fresh() + const into = fresh() + mkdirSync(join(into, '.claude.json'), { recursive: true }) // a directory where a file belongs + const fake = fakeSecurity({ [keychainService(from, env)]: BLOB }) + const result = swapCredential(from, into, { env, platform: 'darwin', exec: fake.exec }) + assert.equal(result.ok, true) + assert.equal((result as { wroteIdentity: boolean }).wroteIdentity, false) +}) diff --git a/test/adapters/composite-providers.test.ts b/test/adapters/composite-providers.test.ts new file mode 100644 index 0000000..4b2394c --- /dev/null +++ b/test/adapters/composite-providers.test.ts @@ -0,0 +1,149 @@ +// The composite provider registry. +// +// The point of this adapter is that NOTHING else had to change: core/ does not +// know providers can come from a config file, and neither the launch path nor +// the doctor learned a new concept. They ask a `ProviderRegistryPort` for a +// `ProviderDescriptor` and get one. These tests pin that, plus the two rules +// that decide what happens when a stored provider misbehaves. +import test from 'node:test' +import assert from 'node:assert/strict' +import { RESERVED_PROVIDER_IDS, toDescriptor, withCustomProviders } from '../../src/adapters/providers/composite.ts' +import { registry as shipped } from '../../src/adapters/providers/registry.ts' +import { buildEnvPlan } from '../../src/adapters/agents/claude-code/env.ts' +import { planLaunch } from '../../src/composition/launch-root.ts' +import { registry as agents } from '../../src/adapters/agents/registry.ts' +import { makeProfile } from '../support/fixtures.ts' +import type { State } from '../../src/ports/config-store.ts' + +const custom = { + id: 'my-gw', + label: 'My Gateway', + baseUrl: 'https://gw.example.com/anthropic', + defaultModels: { opus: 'big', sonnet: 'big', haiku: 'small', fable: 'small' }, + env: { API_TIMEOUT_MS: '600000' }, +} + +const stateWith = (providers: Record): State => + ({ + version: 2, + providerAccounts: { + p: makeProfile({ provider: 'my-gw', apiKey: 'k' }), + }, + agentProfiles: { + p: {}, + }, + profiles: { + p: { agentProfile: 'p', accounts: ['p'] }, + }, + defaultProfile: 'p', + bindings: {}, + settings: {}, + providers, + }) as unknown as State + +test('a custom provider resolves through the same port as a shipped one', () => { + const reg = withCustomProviders(shipped, stateWith({ 'my-gw': custom })) + const found = reg.byId('my-gw') + assert.ok(found) + assert.equal(found.baseUrl, 'https://gw.example.com/anthropic') + // …and the shipped ones are all still there. + assert.ok(reg.byId('openrouter')) + assert.equal(reg.all().length, shipped.all().length + 1) +}) + +test('with no custom providers the base registry is returned unchanged', () => { + // Identity, not a copy: every launch pays for this call, and the common case + // must cost nothing. + const reg = withCustomProviders(shipped, stateWith({})) + assert.equal(reg, shipped) +}) + +test('a stored provider may not shadow a shipped id', () => { + // Validation refuses to create one, so reaching this branch means a + // hand-edited or hostile config — and "openrouter now points elsewhere" is an + // attempt to redirect a credential to a host it was not entered for. + const hostile = stateWith({ + openrouter: { ...custom, id: 'openrouter', baseUrl: 'https://attacker.example' }, + }) + const reg = withCustomProviders(shipped, hostile) + assert.equal(reg.byId('openrouter')!.baseUrl, 'https://openrouter.ai/api') + assert.equal(reg.all().length, shipped.all().length, 'the shadowing entry was listed') +}) + +test('the map key wins over a disagreeing inner id', () => { + // Otherwise the provider resolves differently depending on which of the two + // a caller happened to use. + const reg = withCustomProviders(shipped, stateWith({ 'filed-as': { ...custom, id: 'claims-to-be' } })) + assert.ok(reg.byId('filed-as')) + assert.equal(reg.byId('claims-to-be'), null) +}) + +test('a custom provider produces a real environment, billing guard included', () => { + // The proof that this is a descriptor like any other: the Claude Code adapter + // does not special-case it, so the stale-key guard applies unchanged. + const plan = buildEnvPlan( + makeProfile(({ provider: 'my-gw', apiKey: 'k' })), + toDescriptor(custom), + { ANTHROPIC_API_KEY: 'sk-ant-STALE' }, + ) + assert.equal(plan.set.ANTHROPIC_BASE_URL, 'https://gw.example.com/anthropic') + assert.equal(plan.set.ANTHROPIC_AUTH_TOKEN, 'k') + assert.equal(plan.set.API_TIMEOUT_MS, '600000') + assert.ok(plan.unset.includes('ANTHROPIC_API_KEY'), 'the billing guard did not apply') + assert.equal(plan.set.ANTHROPIC_DEFAULT_OPUS_MODEL, 'big') +}) + +test('a custom provider is launchable end to end', () => { + // planLaunch composes the registry itself, after loading state — which is the + // only place it can, because the providers live in the file being read. + const state = stateWith({ 'my-gw': custom }) + const replaced: string[][] = [] + const planned = planLaunch({ + store: { + load: () => ({ state, corrupt: false, readOnly: false, migrated: false, warnings: [] }), + save: () => '/tmp/config.json', + path: () => '/tmp/config.json', + }, + registry: shipped, + agents, + proc: { + env: () => ({}), + cwd: () => '/work', + resolveBinary: () => '/usr/local/bin/claude', + replace: (bin, args) => replaced.push([bin, ...args]), + }, + }) + assert.equal(planned.needsSetup, false) + if (planned.needsSetup) return + assert.equal(planned.provider?.id, 'my-gw') + assert.equal(planned.env.ANTHROPIC_BASE_URL, 'https://gw.example.com/anthropic') +}) + +test('a profile naming a deleted custom provider still refuses to launch', () => { + // The pre-existing rule, unchanged: an unknown provider with no baseUrl of + // its own must not fall back to Anthropic and bill the wrong account. + const state = stateWith({}) + assert.throws( + () => + planLaunch({ + store: { + load: () => ({ state, corrupt: false, readOnly: false, migrated: false, warnings: [] }), + save: () => '/tmp/config.json', + path: () => '/tmp/config.json', + }, + registry: shipped, + agents, + proc: { + env: () => ({}), + cwd: () => '/work', + resolveBinary: () => '/usr/local/bin/claude', + replace: () => {}, + }, + }), + /does not know/, + ) +}) + +test('the reserved id list matches what is actually shipped', () => { + assert.deepEqual([...RESERVED_PROVIDER_IDS].sort(), shipped.all().map((p) => p.id).sort()) +}) diff --git a/test/adapters/doctor-probe.test.ts b/test/adapters/doctor-probe.test.ts index c77e9f4..8f060fe 100644 --- a/test/adapters/doctor-probe.test.ts +++ b/test/adapters/doctor-probe.test.ts @@ -16,6 +16,7 @@ import type { ProbeFetch, ProbeResponse } from '../../src/adapters/doctor/probe. import type { ProbeRequest, ProbeResult } from '../../src/ports/doctor.ts' import type { State } from '../../src/ports/config-store.ts' import type { EnvMap } from '../../src/ports/process.ts' +import { makeProfile } from '../support/fixtures.ts' /** * A ProbeRequest that omits the credential fields. @@ -171,13 +172,15 @@ const SECRET = 'ms-super-secret-token' function deps(over: { state?: State; env?: EnvMap } = {}) { const state = over.state ?? { - version: 2, + version: 3, + providerAccounts: { + z: { provider: 'zai', apiKey: SECRET }, + }, + agentProfiles: { + z: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' } }, + }, profiles: { - z: { - provider: 'zai', - apiKey: SECRET, - models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, - }, + z: { agentProfile: 'z', accounts: ['z'] }, }, defaultProfile: 'z', bindings: {}, @@ -213,8 +216,16 @@ test('a provider placeholder is not treated as a secret and is not redacted', as // a secret, and redaction must cover real secrets exactly rather than // everything sitting in a credential-shaped slot. const state = { - version: 2, - profiles: { local: { provider: 'ollama', models: { opus: 'qwen3-coder:30b' } } }, + version: 3, + providerAccounts: { + local: makeProfile({ provider: 'ollama' }), + }, + agentProfiles: { + local: { models: { opus: 'qwen3-coder:30b' } }, + }, + profiles: { + local: { agentProfile: 'local', accounts: ['local'] }, + }, defaultProfile: 'local', bindings: {}, settings: {}, @@ -280,13 +291,15 @@ test('--offline makes no network calls at all', async () => { test('the total budget stops the run rather than running long', async () => { const state = { - version: 2, + version: 3, + providerAccounts: { + z: { provider: 'zai', apiKey: SECRET }, + }, + agentProfiles: { + z: { models: { opus: 'a', sonnet: 'b', haiku: 'c', fable: 'd' } }, + }, profiles: { - z: { - provider: 'zai', - apiKey: SECRET, - models: { opus: 'a', sonnet: 'b', haiku: 'c', fable: 'd' }, - }, + z: { agentProfile: 'z', accounts: ['z'] }, }, defaultProfile: 'z', bindings: {}, @@ -344,8 +357,16 @@ test('doctor says what it did NOT test about the [1m] suffix', async () => { test('--fix prunes dangling bindings and nothing else', async () => { const state = { - version: 2, - profiles: { z: { provider: 'zai', apiKey: SECRET, models: { opus: 'glm-5.2' } } }, + version: 3, + providerAccounts: { + z: { provider: 'zai', apiKey: SECRET }, + }, + agentProfiles: { + z: { models: { opus: 'glm-5.2' } }, + }, + profiles: { + z: { agentProfile: 'z', accounts: ['z'] }, + }, defaultProfile: 'z', bindings: { '/definitely/not/a/real/path': 'gone' }, settings: {}, @@ -354,6 +375,7 @@ test('--fix prunes dangling bindings and nothing else', async () => { await runDoctor({ deps: d.deps, offline: true, fix: true }) assert.equal(d.saves.length, 1) assert.deepEqual(d.saves[0]!.bindings, {}) - // The model string the user pinned is untouched. - assert.deepEqual(d.saves[0]!.profiles.z!.models, { opus: 'glm-5.2' }) + // The model string the user pinned is untouched — on the agent profile, + // which is where models live since v3. + assert.deepEqual(d.saves[0]!.agentProfiles.z!.models, { opus: 'glm-5.2' }) }) diff --git a/test/adapters/doctor-session.test.ts b/test/adapters/doctor-session.test.ts new file mode 100644 index 0000000..eee4d70 --- /dev/null +++ b/test/adapters/doctor-session.test.ts @@ -0,0 +1,350 @@ +// The doctor's two session-mode jobs: say who a session directory is logged in +// as, and refresh the snapshot that `usage` selection reads. +// +// The second one closes a loop that was open in the shipped code: +// `core/resolve.ts` tells users "Run `swisscode config doctor` to refresh +// usage" when a `usage` profile has no snapshot, and until now the doctor had +// no idea what a snapshot was. +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runDoctor } from '../../src/composition/doctor-root.ts' +import { measureAccounts, remainingMap } from '../../src/adapters/usage/measure.ts' +import { registry } from '../../src/adapters/providers/registry.ts' +import { registry as agents } from '../../src/adapters/agents/registry.ts' +import { makeAccount, makeAgentProfile, makeProfileRefs, makeState } from '../support/fixtures.ts' +import type { SelectionStrategy, State } from '../../src/ports/config-store.ts' +import type { DoctorCheck } from '../../src/ports/doctor.ts' +import type { UsageSnapshot } from '../../src/core/resolve.ts' +import type { SubscriptionUsage } from '../../src/adapters/usage/anthropic-subscription.ts' + +const fresh = () => mkdtempSync(join(tmpdir(), 'swisscode-doctor-')) + +/** A measured window, shaped like the real payload but without the network. */ +const usageOf = (remaining: number): SubscriptionUsage => ({ + remaining, + limit: 100, + used: 100 - remaining, + unit: 'percent of window remaining', + checkedAt: 0, + fiveHour: { utilization: 100 - remaining, resetsAt: null }, + sevenDay: { utilization: 0, resetsAt: null }, + sevenDayOpus: { utilization: null, resetsAt: null }, + sevenDaySonnet: { utilization: null, resetsAt: null }, + extraUsage: null, +}) + +/** Write a `.claude.json` carrying a login, the way `/login` leaves it. */ +function loginAt(dir: string, email: string): string { + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, '.claude.json'), + JSON.stringify({ + oauthAccount: { + emailAddress: email, + organizationRateLimitTier: 'default_claude_max_20x', + }, + }), + ) + return dir +} + +type DepsOver = { + state?: State + usageStore?: { read: () => UsageSnapshot | null; write: (s: UsageSnapshot) => void } +} + +function deps(over: DepsOver = {}) { + const state = + over.state ?? + makeState({ + version: 3, + providerAccounts: { z: makeAccount({ provider: 'zai', apiKey: 'k' }) }, + agentProfiles: { z: makeAgentProfile({ models: { opus: 'glm-5.2' } }) }, + profiles: { z: makeProfileRefs({ agentProfile: 'z', accounts: ['z'] }) }, + defaultProfile: 'z', + bindings: {}, + settings: {}, + }) + return { + store: { + load: () => ({ state, corrupt: false, readOnly: false, migrated: false, warnings: [] }), + save: () => '/tmp/config.json', + path: () => '/tmp/config.json', + modes: () => ({ dir: 0o700, file: 0o600 }), + }, + registry, + agents, + proc: { + env: () => ({}), + cwd: () => '/work', + resolveBinary: () => '/usr/local/bin/claude', + replace: () => { + throw new Error('doctor must never launch anything') + }, + }, + ...(over.usageStore ? { usage: over.usageStore } : {}), + } +} + +/** A state whose default profile is a session-mode Anthropic account. */ +const sessionState = (configDir: string, over: { strategy?: SelectionStrategy } = {}): State => + makeState({ + version: 3, + providerAccounts: { personal: makeAccount({ provider: 'anthropic', configDir }) }, + agentProfiles: { a: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, + profiles: { + a: makeProfileRefs({ + agentProfile: 'a', + accounts: ['personal'], + ...(over.strategy ? { strategy: over.strategy } : {}), + }), + }, + defaultProfile: 'a', + bindings: {}, + settings: {}, + }) + +const byId = (checks: readonly DoctorCheck[], id: string) => checks.find((c) => c.id === id) + +// ── the session login check ── + +test('a logged-in session directory reports WHO, not just that it exists', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const { report } = await runDoctor({ deps: deps({ state: sessionState(dir) }), offline: true }) + const check = byId(report.checks, 'session') + assert.equal(check?.status, 'ok') + // The email is the thing a user recognises — "which of my accounts is this?" + assert.match(check!.detail, /a@b\.c/) + assert.match(check!.detail, /Max 20x/) +}) + +test('a session directory that has never been used warns, and says how to fix it', async () => { + // The failure this catches is late and expensive: the launch succeeds, the + // process is replaced, and the first sign of trouble is the agent's own login + // prompt — with swisscode gone and unable to explain. + const dir = join(fresh(), 'never-created') + const { report } = await runDoctor({ deps: deps({ state: sessionState(dir) }), offline: true }) + const check = byId(report.checks, 'session') + assert.equal(check?.status, 'warn') + assert.match(check!.detail, /never been used/) + assert.equal(check!.fix, 'swisscode config accounts login personal') +}) + +test('used-but-logged-out is a DIFFERENT sentence from never-used', async () => { + // Same fix, different problem: one is onboarding you have not done, the other + // is a login that lapsed. A user told the right one stops guessing. + const dir = join(fresh(), 'used') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, '.claude.json'), JSON.stringify({ someOtherKey: true })) + const { report } = await runDoctor({ deps: deps({ state: sessionState(dir) }), offline: true }) + const check = byId(report.checks, 'session') + assert.equal(check?.status, 'warn') + assert.match(check!.detail, /has been used but carries no login/) + assert.doesNotMatch(check!.detail, /never been used/) +}) + +test('a key-mode profile gets no session check at all', async () => { + // Not an OK check saying "n/a" — an absent one. A key account has no session + // directory to be right or wrong about. + const { report } = await runDoctor({ deps: deps(), offline: true }) + assert.equal(byId(report.checks, 'session'), undefined) +}) + +test('the session check runs under --offline, because it costs no network', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const { report } = await runDoctor({ deps: deps({ state: sessionState(dir) }), offline: true }) + assert.equal(byId(report.checks, 'session')?.status, 'ok') +}) + +// ── the usage snapshot ── + +test('no profile selects by usage, so nothing is measured and nothing prompts', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + let asked = 0 + const { report } = await runDoctor({ + deps: deps({ state: sessionState(dir) }), + usageFetch: async () => { + asked++ + return usageOf(50) + }, + }) + assert.equal(asked, 0, 'measuring for figures nothing reads costs a Keychain prompt for nothing') + assert.equal(byId(report.checks, 'usage-snapshot')?.status, 'skip') + assert.match(byId(report.checks, 'usage-snapshot')!.detail, /no profile selects/) +}) + +test('--offline skips the refresh rather than measuring', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + let asked = 0 + const { report } = await runDoctor({ + deps: deps({ state: sessionState(dir, { strategy: 'usage' }) }), + offline: true, + usageFetch: async () => { + asked++ + return usageOf(50) + }, + }) + assert.equal(asked, 0) + assert.match(byId(report.checks, 'usage-snapshot')!.detail, /--offline/) +}) + +test('a usage profile gets measured and the snapshot WRITTEN', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const written: UsageSnapshot[] = [] + const { report } = await runDoctor({ + deps: deps({ + state: sessionState(dir, { strategy: 'usage' }), + usageStore: { read: () => null, write: (s) => void written.push(s) }, + }), + now: () => 1234, + usageFetch: async () => usageOf(27), + }) + assert.deepEqual(written, [{ remaining: { personal: 27 }, checkedAt: 1234 }]) + const check = byId(report.checks, 'usage-snapshot') + assert.equal(check?.status, 'ok') + assert.match(check!.detail, /personal 27% left/) +}) + +test('only the accounts a usage profile NAMES are measured', async () => { + // Each measurement can raise a Keychain prompt. Measuring the whole machine + // to answer a question about one profile is a cost with no answer attached. + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const other = loginAt(join(fresh(), 'other'), 'x@y.z') + const state = makeState({ + version: 3, + providerAccounts: { + personal: makeAccount({ provider: 'anthropic', configDir: dir }), + unused: makeAccount({ provider: 'anthropic', configDir: other }), + }, + agentProfiles: { a: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, + profiles: { + a: makeProfileRefs({ agentProfile: 'a', accounts: ['personal'], strategy: 'usage' }), + b: makeProfileRefs({ agentProfile: 'a', accounts: ['unused'] }), + }, + defaultProfile: 'a', + bindings: {}, + settings: {}, + }) + + const asked: (string | null | undefined)[] = [] + await runDoctor({ + deps: deps({ state, usageStore: { read: () => null, write: () => {} } }), + usageFetch: async (req) => { + asked.push(req.sessionDir) + return usageOf(40) + }, + }) + assert.deepEqual(asked, [dir], 'the non-usage profile\'s account must not be touched') +}) + +test('nothing measurable leaves the cached snapshot ALONE rather than clearing it', async () => { + // A stale figure that selection will age out beats no figure at all: the + // alternative is falling back to "first account" the moment the endpoint + // hiccups. + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const written: UsageSnapshot[] = [] + const { report } = await runDoctor({ + deps: deps({ + state: sessionState(dir, { strategy: 'usage' }), + usageStore: { read: () => null, write: (s) => void written.push(s) }, + }), + usageFetch: async () => null, + }) + assert.deepEqual(written, [], 'the snapshot was overwritten with nothing') + const check = byId(report.checks, 'usage-snapshot') + assert.equal(check?.status, 'warn') + assert.match(check!.detail, /left alone/) +}) + +test('a partly-measured set writes what it has and NAMES what it missed', async () => { + // A partial snapshot still selects, and it selects among the accounts that + // answered — so an account missing from it silently stops being a candidate. + const a = loginAt(join(fresh(), 'a'), 'a@b.c') + const b = loginAt(join(fresh(), 'b'), 'x@y.z') + const state = makeState({ + version: 3, + providerAccounts: { + one: makeAccount({ provider: 'anthropic', configDir: a }), + two: makeAccount({ provider: 'anthropic', configDir: b }), + }, + agentProfiles: { p: makeAgentProfile({ models: { opus: 'claude-opus-4-8' } }) }, + profiles: { + p: makeProfileRefs({ agentProfile: 'p', accounts: ['one', 'two'], strategy: 'usage' }), + }, + defaultProfile: 'p', + bindings: {}, + settings: {}, + }) + + const written: UsageSnapshot[] = [] + const { report } = await runDoctor({ + deps: deps({ state, usageStore: { read: () => null, write: (s) => void written.push(s) } }), + now: () => 99, + usageFetch: async (req) => (req.sessionDir === a ? usageOf(80) : null), + }) + assert.deepEqual(written, [{ remaining: { one: 80 }, checkedAt: 99 }]) + assert.equal(byId(report.checks, 'usage-snapshot')?.status, 'ok') + const missed = byId(report.checks, 'usage-unmeasured') + assert.equal(missed?.status, 'warn') + assert.match(missed!.detail, /two/) +}) + +// ── the shared measurement loop ── + +test('measureAccounts returns an entry per account, failures included', async () => { + // Dropping the failures would make "could not be measured" indistinguishable + // from "no longer exists" for anything rendering a list. + const result = await measureAccounts( + [{ name: 'a', configDir: '/a' }, { name: 'b', configDir: '/b' }], + { fetchUsage: async (req) => (req.sessionDir === '/a' ? usageOf(10) : null), readIdentity: () => null }, + ) + assert.deepEqual(result.map((r) => r.name), ['a', 'b']) + assert.equal(result[0]!.usage?.remaining, 10) + assert.equal(result[1]!.usage, null) +}) + +test('a key-mode account is never asked — it has no window to be out of', async () => { + let asked = 0 + const result = await measureAccounts([{ name: 'k' }], { + fetchUsage: async () => { + asked++ + return usageOf(1) + }, + readIdentity: () => null, + }) + assert.equal(asked, 0) + assert.deepEqual(result, [{ name: 'k', configDir: null, identity: null, usage: null }]) +}) + +test('accounts are measured SEQUENTIALLY, so Keychain dialogs cannot stack', async () => { + let inFlight = 0 + let maxInFlight = 0 + await measureAccounts( + [{ name: 'a', configDir: '/a' }, { name: 'b', configDir: '/b' }, { name: 'c', configDir: '/c' }], + { + readIdentity: () => null, + fetchUsage: async () => { + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((r) => setTimeout(r, 1)) + inFlight-- + return usageOf(5) + }, + }, + ) + assert.equal(maxInFlight, 1, 'three unlock dialogs at once is worse than waiting') +}) + +test('an unmeasured account is ABSENT from the map, never zero', () => { + // Zero would read as "exhausted" and route work away from an account that may + // be entirely free. Absent reads as "unknown", which is the truth. + const map = remainingMap([ + { name: 'good', configDir: '/a', identity: null, usage: usageOf(42) }, + { name: 'failed', configDir: '/b', identity: null, usage: null }, + { name: 'key', configDir: null, identity: null, usage: null }, + ]) + assert.deepEqual(map, { good: 42 }) +}) diff --git a/test/adapters/fs-config-store.test.ts b/test/adapters/fs-config-store.test.ts index a30202d..811d4df 100644 --- a/test/adapters/fs-config-store.test.ts +++ b/test/adapters/fs-config-store.test.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { createFsConfigStore } from '../../src/adapters/store/fs-config-store.ts' +import { makeProfile } from '../support/fixtures.ts' /** Byte-for-byte what swisscode 0.1.0's saveConfig writes. */ const V1_ON_DISK = `${JSON.stringify( @@ -51,19 +52,21 @@ test('a real 0.1.0 config file migrates automatically on load', () => { const loaded = store.load() assert.equal(loaded.migrated, true) - assert.equal(loaded.state.version, 2) + assert.equal(loaded.state.version, 3) assert.equal(loaded.state.defaultProfile, 'zai') - assert.deepEqual(loaded.state.profiles.zai, { + assert.deepEqual(loaded.state.providerAccounts.zai, { provider: 'zai', apiKey: 'zai-secret-key', + }) + assert.deepEqual(loaded.state.agentProfiles.zai, { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2' }, skipPermissions: true, }) // Persisted, and the original kept beside it. const onDisk = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')) - assert.equal(onDisk.version, 2) - assert.equal(onDisk.profiles.zai.apiKey, 'zai-secret-key') + assert.equal(onDisk.version, 3) + assert.equal(onDisk.providerAccounts.zai.apiKey, 'zai-secret-key') assert.equal(readFileSync(join(dir, 'config.v1.bak.json'), 'utf8'), V1_ON_DISK) assert.ok(loaded.warnings.some((w) => w.includes('migrated'))) }) @@ -90,14 +93,22 @@ test('the migrated file keeps 0600 in a 0700 directory', () => { test('save writes 0600 in 0700 even when the directory did not exist', () => { const { dir, store } = freshHome() - store.save({ version: 2, profiles: { a: { provider: 'zai' } }, defaultProfile: 'a', bindings: {}, settings: {} }) + store.save({ + version: 3, + providerAccounts: { a: { provider: 'zai' } }, + agentProfiles: { a: {} }, + profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, + defaultProfile: 'a', + bindings: {}, + settings: {}, + }) assert.equal(statSync(dir).mode & 0o777, 0o700) assert.equal(statSync(join(dir, 'config.json')).mode & 0o777, 0o600) }) test('save leaves no temp file behind', () => { const { dir, store } = freshHome() - store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) assert.deepEqual(readdirSync(dir), ['config.json']) }) @@ -109,7 +120,7 @@ test('a truncated config is quarantined rather than overwritten in place', () => // Still on disk: nothing has been written yet. assert.equal(readFileSync(join(dir, 'config.json'), 'utf8'), '{"provider": "zai", "apiK') - store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) const quarantined = readdirSync(dir).filter((f) => f.startsWith('config.corrupt-')) assert.equal(quarantined.length, 1) assert.equal(readFileSync(join(dir, quarantined[0]!), 'utf8'), '{"provider": "zai", "apiK') @@ -123,7 +134,7 @@ test('a quarantine that cannot rename aborts save() instead of destroying the co chmodSync(dir, 0o500) try { assert.throws( - () => store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }), + () => store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }), /could not move it aside|refusing to overwrite/, ) chmodSync(dir, 0o700) @@ -137,14 +148,16 @@ test('a quarantine that cannot rename aborts save() instead of destroying the co test('a NEWER schema is read but never written back', () => { const future = `${JSON.stringify({ version: 99, - profiles: { a: { provider: 'zai', apiKey: 'k' } }, + providerAccounts: { a: { provider: 'zai', apiKey: 'k' } }, + agentProfiles: { a: {} }, + profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, defaultProfile: 'a', })}\n` const { dir, store } = freshHome(future) const loaded = store.load() assert.equal(loaded.readOnly, true) - assert.equal(loaded.state.profiles.a!.provider, 'zai') + assert.equal(loaded.state.providerAccounts.a!.provider, 'zai') assert.ok(loaded.warnings.some((w) => w.includes('version 99'))) const before = statSync(join(dir, 'config.json')).mtimeMs @@ -167,9 +180,9 @@ test('a failed migration write does not block the launch', () => { const loaded = store.load() // The migrated settings are still usable in memory, and the launch proceeds. - assert.equal(loaded.state.version, 2) - assert.equal(loaded.state.profiles.zai!.provider, 'zai') - assert.equal(loaded.state.profiles.zai!.apiKey, 'zai-secret-key') + assert.equal(loaded.state.version, 3) + assert.equal(loaded.state.providerAccounts.zai!.provider, 'zai') + assert.equal(loaded.state.providerAccounts.zai!.apiKey, 'zai-secret-key') assert.ok(loaded.warnings.some((w) => w.includes('could not rewrite'))) // The original file is untouched, so the next run can migrate it again. assert.equal(readFileSync(join(dir, 'config.json'), 'utf8'), V1_ON_DISK) @@ -184,8 +197,10 @@ test('the 0700 directory mode is re-asserted even on a loose existing dir', () = test('unknown top-level keys survive a round trip', () => { const withExtra = `${JSON.stringify({ - version: 2, - profiles: { a: { provider: 'zai', futureField: 1 } }, + version: 3, + providerAccounts: { a: { provider: 'zai' } }, + agentProfiles: { a: {} }, + profiles: { a: { agentProfile: 'a', accounts: ['a'], futureField: 1 } }, defaultProfile: 'a', bindings: {}, settings: {}, @@ -200,7 +215,50 @@ test('unknown top-level keys survive a round trip', () => { }) test('a v1 config whose provider is no longer known still migrates', () => { - const { store } = freshHome(`${JSON.stringify({ provider: 'volcengine', apiKey: 'k' })}\n`) + const { store } = freshHome(`${JSON.stringify(makeProfile({ provider: 'volcengine', apiKey: 'k' }))}\n`) const loaded = store.load() - assert.equal(loaded.state.profiles.volcengine!.provider, 'volcengine') + assert.equal(loaded.state.providerAccounts.volcengine!.provider, 'volcengine') +}) + +// revision(): lost-update detection for long-lived editors (the web UI). + +test('revision is null when there is no file, and a string once there is', () => { + // "No config yet" is itself a revision worth quoting back: a caller that read + // an empty state and then saves must still be told if someone created one in + // the meantime. + const { store } = freshHome() + assert.equal(store.revision!(), null) + store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + assert.equal(typeof store.revision!(), 'string') +}) + +test('revision follows CONTENT, not the clock', () => { + // Deliberately not mtime. Its granularity is coarse and platform-dependent, + // so two writes inside one tick can share a timestamp — a lost update would + // slip through exactly when writers are most concurrent. Hashing bytes cannot + // have that failure, and this test is what pins the choice. + const { store } = freshHome() + const base = { version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} } + + store.save(base) + const first = store.revision!() + + // Same content written again, immediately: same revision. + store.save(base) + assert.equal(store.revision!(), first, 'identical content changed the revision') + + // Different content, also immediately: different revision. + store.save({ ...base, defaultProfile: 'work', profiles: { work: { provider: 'zai' } } } as never) + assert.notEqual(store.revision!(), first, 'a real edit did not change the revision') +}) + +test('an edit made behind our back is visible as a revision change', () => { + // The interleaving this exists to catch: read, wait, and meanwhile another + // swisscode command writes. + const { dir, store } = freshHome() + store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + const held = store.revision!() + + writeFileSync(join(dir, 'config.json'), JSON.stringify({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: 'other', bindings: {}, settings: {} })) + assert.notEqual(store.revision!(), held) }) diff --git a/test/adapters/fs-cursor-store.test.ts b/test/adapters/fs-cursor-store.test.ts new file mode 100644 index 0000000..6bee772 --- /dev/null +++ b/test/adapters/fs-cursor-store.test.ts @@ -0,0 +1,101 @@ +// The round-robin cursor store. +// +// Everything here is about DEGRADING WELL. The cursor is written after a launch +// has already been decided, so no failure in this adapter may ever propagate — +// the worst it is allowed to do is stop advancing, which the profile banner +// makes visible because it names the account. +import test from 'node:test' +import assert from 'node:assert/strict' +import { chmodSync, mkdtempSync, readFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createFsCursorStore, stateDir } from '../../src/adapters/store/fs-cursor-store.ts' + +const fresh = () => { + const dir = join(mkdtempSync(join(tmpdir(), 'swisscode-cursor-')), 'state') + return { dir, store: createFsCursorStore({ dir }) } +} + +test('an absent cursor reads as null, which starts the rotation', () => { + const { store } = fresh() + assert.equal(store.read('anything'), null) +}) + +test('a cursor round-trips', () => { + const { store } = fresh() + store.advance('work', 2) + assert.equal(store.read('work'), 2) +}) + +test('cursors for different profiles do not collide', () => { + const { store } = fresh() + store.advance('a', 1) + store.advance('b', 7) + assert.equal(store.read('a'), 1) + assert.equal(store.read('b'), 7) +}) + +test('it lives OUTSIDE the config directory', () => { + // The launch path writes no config, and a counter that churned on every + // launch would show up in every config diff pasted into a bug report. + const dir = stateDir({ HOME: '/home/u' }) + assert.match(dir, /\.local[/\\]state[/\\]swisscode$/) + assert.doesNotMatch(dir, /\.config/) + // XDG wins when set. + assert.equal(stateDir({ XDG_STATE_HOME: '/xdg/state' }), join('/xdg/state', 'swisscode')) +}) + +test('the file is 0600 in a 0700 directory', () => { + // It names PROFILES, which are user-chosen and can carry client names — the + // same leak SECURITY.md flags for binding paths. No credential, but nobody + // else's business. + const { dir, store } = fresh() + store.advance('work', 1) + assert.equal(statSync(dir).mode & 0o777, 0o700) + assert.equal(statSync(join(dir, 'cursors.json')).mode & 0o777, 0o600) +}) + +test('a corrupt file reads as no cursor rather than throwing', () => { + const { dir, store } = fresh() + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'cursors.json'), 'not json at all') + assert.equal(store.read('work'), null) + // …and the next write repairs it, because a torn cursor file is worth + // nothing and overwriting it costs nothing. + store.advance('work', 3) + assert.equal(store.read('work'), 3) +}) + +test('a hand-edited nonsense value restarts the rotation', () => { + // It indexes an account array, so a negative or fractional value must never + // reach the caller. + const { dir, store } = fresh() + mkdirSync(dir, { recursive: true }) + for (const bad of ['-1', '1.5', '"two"', 'null', '{}']) { + writeFileSync(join(dir, 'cursors.json'), `{"work": ${bad}}`) + assert.equal(store.read('work'), null, `${bad} was accepted as a cursor`) + } +}) + +test('an unwritable directory stops rotation instead of failing the launch', () => { + // THE CONTRACT. By the time this runs the launch is already decided; throwing + // here would trade a working session for a tidy file. + const parent = mkdtempSync(join(tmpdir(), 'swisscode-cursor-ro-')) + const dir = join(parent, 'state') + mkdirSync(dir) + chmodSync(dir, 0o500) + const store = createFsCursorStore({ dir }) + try { + assert.doesNotThrow(() => store.advance('work', 1)) + assert.equal(store.read('work'), null, 'it degrades to no cursor, which means no rotation') + } finally { + chmodSync(dir, 0o700) + } +}) + +test('the written file is readable JSON, not an opaque blob', () => { + // Someone debugging a rotation should be able to look. + const { dir, store } = fresh() + store.advance('work', 4) + assert.deepEqual(JSON.parse(readFileSync(join(dir, 'cursors.json'), 'utf8')), { work: 4 }) +}) diff --git a/test/adapters/fs-usage-store.test.ts b/test/adapters/fs-usage-store.test.ts new file mode 100644 index 0000000..8cee4e8 --- /dev/null +++ b/test/adapters/fs-usage-store.test.ts @@ -0,0 +1,101 @@ +// The measured-usage snapshot, between runs. +// +// Mirrors test/adapters/fs-cursor-store.test.ts, because the discipline is the +// same: best-effort writes, a corrupt file reads as absent, and nothing here may +// fail the command that triggered it. +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SNAPSHOT_TTL_MS, createFsUsageStore } from '../../src/adapters/store/fs-usage-store.ts' + +const fresh = () => mkdtempSync(join(tmpdir(), 'swisscode-usage-')) + +test('nothing measured yet reads as null, not as empty', () => { + const store = createFsUsageStore({ dir: join(fresh(), 'never-created') }) + assert.equal(store.read(), null) +}) + +test('a snapshot round-trips', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + const snapshot = { remaining: { personal: 27, work: 88 }, checkedAt: Date.now() } + store.write(snapshot) + assert.deepEqual(store.read(), snapshot) +}) + +test('the file is 0600 and the directory 0700 — it maps what you pay for', () => { + const dir = join(fresh(), 'state') + const store = createFsUsageStore({ dir }) + store.write({ remaining: { personal: 27 }, checkedAt: Date.now() }) + assert.equal(statSync(dir).mode & 0o777, 0o700) + assert.equal(statSync(join(dir, 'usage.json')).mode & 0o777, 0o600) +}) + +test('a snapshot older than the TTL reads as ABSENT, not as zero', () => { + // A 5-hour window means a figure older than the TTL describes a window that + // has since rolled over. Selection falls back to the first account and says + // so, which is better than routing on a number about a different period. + const dir = fresh() + const store = createFsUsageStore({ dir }) + store.write({ remaining: { personal: 27 }, checkedAt: Date.now() - SNAPSHOT_TTL_MS - 1 }) + assert.equal(store.read(), null) +}) + +test('a snapshot just inside the TTL still reads', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + const checkedAt = Date.now() - SNAPSHOT_TTL_MS + 60_000 + store.write({ remaining: { personal: 27 }, checkedAt }) + assert.equal(store.read()?.checkedAt, checkedAt) +}) + +test('a corrupt file reads as absent rather than throwing', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + store.write({ remaining: { personal: 27 }, checkedAt: Date.now() }) + for (const body of ['', 'not json', '[]', 'null', '{"remaining":{}}', '{"remaining":null,"checkedAt":1}']) { + writeFileSync(join(dir, 'usage.json'), body) + assert.equal(store.read(), null, `${JSON.stringify(body)} should read as absent`) + } +}) + +test('non-numeric entries are DROPPED, never compared against', () => { + // This map decides which account pays. A hand-edited NaN or string must not + // enter that comparison. + const dir = fresh() + const store = createFsUsageStore({ dir }) + writeFileSync( + join(dir, 'usage.json'), + JSON.stringify({ + remaining: { good: 42, nan: Number.NaN, str: '99', nul: null, obj: {} }, + checkedAt: Date.now(), + }), + ) + assert.deepEqual(store.read()?.remaining, { good: 42 }) +}) + +test('a snapshot with nothing usable left in it reads as absent', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + writeFileSync( + join(dir, 'usage.json'), + JSON.stringify({ remaining: { bad: 'x' }, checkedAt: Date.now() }), + ) + assert.equal(store.read(), null) +}) + +test('an unwritable directory does not throw — the measurement already happened', () => { + const store = createFsUsageStore({ dir: '/proc/nonexistent/swisscode' }) + assert.doesNotThrow(() => store.write({ remaining: { a: 1 }, checkedAt: Date.now() })) + assert.equal(store.read(), null) +}) + +test('the file holds no credential — only account names and figures', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + store.write({ remaining: { personal: 27 }, checkedAt: Date.now() }) + const raw = readFileSync(join(dir, 'usage.json'), 'utf8') + assert.deepEqual(Object.keys(JSON.parse(raw)).sort(), ['checkedAt', 'remaining']) +}) diff --git a/test/adapters/ollama-doctor.test.ts b/test/adapters/ollama-doctor.test.ts index b605efc..960f300 100644 --- a/test/adapters/ollama-doctor.test.ts +++ b/test/adapters/ollama-doctor.test.ts @@ -19,6 +19,7 @@ import { registry } from '../../src/adapters/providers/registry.ts' import { registry as agents } from '../../src/adapters/agents/registry.ts' import type { OllamaContext, OllamaIntrospectPort } from '../../src/ports/doctor.ts' import type { State } from '../../src/ports/config-store.ts' +import { makeProfile } from '../support/fixtures.ts' // GET /api/ps, model resident const PS = { @@ -131,7 +132,15 @@ test('a failed lookup is a skip, never a pass', () => { const ollamaState = { version: 2, - profiles: { local: { provider: 'ollama', models: { opus: 'qwen3:0.6b' } } }, + providerAccounts: { + local: makeProfile({ provider: 'ollama' }), + }, + agentProfiles: { + local: { models: { opus: 'qwen3:0.6b' } }, + }, + profiles: { + local: { agentProfile: 'local', accounts: ['local'] }, + }, defaultProfile: 'local', bindings: {}, settings: {}, @@ -198,7 +207,15 @@ test('--offline skips it, because it is still a network call', async () => { test('a non-Ollama profile gets no context check at all', async () => { const zaiState = { version: 2, - profiles: { z: { provider: 'zai', apiKey: 'k', models: { opus: 'glm-5.2' } } }, + providerAccounts: { + z: makeProfile({ provider: 'zai', apiKey: 'k' }), + }, + agentProfiles: { + z: { models: { opus: 'glm-5.2' } }, + }, + profiles: { + z: { agentProfile: 'z', accounts: ['z'] }, + }, defaultProfile: 'z', bindings: {}, settings: {}, diff --git a/test/adapters/usage.test.ts b/test/adapters/usage.test.ts new file mode 100644 index 0000000..a43e211 --- /dev/null +++ b/test/adapters/usage.test.ts @@ -0,0 +1,68 @@ +// The provider usage capability. +// +// Every payload here matches OpenRouter's documented `GET /v1/key` shape. The +// tests are mostly about what happens when a figure is ABSENT, because a +// missing number and a zero are different facts and this one ranks accounts by +// remaining money. +import test from 'node:test' +import assert from 'node:assert/strict' +import { keyUrl, parseKeyResponse } from '../../src/adapters/usage/openrouter.ts' +import { registry } from '../../src/adapters/providers/registry.ts' + +test('the usage URL composes from the provider base URL', () => { + // Composed rather than hard-coded so the two cannot drift; the provider base + // URL is a bare host because Claude Code appends /v1/messages itself. + const openrouter = registry.byId('openrouter')! + assert.equal(keyUrl(openrouter.baseUrl!), 'https://openrouter.ai/api/v1/key') + assert.equal(keyUrl('https://x.example/api/'), 'https://x.example/api/v1/key') +}) + +test('a full payload becomes a usage record', () => { + const u = parseKeyResponse( + { data: { limit: 100, limit_remaining: 42.5, usage: 57.5, usage_daily: 3 } }, + 1000, + ) + assert.ok(u) + assert.equal(u.remaining, 42.5) + assert.equal(u.limit, 100) + assert.equal(u.used, 57.5) + assert.equal(u.checkedAt, 1000) +}) + +test('a null limit survives as null, never as zero', () => { + // OpenRouter uses null for "no limit". Coercing it to 0 would make an + // UNCAPPED key rank LAST under the usage strategy — exactly backwards. + const u = parseKeyResponse({ data: { limit: null, limit_remaining: null, usage: 12 } }, 1) + assert.ok(u) + assert.equal(u.limit, null) + assert.equal(u.remaining, null) + assert.equal(u.used, 12) +}) + +test('a payload with nothing usable is null, not a row of nulls', () => { + // "This provider does not publish usage" and "this account has no limit" are + // different answers, and only one of them is worth rendering. + assert.equal(parseKeyResponse({ data: {} }, 1), null) + assert.equal(parseKeyResponse({ data: { label: 'my key' } }, 1), null) +}) + +test('an unexpected shape is null rather than a throw', () => { + for (const body of [null, undefined, 'nope', [], { notData: 1 }, { data: 'string' }]) { + assert.equal(parseKeyResponse(body, 1), null) + } +}) + +test('non-finite numbers are refused', () => { + // NaN would sort unpredictably against real balances. + const u = parseKeyResponse({ data: { limit_remaining: Number.NaN, usage: Infinity } }, 1) + assert.equal(u, null) +}) + +test('only providers with a VERIFIED endpoint declare one', () => { + // The standard REJECTED_PROVIDERS and the Ollama work were held to: a + // speculative entry here would route real money by a number nobody checked. + const withUsage = registry.all().filter((p) => p.usageId) + assert.deepEqual(withUsage.map((p) => p.id), ['openrouter']) + // …and the id must name an adapter that exists. + assert.equal(withUsage[0]!.usageId, 'openrouter') +}) diff --git a/test/adapters/web-async.test.ts b/test/adapters/web-async.test.ts new file mode 100644 index 0000000..2f58a42 --- /dev/null +++ b/test/adapters/web-async.test.ts @@ -0,0 +1,206 @@ +// The two API routes that do I/O. +// +// They live apart from api.ts so the pure handler stays pure; these tests +// exercise them through injected fakes, so nothing here resolves a binary, +// bills a token, or touches a network. +import test from 'node:test' +import assert from 'node:assert/strict' +import { handleAsyncApi } from '../../src/adapters/web/api-async.ts' +import type { DoctorReport } from '../../src/ports/doctor.ts' +import type { CatalogRegistryPort, ModelCatalogPort } from '../../src/ports/catalog.ts' + +const report = (): DoctorReport => + ({ + profile: 'work', + source: 'default', + provider: 'zai', + endpoint: 'https://api.z.ai/api/anthropic', + checks: [], + repairs: [], + notes: [], + summary: { counts: { ok: 1, warn: 0, error: 0, skip: 0 }, exitCode: 0 }, + }) as DoctorReport + +test('an unrecognised route is handed back, not answered', async () => { + // Returning null rather than a 404 is what lets the pure handler own + // everything else. Answering here would shadow every route in api.ts. + assert.equal(await handleAsyncApi({ method: 'GET', path: '/api/bootstrap', body: null }, {}), null) + assert.equal(await handleAsyncApi({ method: 'PUT', path: '/api/profiles/x', body: null }, {}), null) +}) + +test('the doctor defaults to OFFLINE, inverting the CLI on purpose', async () => { + // On the command line, `config doctor` is an explicit act. In a browser it is + // a button, and the probes are real billable inference requests — so spending + // money has to be opted into, not defaulted into. + const calls: boolean[] = [] + const deps = { + doctor: async ({ offline }: { offline: boolean }) => { + calls.push(offline) + return report() + }, + } + + await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: null }, deps) + await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: {} }, deps) + await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: { offline: true } }, deps) + assert.deepEqual(calls, [true, true, true], 'a probe ran without being asked for') + + // …and it is genuinely reachable when asked for explicitly. + await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: { offline: false } }, deps) + assert.equal(calls[3], false) +}) + +test('a doctor that throws is reported, not turned into a hang', async () => { + const res = await handleAsyncApi( + { method: 'POST', path: '/api/doctor', body: null }, + { + doctor: async () => { + throw new Error('resolveBinary exploded') + }, + }, + ) + assert.equal(res?.status, 500) + assert.match(String((res?.body as { error: string }).error), /exploded/) +}) + +test('with no doctor wired the route says so rather than 404ing', async () => { + // 404 would read as "no such endpoint", which would send someone looking for + // a typo in a URL that is perfectly correct. + const res = await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: null }, {}) + assert.equal(res?.status, 501) +}) + +// catalog + +const fakeCatalog = (over: Partial = {}): ModelCatalogPort => + ({ + id: 'openrouter', + label: 'OpenRouter', + capabilities: { pricing: true, benchmarks: true, toolSupportKnown: true, requiresAuth: false }, + list: async () => ({ models: [], fromCache: false, stale: false, error: null }), + ...over, + }) as ModelCatalogPort + +const registry = (catalog: ModelCatalogPort | null): CatalogRegistryPort => ({ + ids: () => ['openrouter'], + has: () => catalog !== null, + byId: () => catalog, +}) + +test('a catalog is served with its capabilities, so the UI can branch on facts', async () => { + const res = await handleAsyncApi( + { method: 'GET', path: '/api/catalog/openrouter', body: null }, + { catalogs: registry(fakeCatalog()) }, + ) + assert.equal(res?.status, 200) + const body = res?.body as Record + // Declared up front rather than inferred from a page of nulls — the same + // reason the port carries `capabilities` at all. + assert.deepEqual(body.capabilities, { + pricing: true, + benchmarks: true, + toolSupportKnown: true, + requiresAuth: false, + }) +}) + +test('a stale cache is reported as stale rather than passed off as fresh', async () => { + // list() never throws; it reports. Losing that distinction would show a + // day-old list as current. + const res = await handleAsyncApi( + { method: 'GET', path: '/api/catalog/openrouter', body: null }, + { + catalogs: registry( + fakeCatalog({ + list: async () => ({ + models: [], + fromCache: true, + stale: true, + error: 'network unreachable', + }), + }), + ), + }, + ) + const body = res?.body as Record + assert.equal(body.stale, true) + assert.equal(body.error, 'network unreachable') +}) + +test('a provider with no catalog is a 404, which is a normal state', async () => { + // Most providers publish nothing browsable; the UI asks and gets told no. + const res = await handleAsyncApi( + { method: 'GET', path: '/api/catalog/zai', body: null }, + { catalogs: registry(null) }, + ) + assert.equal(res?.status, 404) +}) + +// ── usage measurement ── + +const usageReport = () => ({ + checkedAt: 1234, + accounts: [ + { + name: 'personal', + mode: 'session' as const, + login: 'a@b.c · Max 20x', + remaining: 27, + fiveHour: { utilization: 0, resetsAt: null }, + sevenDay: { utilization: 73, resetsAt: null }, + }, + ], +}) + +test('measuring usage is POST-only, so nothing can prefetch a Keychain prompt', async () => { + // A GET is something a browser may prefetch, retry or replay on its own + // initiative. On macOS each measurement can raise an unlock dialog, and + // nothing that pops a system dialog should be reachable that way. + let called = 0 + const deps = { + measureUsage: async () => { + called++ + return usageReport() + }, + } + assert.equal(await handleAsyncApi({ method: 'GET', path: '/api/usage', body: null }, deps), null) + assert.equal(called, 0) + + const res = await handleAsyncApi({ method: 'POST', path: '/api/usage', body: {} }, deps) + assert.equal(res?.status, 200) + assert.equal(called, 1) +}) + +test('measured usage crosses with the windows attached and no credential', async () => { + const res = await handleAsyncApi( + { method: 'POST', path: '/api/usage', body: {} }, + { measureUsage: async () => usageReport() }, + ) + const body = res?.body as ReturnType + assert.equal(body.accounts[0]!.remaining, 27) + assert.equal(body.accounts[0]!.sevenDay!.utilization, 73) + // The identity is the point of the payload; a token never is. + const raw = JSON.stringify(body) + assert.match(raw, /a@b\.c/) + assert.doesNotMatch(raw, /sk-ant|accessToken|Bearer/) +}) + +test('an unwired measurer answers 501 rather than pretending everything is fine', async () => { + // The same rule as the doctor: a context that cannot measure says so, instead + // of returning an empty list that reads as "you have no accounts". + const res = await handleAsyncApi({ method: 'POST', path: '/api/usage', body: {} }, {}) + assert.equal(res?.status, 501) +}) + +test('a thrown measurement becomes a 500 body, never an unhandled rejection', async () => { + const res = await handleAsyncApi( + { method: 'POST', path: '/api/usage', body: {} }, + { + measureUsage: async () => { + throw new Error('keychain denied') + }, + }, + ) + assert.equal(res?.status, 500) + assert.match(String((res?.body as { error: string }).error), /keychain denied/) +}) diff --git a/test/adapters/web.test.ts b/test/adapters/web.test.ts new file mode 100644 index 0000000..66aec4a --- /dev/null +++ b/test/adapters/web.test.ts @@ -0,0 +1,552 @@ +// The web UI's gate and API. +// +// The gate gets the most attention in this file, and deliberately: it stands in +// front of a server that can read and write a file holding plaintext API keys, +// reachable by any page the user has open in the same browser. "It only listens +// on localhost" is the reason a security model is needed, not a substitute for +// one. +import test from 'node:test' +import assert from 'node:assert/strict' +import { + SECURITY_HEADERS, + TOKEN_HEADER, + allowedHosts, + checkHost, + checkOrigin, + guardApiRequest, + guardDocumentRequest, + tokensMatch, +} from '../../src/adapters/web/security.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' +import { registry as providers } from '../../src/adapters/providers/registry.ts' +import { registry as agents } from '../../src/adapters/agents/registry.ts' +import { makeAccount, makeAgentProfile, makeProfile } from '../support/fixtures.ts' +import type { ConfigStorePort, State } from '../../src/ports/config-store.ts' + +const PORT = 4242 +const TOKEN = 'a'.repeat(64) +const sec = { token: TOKEN, port: PORT } + +// the gate + +test('DNS rebinding is refused at the Host header', () => { + // The whole attack: the connection genuinely comes from loopback, so no + // socket-level check can tell it apart. Only the Host header can. + const v = checkHost('evil.example', PORT) + assert.equal(v.ok, false) + assert.equal(v.status, 403) + assert.match(v.reason, /rebinding/) +}) + +test('the Host allowlist is exact, not a suffix or substring match', () => { + for (const host of allowedHosts(PORT)) assert.equal(checkHost(host, PORT).ok, true) + // Each of these would pass a naive `includes`/`endsWith` check. + for (const bad of [ + '127.0.0.1.evil.example:4242', + 'evil.example?127.0.0.1:4242', + 'localhost:4243', + 'notlocalhost:4242', + '127.0.0.1', + ]) { + assert.equal(checkHost(bad, PORT).ok, false, `${bad} was accepted`) + } +}) + +test('a wrong Origin is refused; an absent one is not', () => { + assert.equal(checkOrigin('https://evil.example', PORT).ok, false) + assert.equal(checkOrigin(`http://127.0.0.1:${PORT}`, PORT).ok, true) + // Some browsers omit Origin on same-origin GETs, so absent cannot be fatal + // without breaking ordinary navigation. + assert.equal(checkOrigin(undefined, PORT).ok, true) +}) + +test('the token comparison does not short-circuit on the first wrong byte', () => { + assert.equal(tokensMatch(TOKEN, TOKEN), true) + assert.equal(tokensMatch('b' + TOKEN.slice(1), TOKEN), false) + assert.equal(tokensMatch(TOKEN.slice(0, -1) + 'b', TOKEN), false) + assert.equal(tokensMatch(TOKEN.slice(0, 10), TOKEN), false) + assert.equal(tokensMatch(undefined, TOKEN), false) +}) + +test('an API request needs all three: Host, Origin and token', () => { + const good = { host: `127.0.0.1:${PORT}`, origin: `http://127.0.0.1:${PORT}`, token: TOKEN } + assert.equal(guardApiRequest(good, sec).ok, true) + assert.equal(guardApiRequest({ ...good, host: 'evil.example' }, sec).ok, false) + assert.equal(guardApiRequest({ ...good, origin: 'https://evil.example' }, sec).ok, false) + assert.equal(guardApiRequest({ ...good, token: 'wrong' }, sec).ok, false) + assert.equal(guardApiRequest({ ...good, token: undefined }, sec).ok, false) +}) + +test('the document is exempt from the token but not from Host', () => { + // Circular otherwise: the document is what delivers the token. Safe because + // it carries only markup and that token, and cannot be read cross-origin. + assert.equal(guardDocumentRequest({ host: `localhost:${PORT}` }, { port: PORT }).ok, true) + assert.equal(guardDocumentRequest({ host: 'evil.example' }, { port: PORT }).ok, false) +}) + +test('the CSP forbids inline script and framing', () => { + const csp = SECURITY_HEADERS['content-security-policy']! + assert.match(csp, /script-src 'self'/) + assert.doesNotMatch(csp, /script-src[^;]*unsafe-inline/) + assert.match(csp, /frame-ancestors 'none'/) + // The document carries the session token; a disk cache would outlive it. + assert.equal(SECURITY_HEADERS['cache-control'], 'no-store') +}) + +test('path traversal cannot escape the asset directory', () => { + const root = '/tmp/swisscode-assets' + for (const p of ['/../../etc/passwd', '/..%2f..%2fetc/passwd', '/subdir/../../../etc/passwd']) { + assert.equal(resolveAsset(root, p), null, `${p} escaped the root`) + } +}) + +// redaction + +test('an API key never crosses the boundary to the browser', () => { + // Redaction moved to the ACCOUNT with the credential. That is an improvement: + // one of the three shapes is security-sensitive and it is obvious which. + const account = makeAccount(makeProfile({ provider: 'zai', apiKey: 'sk-super-secret' })) + const out = redactAccount(account) + const serialized = JSON.stringify(out) + assert.ok(!serialized.includes('sk-super-secret'), 'the key leaked') + assert.ok(!serialized.includes('sk-super'), 'a prefix of the key leaked') + assert.equal(out.hasKey, true) + assert.ok(!('apiKey' in out)) + + // A variable NAME is not a secret, and the user needs to see it. + const fromEnv = redactAccount(makeAccount({ provider: 'zai', apiKeyFromEnv: 'MY_TOKEN' })) + assert.equal(fromEnv.apiKeyFromEnv, 'MY_TOKEN') + assert.equal(fromEnv.hasKey, false) +}) + +// api + +function store(initial: State): ConfigStorePort & { state: State; saves: number } { + let current = initial + let saves = 0 + return { + get state() { + return current + }, + get saves() { + return saves + }, + load: () => ({ state: current, corrupt: false, readOnly: false, migrated: false, warnings: [] }), + save: (s: State) => { + current = s + saves++ + return '/tmp/config.json' + }, + path: () => '/tmp/config.json', + // Content-derived, like the real one, so the conflict tests are honest. + revision: () => JSON.stringify(current).length.toString(36) + ':' + saves, + } +} + +const baseState = (): State => + ({ + version: 2, providerAccounts: { + work: makeProfile({ provider: 'zai', apiKey: 'secret' }), + }, + agentProfiles: { + work: { models: {} }, + }, + profiles: { + work: { agentProfile: 'work', accounts: ['work'] }, + }, + defaultProfile: 'work', + bindings: {}, + settings: {}, + }) as unknown as State + +const deps = (s: ConfigStorePort) => ({ store: s, providers, agents }) + +test('bootstrap hands the UI everything it needs, minus the keys', () => { + const s = store(baseState()) + const res = handleApi({ method: 'GET', path: '/api/bootstrap', body: null }, deps(s)) + assert.equal(res.status, 200) + const body = res.body as Record + assert.ok(!JSON.stringify(body).includes('secret'), 'a key reached the browser payload') + assert.ok(Array.isArray(body.providers)) + assert.ok((body.providers as unknown[]).length >= 8, 'providers are listed for the picker') + assert.equal(typeof body.revision, 'string') +}) + +test('a write without a revision is refused rather than allowed to stomp', () => { + const s = store(baseState()) + const res = handleApi( + { method: 'PUT', path: '/api/profiles/acme', body: { profile: { provider: 'zai' } } }, + deps(s), + ) + assert.equal(res.status, 400) + assert.equal(s.saves, 0) +}) + +test('a stale revision is a 409, not a silent overwrite', () => { + const s = store(baseState()) + const res = handleApi( + { + method: 'PUT', + path: '/api/profiles/acme', + body: { revision: 'stale', profile: { provider: 'zai' } }, + }, + deps(s), + ) + assert.equal(res.status, 409) + assert.match(String((res.body as { error: string }).error), /changed since you loaded it/) + assert.equal(s.saves, 0, 'a conflicting write must not reach the store') +}) + +test('a current revision writes, and returns the next one', () => { + const s = store(baseState()) + const res = handleApi( + { + method: 'PUT', + path: '/api/accounts/acme', + body: { revision: s.revision!(), account: { provider: 'openrouter' } }, + }, + deps(s), + ) + assert.equal(res.status, 200) + assert.equal(s.state.providerAccounts.acme!.provider, 'openrouter') + assert.notEqual((res.body as { revision: string }).revision, 'stale') +}) + +test('an omitted key does not erase the stored one; an explicit null does', () => { + // The single most destructive mistake this endpoint could make is treating an + // untouched form field as "delete my credential". + const existing = makeAccount(makeProfile({ provider: 'zai', apiKey: 'keep-me' })) + const kept = parseAccount(makeProfile({ provider: 'zai' }), existing) + assert.equal(typeof kept === 'string' ? null : kept.apiKey, 'keep-me') + + const blanked = parseAccount(makeProfile({ provider: 'zai', apiKey: '' }), existing) + assert.equal(typeof blanked === 'string' ? null : blanked.apiKey, 'keep-me') + + const cleared = parseAccount({ provider: 'zai', apiKey: null }, existing) + assert.equal(typeof cleared === 'string' ? undefined : cleared.apiKey, undefined) +}) + +test('unknown fields from the client never reach config.json', () => { + const parsed = parseAccount( + { provider: 'zai', evil: 'payload', __proto__: { polluted: true } }, + undefined, + ) + assert.notEqual(typeof parsed, 'string') + assert.ok(!('evil' in (parsed as object))) + assert.equal(({} as Record).polluted, undefined, 'prototype was polluted') +}) + +test('an invalid profile name is refused with the CLI’s own reason', () => { + const s = store(baseState()) + const res = handleApi( + { + method: 'PUT', + path: '/api/profiles/not%20valid', + body: { revision: s.revision!(), profile: { provider: 'zai' } }, + }, + deps(s), + ) + assert.equal(res.status, 400) + assert.equal(s.saves, 0) +}) + +test('deleting a profile prunes its bindings and clears the default', () => { + const s = store({ + ...baseState(), + bindings: { '/work/repo': 'work' }, + } as unknown as State) + const res = handleApi( + { method: 'DELETE', path: '/api/profiles/work', body: { revision: s.revision!() } }, + deps(s), + ) + assert.equal(res.status, 200) + assert.equal(s.state.profiles.work, undefined) + assert.deepEqual(s.state.bindings, {}, 'a binding to a deleted profile would silently fall back') + assert.equal(s.state.defaultProfile, null) +}) + +// end to end, over a real socket + +/** A raw GET that can set headers fetch() forbids — Host being the one at issue. */ +function rawGet( + port: number, + path: string, + headers: Record, +): Promise<{ status: number; body: string }> { + return new Promise((resolveRaw, rejectRaw) => { + const req = request({ host: '127.0.0.1', port, path, method: 'GET', headers }, (res) => { + let body = '' + res.on('data', (c) => (body += c)) + res.on('end', () => resolveRaw({ status: res.statusCode ?? 0, body })) + }) + req.on('error', rejectRaw) + req.end() + }) +} + +test('the live server refuses a rebound Host and serves with the token', async () => { + const s = store(baseState()) + const server = await startWebServer({ ...deps(s), port: 0 }) + try { + const base = `http://127.0.0.1:${server.port}` + + // Host is a FORBIDDEN header for fetch(), which silently pins it from the + // URL — so the rebinding case is unreachable through fetch and has to go + // over a raw request. Asserting it via fetch would have been a test that + // could never fail. + const rebound = await rawGet(server.port, '/api/bootstrap', { + host: 'evil.example', + [TOKEN_HEADER]: server.token, + }) + assert.equal(rebound.status, 403, 'a rebound Host reached the API') + assert.match(rebound.body, /rebinding/) + + const noToken = await fetch(`${base}/api/bootstrap`) + assert.equal(noToken.status, 401) + + const ok = await fetch(`${base}/api/bootstrap`, { + headers: { [TOKEN_HEADER]: server.token }, + }) + assert.equal(ok.status, 200) + const body = (await ok.json()) as { state: { profiles: Record } } + assert.ok(Object.keys(body.state.profiles).includes('work')) + assert.ok(!JSON.stringify(body).includes('secret'), 'the key went over the wire') + + const doc = await fetch(`${base}/`) + assert.equal(doc.status, 200) + const html = await doc.text() + assert.match(html, /swisscode-token/) + assert.equal(doc.headers.get('x-frame-options'), 'DENY') + } finally { + await server.close() + } +}) + +test('the served page can actually run under the CSP we send it with', async () => { + // REGRESSION. The CSP said `script-src 'self'` with no 'unsafe-inline' and + // the page's only script was inline, so the browser refused to run it and the + // page sat at "loading /api/bootstrap…" forever. + // + // Two tests already existed and neither could catch it: one asserted the CSP + // was strict, the other that the document rendered. The property that was + // missing is the RELATIONSHIP between them — a page is only correct with + // respect to the policy it is served under. + const s = store(baseState()) + const server = await startWebServer({ ...deps(s), port: 0 }) + try { + const base = `http://127.0.0.1:${server.port}` + const html = await (await fetch(`${base}/`)).text() + + // No + + diff --git a/web/panda.config.ts b/web/panda.config.ts new file mode 100644 index 0000000..14ac4bc --- /dev/null +++ b/web/panda.config.ts @@ -0,0 +1,63 @@ +import { defineConfig } from '@pandacss/dev' + +/** + * Panda is BUILD-TIME ONLY: it emits static CSS and ships no runtime library, + * which is why a styling system could be added to a project whose whole pitch + * is four runtime dependencies without changing that number. + * + * The token set below is the "Linear" look, and it is mostly restraint: a + * near-black surface ramp, hairline borders doing the structural work instead + * of shadows, a three-step text hierarchy, and colour reserved for status. + */ +export default defineConfig({ + preflight: true, + include: ['./src/**/*.{ts,tsx}'], + exclude: [], + // No dark VARIANT: this UI is dark, full stop. A theme toggle nobody asked + // for is two code paths to keep honest. + theme: { + extend: { + tokens: { + colors: { + // surfaces, darkest first + bg: { value: '#0c0d10' }, + panel: { value: '#101116' }, + raised: { value: '#16181d' }, + hover: { value: '#1b1e24' }, + // hairlines + line: { value: '#22252c' }, + lineStrong: { value: '#2e323b' }, + // text, three steps and no more + text: { value: '#e7e8ea' }, + dim: { value: '#9ba1ac' }, + faint: { value: '#6b7280' }, + // status, used sparingly + accent: { value: '#5e6ad2' }, + accentHover: { value: '#6e79db' }, + ok: { value: '#3fb950' }, + warn: { value: '#d29922' }, + danger: { value: '#f85149' }, + }, + radii: { + sm: { value: '4px' }, + md: { value: '6px' }, + lg: { value: '8px' }, + }, + fonts: { + sans: { + value: + 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif', + }, + mono: { value: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }, + }, + }, + semanticTokens: { + colors: { + border: { value: '{colors.line}' }, + }, + }, + }, + }, + outdir: 'styled-system', + jsxFramework: 'react', +}) diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..36a0b48 --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,9 @@ +// Panda's PostCSS plugin is what turns the @layer declarations in index.css +// into the actual generated CSS. Without it the layers stay empty and every +// class name resolves to nothing — a page that renders unstyled while every +// build step reports success. +module.exports = { + plugins: { + '@pandacss/dev/postcss': {}, + }, +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..b2e4d33 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,153 @@ +import { useCallback, useEffect, useState } from 'react' +import { css } from '../styled-system/css' +import { ApiError, api, type Bootstrap } from './api' +import { Banner, Dot } from './ui' +import { Profiles } from './routes/Profiles' +import { Accounts } from './routes/Accounts' +import { AgentProfiles } from './routes/AgentProfiles' +import { Providers } from './routes/Providers' +import { Settings } from './routes/Settings' +import { Doctor } from './routes/Doctor' + +type Tab = 'profiles' | 'accounts' | 'agentProfiles' | 'providers' | 'doctor' | 'settings' + +const TABS: { id: Tab; label: string }[] = [ + // Ordered as the concepts compose: who pays, what runs, then the pairing. + { id: 'accounts', label: 'Accounts' }, + { id: 'agentProfiles', label: 'Agent profiles' }, + { id: 'profiles', label: 'Profiles' }, + { id: 'providers', label: 'Providers' }, + { id: 'doctor', label: 'Doctor' }, + { id: 'settings', label: 'Settings' }, +] + +export function App() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [tab, setTab] = useState('profiles') + + const reload = useCallback(async () => { + try { + setData(await api.bootstrap()) + setError(null) + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + }, []) + + useEffect(() => { + void reload() + }, [reload]) + + if (error) { + return ( +
+ Could not reach swisscode: {error} +
+ ) + } + if (!data) { + return
loading…
+ } + + const installed = data.installedAgents ?? [] + + return ( +
+ + +
+ {data.readOnly ? ( + + config.json is a newer schema than this swisscode understands. Every write is + disabled so an older build cannot clobber a newer file. + + ) : null} + {data.warnings.map((w) => ( + + {w} + + ))} + + {tab === 'accounts' ? : null} + {tab === 'agentProfiles' ? : null} + {tab === 'profiles' ? : null} + {tab === 'providers' ? : null} + {tab === 'doctor' ? : null} + {tab === 'settings' ? : null} +
+
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..3497ef9 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,315 @@ +// The typed client for swisscode's local API. +// +// Two things every call here depends on, both decided server-side: +// +// 1. The token comes from a meta tag the server injected, and goes back in a +// CUSTOM header. That header is what forces a CORS preflight the server +// never answers, so a page on another origin cannot make these calls at +// all — it never gets far enough to be refused. +// 2. Writes carry the `revision` they were based on. The server rejects a +// stale one with 409 rather than merging, because it cannot know which of +// two divergent edits was meant. + +export const TOKEN_HEADER = 'x-swisscode-token' + +function token(): string { + const meta = document.querySelector('meta[name=swisscode-token]') + return meta?.getAttribute('content') ?? '' +} + +export type CompatFlag = { + id: string + env: string + value: string + /** non-null when enabling it gives something up; the UI must show it */ + consequence: string | null +} + +export type ProviderInfo = { + id: string + label: string + baseUrl: string | null + askBaseUrl: boolean + credentialOptional: boolean + defaultModels: Record + catalogId: string | null + hints: { keyHint?: string; modelHint?: string; note?: string } +} + +export type AgentInfo = { + id: string + label: string + capabilities: { models: string; skipPermissions: boolean; extendedContextSuffix: boolean; compatFlags: boolean } + binary: string + overrideEnv: string +} + +export type InstalledAgent = { + id: string + label: string + installed: boolean + path: string | null + error: string | null +} + +/** + * WHO PAYS, as the browser is allowed to see it: `hasKey`, never the key. + * + * Only one of the three shapes is security-sensitive, and this is it — which is + * itself a benefit of the split, since there is one type to get right rather + * than one field on a type carrying everything else. + */ +export type ProviderAccount = { + provider: string + label?: string + baseUrl?: string + hasKey: boolean + apiKeyFromEnv?: string + /** + * Session mode: a directory holding a login the agent already performed. + * + * Crosses to the browser in full, unlike `apiKey`, because it is a PATH and + * not a secret — the credential it points at stays in the Keychain, which is + * the entire point of this mode. Mutually exclusive with the key fields; the + * server refuses the combination rather than picking one. + */ + configDir?: string +} + +/** WHAT RUNS. Holds no credential, so it crosses whole. */ +export type AgentProfile = { + agent?: string + label?: string + models?: Record + compat?: Record + env?: Record + contextWindows?: Record + skipPermissions?: boolean +} + +export type SelectionStrategy = 'single' | 'round-robin' | 'usage' + +/** THE PAIRING. References plus the rule for choosing among them. */ +export type Profile = { + label?: string + agentProfile: string + accounts: string[] + strategy?: SelectionStrategy +} + +export type CustomProvider = { + id: string + label: string + baseUrl: string + credentialEnv?: string + credentialOptional?: boolean + defaultCredential?: string + defaultModels?: Record + env?: Record + unsetEnv?: string[] + compat?: Record + subagentFollowsOpus?: boolean +} + +export type Bootstrap = { + state: { + providerAccounts: Record + agentProfiles: Record + profiles: Record + defaultProfile: string | null + bindings: Record + settings: { quiet?: boolean; bindingWalkDepth?: number } + } + revision: string | null + readOnly: boolean + corrupt: boolean + warnings: string[] + configPath: string + providers: ProviderInfo[] + agents: AgentInfo[] + tiers: string[] + compatFlags: CompatFlag[] + credentialEnvs: string[] + installedAgents: InstalledAgent[] | null + /** + * Who each session account is logged in as, keyed by account name. + * + * Null when the server wired no identity reader — NOT an empty map, which + * would be indistinguishable from "every account is logged out". Key-mode + * accounts are simply absent from it. + */ + logins: Record | null + customProviders: Record + reservedProviderIds: string[] +} + +/** One window of a subscription, as the endpoint publishes it. */ +export type UsageWindow = { utilization: number | null; resetsAt: string | null } + +export type MeasuredAccount = { + name: string + mode: 'session' | 'key' + login: string | null + remaining: number | null + fiveHour: UsageWindow | null + sevenDay: UsageWindow | null +} + +export type UsageReport = { + accounts: MeasuredAccount[] + checkedAt: number | null +} + +/** A refusal the UI must render rather than swallow. */ +export class ApiError extends Error { + status: number + errors: string[] + constructor(status: number, message: string, errors: string[] = []) { + super(message) + this.status = status + this.errors = errors + } +} + +async function call(path: string, init: RequestInit = {}): Promise { + const res = await fetch(path, { + ...init, + headers: { + [TOKEN_HEADER]: token(), + ...(init.body ? { 'content-type': 'application/json' } : {}), + ...init.headers, + }, + }) + const body = (await res.json().catch(() => ({}))) as Record + if (!res.ok) { + throw new ApiError( + res.status, + typeof body.error === 'string' ? body.error : `HTTP ${res.status}`, + Array.isArray(body.errors) ? (body.errors as string[]) : [], + ) + } + return body as T +} + +export type DoctorCheck = { + id: string + title: string + status: 'ok' | 'warn' | 'error' | 'skip' + detail: string + fix?: string +} + +export type DoctorReport = { + profile: string | null + provider: string | null + endpoint: string | null + checks: DoctorCheck[] + notes: string[] + 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 +} + +export type CatalogResult = { + id: string + label: string + capabilities: { pricing: boolean; benchmarks: boolean; toolSupportKnown: boolean } + models: CatalogModel[] + fromCache: boolean + stale: boolean + error: string | null +} + +export const api = { + bootstrap: () => call('/api/bootstrap'), + + saveAccount: (name: string, account: unknown, revision: string | null) => + call<{ revision: string }>(`/api/accounts/${encodeURIComponent(name)}`, { + method: 'PUT', + body: JSON.stringify({ revision, account }), + }), + + deleteAccount: (name: string, revision: string | null) => + call<{ revision: string; affectedProfiles: string[] }>( + `/api/accounts/${encodeURIComponent(name)}`, + { method: 'DELETE', body: JSON.stringify({ revision }) }, + ), + + saveAgentProfile: (name: string, agentProfile: unknown, revision: string | null) => + call<{ revision: string }>(`/api/agent-profiles/${encodeURIComponent(name)}`, { + method: 'PUT', + body: JSON.stringify({ revision, agentProfile }), + }), + + deleteAgentProfile: (name: string, revision: string | null) => + call<{ revision: string; affectedProfiles: string[] }>( + `/api/agent-profiles/${encodeURIComponent(name)}`, + { method: 'DELETE', body: JSON.stringify({ revision }) }, + ), + + saveProfile: (name: string, profile: unknown, revision: string | null) => + call<{ revision: string }>(`/api/profiles/${encodeURIComponent(name)}`, { + method: 'PUT', + body: JSON.stringify({ revision, profile }), + }), + + deleteProfile: (name: string, revision: string | null) => + call<{ revision: string }>(`/api/profiles/${encodeURIComponent(name)}`, { + method: 'DELETE', + body: JSON.stringify({ revision }), + }), + + setDefault: (name: string, revision: string | null) => + call<{ revision: string }>('/api/default', { + method: 'PUT', + body: JSON.stringify({ revision, name }), + }), + + saveProvider: (id: string, provider: unknown, revision: string | null) => + call<{ revision: string; warnings?: string[] }>(`/api/providers/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify({ revision, provider }), + }), + + deleteProvider: (id: string, revision: string | null) => + call<{ revision: string; orphanedProfiles: string[] }>( + `/api/providers/${encodeURIComponent(id)}`, + { method: 'DELETE', body: JSON.stringify({ revision }) }, + ), + + /** + * `offline` defaults to true server-side. Sending false is opting IN to real, + * billable inference probes — so the UI must make that an explicit click. + */ + doctor: (offline: boolean) => + call<{ report: DoctorReport; offline: boolean }>('/api/doctor', { + method: 'POST', + body: JSON.stringify({ offline }), + }), + + catalog: (id: string) => call(`/api/catalog/${encodeURIComponent(id)}`), + + /** + * Measure every account's remaining subscription window, and cache it. + * + * A POST, not a GET, because on macOS each measurement can raise a Keychain + * unlock dialog — and a GET is something a browser may prefetch, retry or + * replay on its own initiative. Nothing that can pop a system dialog should + * be reachable that way. + */ + usage: () => call('/api/usage', { method: 'POST', body: JSON.stringify({}) }), + + saveSettings: (settings: unknown, revision: string | null) => + call<{ revision: string }>('/api/settings', { + method: 'PUT', + body: JSON.stringify({ revision, settings }), + }), +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..9ff17a0 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,12 @@ +@layer reset, base, tokens, recipes, utilities; + +html, body, #root { height: 100% } +body { + margin: 0; + background: var(--colors-bg); + color: var(--colors-text); + font-family: var(--fonts-sans); + font-size: 13px; + -webkit-font-smoothing: antialiased; +} +input, select, button { font-family: inherit } diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..a633310 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/routes/Accounts.tsx b/web/src/routes/Accounts.tsx new file mode 100644 index 0000000..068c171 --- /dev/null +++ b/web/src/routes/Accounts.tsx @@ -0,0 +1,384 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap, type UsageReport, type UsageWindow } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' + +/** + * Provider accounts — who pays. + * + * An account authenticates one of two ways, and they are mutually exclusive: + * + * key an API key, WRITE-ONLY. The server sends `hasKey` and never the + * key, so the field offers to REPLACE what is stored: leaving it + * blank changes nothing, and clearing it is a separate, explicit + * action. "I did not touch this" and "delete my credential" must not + * be the same gesture. + * session a directory holding a login the agent already performed. The path + * crosses to this page in full because it is not a secret — the + * credential stays in the OS keychain, which is the whole point. + * + * Deleting shows which profiles the account backs rather than repairing them. + * Only the user knows which account should pay instead. + */ +/** + * One window, in the smallest space that stays honest. + * + * 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". + */ +function pct(w: UsageWindow | null): string { + return w?.utilization === null || w === null ? '—' : `${w.utilization}%` +} + +export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const accounts = Object.entries(data.state.providerAccounts ?? {}) + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + const [warnings, setWarnings] = useState([]) + const [usage, setUsage] = useState(null) + const [measuring, setMeasuring] = useState(false) + + const measure = async () => { + setError(null) + setMeasuring(true) + try { + setUsage(await api.usage()) + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } finally { + setMeasuring(false) + } + } + + const open = (name: string | null) => { + setError(null) + setWarnings([]) + setEditing(name ?? '') + setDraft( + name + ? { ...data.state.providerAccounts[name], apiKey: '' } + : { provider: data.providers[0]?.id ?? 'anthropic', apiKey: '' }, + ) + } + + const save = async (name: string) => { + setError(null) + try { + const body: Record = { ...draft } + // An empty string means "untouched", so it never leaves the browser. The + // server ignores it anyway; not sending it makes the intent explicit at + // the boundary that owns it. + if (!body.apiKey) delete body.apiKey + delete body.hasKey + // Switching an account to session mode must CLEAR the key it used to + // carry, not leave it stored behind the new one. The server refuses an + // account holding both — correctly, since "which credential did this + // actually use" must never have a subtle answer — so the explicit null + // is what makes the switch expressible at all. + if (body.configDir) { + body.apiKey = null + body.apiKeyFromEnv = '' + } + await api.saveAccount(name, body, data.revision) + setEditing(null) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const remove = async (name: string) => { + setError(null) + try { + const res = await api.deleteAccount(name, data.revision) + if (res.affectedProfiles.length > 0) { + setWarnings([ + `These profiles still reference "${name}" and will not launch until you repoint ` + + `them: ${res.affectedProfiles.join(', ')}`, + ]) + } + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const field = (k: string) => (draft[k] as string) ?? '' + const put = (k: string, v: unknown) => setDraft((d) => ({ ...d, [k]: v })) + + if (editing !== null) { + const isNew = !data.state.providerAccounts?.[editing] + const provider = data.providers.find((p) => p.id === draft.provider) + const stored = data.state.providerAccounts?.[editing] + return ( + <> +
+ +

+ {isNew ? 'New account' : `Account · ${editing}`} +

+
+ {error ? {error} : null} + + + {isNew ? ( + + setEditing(e.target.value)} + placeholder="work" + /> + + ) : null} + + put('label', e.target.value)} /> + + + + + {provider?.askBaseUrl || draft.baseUrl ? ( + + put('baseUrl', e.target.value)} + placeholder={provider?.baseUrl ?? 'https://…'} + /> + + ) : null} + + + + + + + + {draft.configDir ? ( + <> + + put('configDir', e.target.value)} + placeholder="~/.claude" + /> + + {/* + THE ONE THING THIS PAGE CANNOT DO. `/login` is an interactive + OAuth flow inside the agent's own TUI; a browser tab cannot + drive it. Pointing an account at an empty directory here is + legal and silently useless until someone logs in there, so the + terminal command that does it is named rather than implied. + */} + + Logging in happens in a terminal, not here — run{' '} + swisscode config accounts login {editing || ''} and complete{' '} + /login inside the agent. This page only points the account at a + directory. + + {!isNew && data.logins ? ( +
+ currently: {data.logins[editing] ?? 'not logged in'} +
+ ) : null} + + ) : ( + <> + + put('apiKey', e.target.value)} + placeholder={stored?.hasKey ? '•••••••• stored' : 'paste key'} + /> + + + put('apiKeyFromEnv', e.target.value)} + placeholder="MY_TOKEN" + /> + + + )} +
+ +
+ + +
+ + ) + } + + return ( + <> +
+

Accounts

+
+ {/* + Measuring is a BUTTON rather than something the page does on load. + Each session account costs a keychain read — which on macOS can pop + an unlock dialog — plus a request to Anthropic. A screen that did + that every time you opened it would be indefensible. + */} + + +
+
+ {error ? {error} : null} + {warnings.map((w) => ( + + {w} + + ))} + {usage?.checkedAt === null ? ( + + Nothing could be measured, so the cached figures were left alone. An account must be logged + in, and its token unexpired, to report a window. + + ) : null} + {usage?.checkedAt ? ( +
+ Measured {new Date(usage.checkedAt).toLocaleTimeString()}. “% left” is the tighter of the two + windows, never their average — an account at 5% of its 5-hour window and 95% of its weekly + one has almost nothing left. Profiles using the usage strategy now select on + these figures. +
+ ) : null} + + + {accounts.length === 0 ? ( + No accounts yet. An account is a provider plus the credential that pays for it. + ) : ( + accounts.map(([name, a]) => { + // 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 login = a.configDir ? (data.logins?.[name] ?? null) : null + const measured = usage?.accounts.find((m) => m.name === name) + return ( +
+ {/* + A session account is "ready" when someone has LOGGED IN there, + not when a path is set. A directory nobody has logged into + looks identical in config.json and fails only after execve, so + the dot tracks the login rather than the field. + */} + +
+
+ {name} + {a.label ? ( + + {a.label} + + ) : null} +
+
+ {a.provider} + {' · '} + {a.configDir + ? (login ?? 'not logged in') + : a.apiKeyFromEnv + ? `key from $${a.apiKeyFromEnv}` + : a.hasKey + ? 'key stored' + : 'no key'} + {' · '} + {usedBy.length > 0 ? `used by ${usedBy.join(', ')}` : 'unused'} +
+ {measured ? ( +
+ {measured.mode === 'key' + ? 'key account — no subscription window' + : measured.remaining === null + ? 'could not be measured' + : `${measured.remaining}% left · 5h ${pct(measured.fiveHour)} · 7d ${pct(measured.sevenDay)}`} +
+ ) : null} +
+ + +
+ ) + }) + )} +
+ + ) +} diff --git a/web/src/routes/AgentProfiles.tsx b/web/src/routes/AgentProfiles.tsx new file mode 100644 index 0000000..54ff3d0 --- /dev/null +++ b/web/src/routes/AgentProfiles.tsx @@ -0,0 +1,299 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' +import { ModelPicker } from './ModelPicker' + +/** + * Agent profiles — what runs. + * + * Holds no credential, which is why this screen has no password field and no + * redaction to think about. It is also the thing that can be SHARED: one setup + * ("Claude Code, yolo, glm on every tier") backing several profiles, each + * pointed at a different account. The listing says when one is shared, because + * editing a shared setup changes every profile that uses it. + * + * The model picker needs a provider to browse, and an agent profile has none — + * so it borrows one from a profile that uses this setup. When nothing does, + * there is no catalog to offer and the fields stay plain text, which is the + * honest answer rather than a picker over a list we cannot obtain. + */ +export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const agentProfiles = Object.entries(data.state.agentProfiles ?? {}) + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + const [warnings, setWarnings] = useState([]) + const [picking, setPicking] = useState(null) + + const open = (name: string | null) => { + setError(null) + setWarnings([]) + setEditing(name ?? '') + setDraft(name ? { ...data.state.agentProfiles[name] } : { models: {}, compat: {} }) + } + + const save = async (name: string) => { + setError(null) + try { + await api.saveAgentProfile(name, draft, data.revision) + setEditing(null) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const remove = async (name: string) => { + setError(null) + try { + const res = await api.deleteAgentProfile(name, data.revision) + if (res.affectedProfiles.length > 0) { + setWarnings([ + `These profiles still reference "${name}" and will not launch until you repoint ` + + `them: ${res.affectedProfiles.join(', ')}`, + ]) + } + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const put = (k: string, v: unknown) => setDraft((d) => ({ ...d, [k]: v })) + const models = (draft.models as Record) ?? {} + const compat = (draft.compat as Record) ?? {} + + /** Which profiles use a given agent profile — the reverse index. */ + const usersOf = (name: string) => + Object.entries(data.state.profiles ?? {}) + .filter(([, p]) => p.agentProfile === name) + .map(([n]) => n) + + if (editing !== null) { + const isNew = !data.state.agentProfiles?.[editing] + const users = usersOf(editing) + // Borrow a provider from a profile that uses this setup, purely so the + // picker has a catalog to browse. + const viaProfile = users.map((n) => data.state.profiles[n]).find(Boolean) + const viaAccount = viaProfile?.accounts?.[0] + ? data.state.providerAccounts?.[viaProfile.accounts[0]] + : undefined + const provider = data.providers.find((p) => p.id === viaAccount?.provider) + + return ( + <> +
+ +

+ {isNew ? 'New agent profile' : `Agent profile · ${editing}`} +

+
+ {error ? {error} : null} + {users.length > 1 ? ( + + Shared by {users.length} profiles ({users.join(', ')}). Changes here affect all of them. + + ) : null} + + + {isNew ? ( + + setEditing(e.target.value)} + placeholder="main" + /> + + ) : null} + + put('label', e.target.value)} + /> + + + + + + + +

+ All four tiers, from one table. Claude Code reads the extended-context marker per + variable, so a tier left out is the bug where three run wide and the fourth silently + does not. Blank inherits the provider default. +

+ {data.tiers.map((tier) => ( + + + put('models', { ...models, [tier]: e.target.value })} + placeholder={provider?.defaultModels?.[tier] ?? '—'} + /> + {provider?.catalogId ? ( + + ) : null} + + + ))} + {!provider ? ( +

+ No profile uses this setup yet, so there is no provider to browse a catalog from. + Type ids by hand, or attach it to a profile first. +

+ ) : null} + + {picking && provider?.catalogId ? ( + setPicking(null)} + onPick={(model) => { + setDraft((d) => ({ + ...d, + models: { ...((d.models as Record) ?? {}), [picking]: model.id }, + // Capture the MEASURED window alongside the id — the only + // moment it is known, and what later lets swisscode set an + // auto-compact window without ever guessing one. + ...(model.context + ? { + contextWindows: { + ...((d.contextWindows as Record) ?? {}), + [model.id]: model.context, + }, + } + : {}), + })) + setPicking(null) + }} + /> + ) : null} +
+ + + + +
+ Gateway compatibility +
+ {data.compatFlags.map((flag) => ( + + ))} +
+ +
+ + +
+ + ) + } + + return ( + <> +
+

Agent profiles

+ +
+ {error ? {error} : null} + {warnings.map((w) => ( + + {w} + + ))} + + + {agentProfiles.length === 0 ? ( + None yet. An agent profile is a coding CLI plus how it should behave. + ) : ( + agentProfiles.map(([name, ap]) => { + const users = usersOf(name) + const pinned = data.tiers.filter((t) => ap.models?.[t]).length + return ( +
+ 0 ? 'ok' : 'faint'} /> +
+
+ {name} + {users.length > 1 ? ( + + shared + + ) : null} +
+
+ {ap.agent ?? 'claude-code'} + {' · '} + {pinned > 0 ? `${pinned}/${data.tiers.length} tiers pinned` : 'provider defaults'} + {' · '} + {users.length > 0 ? `used by ${users.join(', ')}` : 'unused'} +
+
+ + +
+ ) + }) + )} +
+ + ) +} diff --git a/web/src/routes/Doctor.tsx b/web/src/routes/Doctor.tsx new file mode 100644 index 0000000..a773b80 --- /dev/null +++ b/web/src/routes/Doctor.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type DoctorReport } from '../api' +import { Banner, Button, Dot, Empty, Panel } from '../ui' + +const TONE = { ok: 'ok', warn: 'warn', error: 'danger', skip: 'faint' } as const + +/** + * The doctor, on demand. + * + * Offline is the default and the network run is a separate, clearly-labelled + * button: the probes are real inference requests, and a UI that spends money on + * a click nobody understood would be a worse bug than anything it diagnoses. + */ +export function Doctor() { + const [report, setReport] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [probed, setProbed] = useState(false) + + const run = async (offline: boolean) => { + setBusy(true) + setError(null) + try { + const res = await api.doctor(offline) + setReport(res.report) + setProbed(!res.offline) + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + return ( + <> +

Doctor

+ {error ? {error} : null} + + + + + + } + > +

+ Offline checks read your config, resolve the agent binary and inspect the environment. + Live probes additionally send a real request to the endpoint — at most one per distinct + model plus one tool-calling probe — which your provider will bill you for. +

+
+ + {report ? ( + + {report.checks.length === 0 ? ( + No checks ran. + ) : ( + report.checks.map((c) => ( +
+ + + +
+
{c.title}
+
+ {c.detail} +
+ {c.fix ? ( +
+ ↳ {c.fix} +
+ ) : null} +
+
+ )) + )} + {report.notes.map((n) => ( +

+ {n} +

+ ))} +
+ ) : null} + + ) +} diff --git a/web/src/routes/ModelPicker.tsx b/web/src/routes/ModelPicker.tsx new file mode 100644 index 0000000..53bd397 --- /dev/null +++ b/web/src/routes/ModelPicker.tsx @@ -0,0 +1,190 @@ +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 browsable model list, for providers that publish one. + * + * Two rules carried over from the Ink picker, because they are properties of the + * data rather than of the widget: + * + * * `tools` is TRI-STATE. Only a CONFIRMED absence hides a row; UNKNOWN stays + * visible. Collapsing them would empty the list for any catalog that does + * not publish capability at all. + * * Nothing missing is rendered as a number. A catalog with no prices shows + * no prices, rather than "$0.00", which would read as free. + */ +export function ModelPicker({ + catalogId, + tier, + onPick, + onClose, +}: { + catalogId: string + tier: string + onPick: (model: CatalogModel) => void + onClose: () => void +}) { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [toolsOnly, setToolsOnly] = useState(true) + + useEffect(() => { + let live = true + api + .catalog(catalogId) + .then((r) => live && setData(r)) + .catch((err) => live && setError(err instanceof ApiError ? err.message : String(err))) + return () => { + live = false + } + }, [catalogId]) + + 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]) + + return ( +
+
e.stopPropagation()} + className={css({ + bg: 'panel', + border: '1px solid', + borderColor: 'lineStrong', + borderRadius: 'lg', + w: '100%', + maxW: '44rem', + maxH: '80vh', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + })} + > +
+ setQuery(e.target.value)} + /> + +
+ +
+ + {rows.length} + {data ? `/${data.models.length}` : ''} shown + + {data?.capabilities.toolSupportKnown ? ( + + ) : null} + {data?.stale ? stale cache : null} + {data?.fromCache && !data.stale ? cached : null} +
+ +
+ {error ? {error} : null} + {data?.error && data.models.length === 0 ? ( + Could not fetch the catalog: {data.error} + ) : null} + {!data && !error ? ( +

loading catalog…

+ ) : null} + + {rows.map((m) => ( + + ))} +
+
+
+ ) +} diff --git a/web/src/routes/Profiles.tsx b/web/src/routes/Profiles.tsx new file mode 100644 index 0000000..3356971 --- /dev/null +++ b/web/src/routes/Profiles.tsx @@ -0,0 +1,278 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap, type SelectionStrategy } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle } from '../ui' + +/** + * Profiles — the pairing, and the only screen that expresses MULTIPLE accounts. + * + * Holds no credential and no agent settings of its own: it names one agent + * profile, one or more accounts, and the rule for choosing among them. Editing + * what those references point AT happens on the other two screens, which is the + * whole reason the split exists. + */ +const STRATEGIES: { id: SelectionStrategy; label: string; note: string }[] = [ + { id: 'single', label: 'Single', note: 'Always the first account. No state, no surprises.' }, + { + id: 'round-robin', + label: 'Round robin', + note: + 'Advances one account per LAUNCH, not per request — swisscode hands off and exits, so ' + + 'there is nothing left to rotate mid-session.', + }, + { + id: 'usage', + label: 'By remaining capacity', + note: + 'Picks the account with the most left, from the last measurement. swisscode cannot check ' + + 'this at launch, so it uses what the doctor or this UI cached — and falls back to the ' + + 'first account, saying so, when nothing has measured it yet.', + }, +] + +export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const names = Object.keys(data.state.profiles ?? {}) + const accountNames = Object.keys(data.state.providerAccounts ?? {}) + const agentProfileNames = Object.keys(data.state.agentProfiles ?? {}) + + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + + const open = (name: string | null) => { + setError(null) + setEditing(name ?? '') + setDraft( + name + ? { ...data.state.profiles[name] } + : { + agentProfile: agentProfileNames[0] ?? '', + accounts: accountNames[0] ? [accountNames[0]] : [], + strategy: 'single', + }, + ) + } + + const save = async (name: string) => { + setError(null) + try { + await api.saveProfile(name, draft, data.revision) + setEditing(null) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const act = async (fn: () => Promise) => { + setError(null) + try { + await fn() + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const put = (k: string, v: unknown) => setDraft((d) => ({ ...d, [k]: v })) + const accounts = (draft.accounts as string[]) ?? [] + const strategy = (draft.strategy as SelectionStrategy) ?? 'single' + + if (editing !== null) { + const isNew = !data.state.profiles?.[editing] + const canCreate = agentProfileNames.length > 0 && accountNames.length > 0 + return ( + <> +
+ +

+ {isNew ? 'New profile' : `Profile · ${editing}`} +

+
+ {error ? {error} : null} + {!canCreate ? ( + + A profile references an account and an agent profile, so at least one of each has to + exist first. + + ) : null} + + + {isNew ? ( + + setEditing(e.target.value)} + placeholder="work" + /> + + ) : null} + + + + + + +

+ Who pays, in preference order. Attach more than one to rotate or to pick by remaining + capacity. +

+ {accountNames.map((n) => { + const on = accounts.includes(n) + const a = data.state.providerAccounts[n]! + return ( + + ) + })} + {accounts.length === 0 ? ( +

+ A profile with no account has nothing to authenticate with and will not launch. +

+ ) : null} +
+ + {accounts.length > 1 ? ( + + {STRATEGIES.map((s) => ( + + ))} + + ) : null} + +
+ + +
+ + ) + } + + return ( + <> +
+

Profiles

+ +
+ {error ? {error} : null} + + + {names.length === 0 ? ( + No profiles yet. A profile pairs an agent profile with one or more accounts. + ) : ( + names.map((name) => { + const p = data.state.profiles[name]! + const isDefault = data.state.defaultProfile === name + // Report what it RESOLVES to, not what it references — a list of + // key names would make the reader do the dereference in their head. + const first = p.accounts?.[0] + const account = first ? data.state.providerAccounts?.[first] : undefined + const broken = + !data.state.agentProfiles?.[p.agentProfile] || (p.accounts ?? []).length === 0 || !account + return ( +
+ +
+
+ {name} + {isDefault ? ( + + default + + ) : null} +
+
+ {broken + ? 'broken reference — open to repair' + : `${p.agentProfile} · ${first} → ${account!.provider}` + + ((p.accounts?.length ?? 0) > 1 + ? ` (+${p.accounts!.length - 1}, ${p.strategy ?? 'single'})` + : '')} +
+
+ {!isDefault ? ( + + ) : null} + + +
+ ) + }) + )} +
+ + ) +} diff --git a/web/src/routes/Providers.tsx b/web/src/routes/Providers.tsx new file mode 100644 index 0000000..2c572e2 --- /dev/null +++ b/web/src/routes/Providers.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' + +/** + * Shipped presets are read-only here and say so. They are constants in source, + * guarded by tests a config file cannot reach; presenting them as editable + * would imply a capability that does not exist. + * + * Custom providers are editable, and every refusal from the server is rendered + * verbatim — those messages are the runtime twin of the shipped descriptors' + * test suite, so they are the most useful thing on the screen when a save fails. + */ +export function Providers({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const custom = Object.entries(data.customProviders) + const shipped = data.providers.filter((p) => data.reservedProviderIds.includes(p.id)) + + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + const [errors, setErrors] = useState([]) + const [warnings, setWarnings] = useState([]) + + const open = (id: string | null) => { + setError(null) + setErrors([]) + setWarnings([]) + setEditing(id ?? '') + setDraft(id ? { ...data.customProviders[id] } : { label: '', baseUrl: '', defaultModels: {} }) + } + + const save = async (id: string) => { + setError(null) + setErrors([]) + try { + const res = await api.saveProvider(id, draft, data.revision) + setWarnings(res.warnings ?? []) + setEditing(null) + await reload() + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + setErrors(err.errors) + } else setError(String(err)) + } + } + + const remove = async (id: string) => { + setError(null) + try { + const res = await api.deleteProvider(id, data.revision) + if (res.orphanedProfiles.length > 0) { + // Reported, never silently repaired: only the user knows where those + // profiles should point now. + setWarnings([ + `These profiles still name "${id}" and will not launch until you repoint them: ` + + res.orphanedProfiles.join(', '), + ]) + } + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const field = (k: string) => (draft[k] as string) ?? '' + const put = (k: string, v: unknown) => setDraft((d) => ({ ...d, [k]: v })) + const models = (draft.defaultModels as Record) ?? {} + + if (editing !== null) { + const isNew = !data.customProviders[editing] + return ( + <> +
+ +

+ {isNew ? 'New provider' : `Provider · ${editing}`} +

+
+ + {error ? ( + + {errors.length > 1 ? ( +
    + {errors.map((e) => ( +
  • {e}
  • + ))} +
+ ) : ( + error + )} +
+ ) : null} + + + {isNew ? ( + + setEditing(e.target.value)} + placeholder="my-gateway" + /> + + ) : null} + + put('label', e.target.value)} /> + + + put('baseUrl', e.target.value)} + placeholder="https://gateway.example.com/anthropic" + /> + + + + + + + + +

+ Optional. A profile can override any of these. Do not type an extended-context + marker — that suffix is derived from a verified capability, and an id the endpoint + does not recognise fails hard. +

+ {data.tiers.map((tier) => ( + + put('defaultModels', { ...models, [tier]: e.target.value })} + /> + + ))} +
+ +
+ + +
+ + ) + } + + return ( + <> +
+

Providers

+ +
+ + {error ? {error} : null} + {warnings.map((w) => ( + + {w} + + ))} + + + {custom.length === 0 ? ( + None yet. Add one for a gateway or a local server swisscode does not ship a preset for. + ) : ( + custom.map(([id, p]) => ( +
+ +
+
{p.label}
+
+ {id} · {p.baseUrl} +
+
+ + +
+ )) + )} +
+ + +

+ Read-only. These are constants in swisscode's source, checked by tests that a config + file cannot reach — including verified extended-context claims. +

+ {shipped.map((p) => ( +
+
+
{p.label}
+
+ {p.baseUrl ?? 'agent default'} +
+
+ {p.catalogId ? ( + browsable catalog + ) : null} +
+ ))} +
+ + ) +} diff --git a/web/src/routes/Settings.tsx b/web/src/routes/Settings.tsx new file mode 100644 index 0000000..fb3f8e6 --- /dev/null +++ b/web/src/routes/Settings.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap } from '../api' +import { Banner, Button, Field, Panel, inputStyle } from '../ui' + +export function Settings({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const [quiet, setQuiet] = useState(Boolean(data.state.settings.quiet)) + const [depth, setDepth] = useState(String(data.state.settings.bindingWalkDepth ?? '')) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + + const save = async () => { + setError(null) + setSaved(false) + try { + const settings: Record = { quiet } + const n = Number(depth) + if (depth.trim() && Number.isInteger(n)) settings.bindingWalkDepth = n + await api.saveSettings(settings, data.revision) + setSaved(true) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + return ( + <> +

Settings

+ {error ? {error} : null} + + + +

+ swisscode writes to stderr only; stdout belongs to the agent. A clean environment + already prints nothing, which is what makes the lines it does print worth reading. +

+
+ + + + setDepth(e.target.value)} placeholder="40" /> + + + +
+ + {saved ? saved : null} +
+ + ) +} diff --git a/web/src/ui.tsx b/web/src/ui.tsx new file mode 100644 index 0000000..2ad02f1 --- /dev/null +++ b/web/src/ui.tsx @@ -0,0 +1,187 @@ +// Shared primitives. Small on purpose — the Linear look is mostly restraint, +// so there are few components and they are reused rather than varied. +import type { ReactNode } from 'react' +import { css, cx } from '../styled-system/css' + +export const row = css({ + display: 'flex', + alignItems: 'center', + gap: '2', +}) + +export function Button({ + children, + onClick, + variant = 'default', + disabled, + type = 'button', +}: { + children: ReactNode + onClick?: () => void + variant?: 'default' | 'primary' | 'danger' + disabled?: boolean + type?: 'button' | 'submit' +}) { + return ( + + ) +} + +export function Field({ + label, + hint, + children, +}: { + label: string + hint?: string | undefined + children: ReactNode +}) { + return ( + + ) +} + +export const inputStyle = css({ + width: '100%', + bg: 'bg', + border: '1px solid', + borderColor: 'line', + borderRadius: 'md', + color: 'text', + font: 'inherit', + fontSize: '13px', + px: '2.5', + height: '30px', + outline: 'none', + transition: 'border-color 120ms ease', + _focus: { borderColor: 'accent' }, + _placeholder: { color: 'faint' }, +}) + +export const monoInput = cx(inputStyle, css({ fontFamily: 'mono', fontSize: '12.5px' })) + +export function Panel({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { + return ( +
+
+

+ {title} +

+ {action} +
+
{children}
+
+ ) +} + +/** A status dot. Colour is reserved for exactly this kind of signal. */ +export function Dot({ tone }: { tone: 'ok' | 'warn' | 'danger' | 'faint' }) { + return ( + + ) +} + +export function Empty({ children }: { children: ReactNode }) { + return ( +

{children}

+ ) +} + +/** Errors are rendered, never swallowed — a refused save must be visible. */ +export function Banner({ tone, children }: { tone: 'danger' | 'warn'; children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..3b8fc7d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023", "dom", "dom.iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src", "styled-system", "*.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..cca9608 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +/** + * Builds the SPA into dist/web, which swisscode's own node:http server then + * serves. There is no Vite server in production and no SSR: the API is + * swisscode's, the security gate is swisscode's, and this is a static bundle. + * + * `base: './'` so the assets resolve regardless of where the server mounts + * them, and no hashed-directory assumptions leak into the HTML. + */ +export default defineConfig({ + root: __dirname, + base: './', + plugins: [react()], + /** + * React's API, Preact's runtime — A BUILD-TIME SUBSTITUTION ONLY. + * + * react-dom is 168 kB of the bundle this server ships, and this page uses + * almost none of what that buys: seven screens, `useState`/`useEffect`/ + * `useMemo`/`useCallback`, no concurrent rendering, no suspense, no server + * components. 237.9 kB became 69.8 kB. + * + * The source still imports `react` and still typechecks against + * `@types/react` — the alias exists here, at the bundler, so nothing in + * `web/src` has to know. Reverting is deleting this block. + * + * NOT related to the `react` in `dependencies`. That one is Ink's, it renders + * the TERMINAL wizard, and it is untouched by any of this. + */ + resolve: { + alias: { + react: 'preact/compat', + 'react-dom': 'preact/compat', + 'react-dom/client': 'preact/compat/client', + 'react/jsx-runtime': 'preact/jsx-runtime', + }, + }, + build: { + outDir: '../dist/web', + emptyOutDir: true, + }, +})