diff --git a/DIVERGENCES.md b/DIVERGENCES.md index eb2babcd7e..7a123876ff 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -16,10 +16,15 @@ upstream maintainers — see README → Syncing with upstream for why and how. | File | Change | Why | On conflict | | --- | --- | --- | --- | | `packages/core/src/global.ts`, `packages/core/test/global.test.ts`, `packages/opencode/test/cli/mcp-add.test.ts` | `const app = "opencode"` → `"codev-code"`; test expectations `tmpdir()/opencode` → `tmpdir()/codev-code` and `.config/opencode/` → `.config/codev-code/` | Config/data/cache/state dirs under `~/.config/codev-code` etc.; upstream tests assert the literal dir name. | Keep ours (the one constant + expectations); take upstream's surrounding changes. | -| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command (the `codev` hub forwards unknown commands to this binary). The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | +| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file) and added `configure` to the help-snapshot TOP_LEVEL list; test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | | `package.json` (root) | Added `release` script delegating to packages/opencode | `bun run release` works from the repo root. | Keep our release line; take upstream's other fields. | | `packages/opencode/package.json` | bin key `opencode` → `codev` (shim path `./bin/opencode` unchanged); added `release` script | Installed command is `codev` — matches the CLI's `scriptName` and help text (the hub's command is `codevhub`; it relinquished the `codev` bin in codev-ai ≥ 0.4.0). `bun run release` publishes the version in this file (upstream-synced; script/release.ts is fork-only). | Keep our bin key and release line; take upstream's other fields including `version`. | -| `bun.lock` | Mirrors the bin key rename above | Lockfile records workspace bin mappings. | Regenerate with `bun install`; never hand-merge. | +| `bun.lock` | Mirrors the bin key rename above and the fork-only workspace packages/deps (`@codev/gateway`) | Lockfile records workspace bin mappings and workspace deps. | Regenerate with `bun install`; never hand-merge. | +| `packages/tui/package.json` | Added dependency `"@codev/gateway": "workspace:*"` | The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only `packages/codev-gateway`. | Keep our dependency line; take upstream's other fields. | +| `packages/tui/src/feature-plugins/home/tips-view.tsx` | `NO_MODELS_TIP` points at `/configure` (CoDev gateway) instead of `/connect` | The unconfigured-state tip should lead to the fork's configure flow. | Keep our tip text. | +| `packages/tui/src/ui/dialog.tsx` | Added a `persistent` store flag (with `setPersistent`/`persistent` accessors): escape and the click-outside backdrop no-op while set, and ctrl+c dispatches `app.exit` (base-mode-only, so suppressed under modal) instead of closing; every `replace`/`clear` resets it to false | The CoDev gateway configure flow is a non-dismissable first-run gate; ctrl+c stays the quit escape hatch. Default-false keeps every other dialog dismissable. | Keep our persistent flag, the escape/backdrop guards, the ctrl+c app.exit dispatch, and the reset lines; take upstream's surrounding changes. | +| `packages/tui/src/ui/dialog-select.tsx` | Added a `persistent?` prop that hides the top-right `esc` hint | A non-dismissable select shouldn't advertise `esc` (the dialog store's persistent flag blocks the actual close). | Keep our prop + the `Show` wrapper around the esc label; take upstream's surrounding changes. | +| `packages/tui/src/ui/dialog-prompt.tsx` | Added `persistent?` (hides the `esc` hint + `DialogPrompt.show` re-arms the store flag), `onInput?` (fires on textarea content change), and `ref?` props | The configure flow's prompts (SSO login, manual credentials) are non-dismissable, and the SSO view watches `onInput` for the lone-`c` copy shortcut on the paste-back link (needs the `ref` to clear the field). | Keep our three props, the `Show` around the esc label, the textarea `onContentChange`, and the `.show` re-arm; take upstream's surrounding changes. | | `packages/opencode/script/build.ts` | Platform packages' `package.json` name `codev-code-` (dist dir names unchanged — the bun compile target derives from them) | npm publishing under the fork's names. | Keep our `name:` line; take upstream's surrounding changes. | | `packages/opencode/script/publish.ts` | `PUBLISH_NAME = "codev-code"` const; npm description "CoDev Code — the AI agent harness"; published-name→dir map; main pkg name + bin key `codev`; embedded README (hub via `codevhub install`, direct install runs `codev`); env-guarded exit before the Docker/AUR/Homebrew section | npm-only publishing under the fork's names (unscoped, public on npmjs); the installed command is `codev`; upstream's other channels target anomalyco infra. | Keep our names, description, bin key + early exit; take upstream's npm-section changes above the exit. | | `packages/opencode/bin/opencode` | `base` prefix `opencode-` → `codev-code-` | Runtime platform-binary lookup must match the published names. | Keep our prefix. | @@ -30,11 +35,12 @@ upstream maintainers — see README → Syncing with upstream for why and how. | `packages/opencode/src/cli/ui.ts` | `wordmark`/`logo()` render the hub-style CODEV banner (bold bright cyan on TTY) | Help banner matches the hub CLI banner. | Keep our wordmark + logo(); take upstream's surrounding changes. | | `packages/tui/src/util/presentation.ts` | Local logo copy replaced by the shared CODEV `banner` (cyan) | Presentation-mode wordmark was still upstream-branded. | Keep our wordmark(); take upstream's surrounding changes. | | `packages/opencode/src/cli/cmd/*.ts` (tui, run, serve, attach, pr, upgrade, uninstall, web) | Help `describe` strings "opencode" → "codev" | `--help` is the first thing users read. | Keep our strings. | -| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|" | Tab title branding. | Keep our strings. | +| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; startup dialog effect gates on `!useConnected()` (not `provider.length === 0` — hosted Zen free models always register) and opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file); added the `codev.configure` command (`/configure`) and a `CODEV_CONFIGURE=1` mount hook for `codev configure` | Tab title branding; first-run setup routes to the CoDev gateway configure flow (upstream's `/connect` stays as the escape hatch). | Keep our strings, gate condition, dialog swap + command block; take upstream's surrounding changes. | | `packages/tui/src/routes/session/permission.tsx`, `packages/opencode/src/cli/cmd/run/permission.shared.ts`, `packages/opencode/test/cli/run/permission.shared.test.ts`, `packages/tui/src/component/error-component.tsx`, `packages/opencode/src/cli/cmd/run/splash.ts` | Visible sentences/badges "OpenCode"/"opencode" → "CoDev Code"/"codev-code" (incl. the permission always-allow copy and its test expectations) | User-facing copy. | Keep our strings. | | `packages/opencode/src/installation/index.ts` | Updater detects/queries/installs `codev-code` (npm-family); GitHub fallback → quickbeard/codev-code | `codev-code upgrade` and the update notification target OUR npm package, never stock opencode. brew/choco/scoop probes never match (no such packages). | Keep our package names. | | `packages/opencode/src/config/config.ts` | `share` defaults to `"disabled"` when unset | Sharing publishes sessions to upstream's share service; internal users must opt in via config. | Keep our default block. | | `packages/tui/src/component/dialog-provider.tsx`, `packages/tui/src/component/dialog-model.tsx`, `packages/opencode/src/cli/cmd/providers.ts` | Filter `opencode`/`opencode-go` out of provider pickers and the `/model` dialog (model list + Favorites/Recent; the model dialog's opencode-first sort became a plain name sort) | Upstream's hosted offerings (Zen/Go) are detours for gateway users. | Keep our filters. | +| `packages/tui/src/component/prompt/index.tsx` | Added an `isHostedZenModel` memo (current `providerID` is `opencode`/`opencode-go`) and gated the prompt footer's model/provider indicator on `!isHostedZenModel()` | Don't advertise the hosted Zen default (e.g. "Big Pickle · OpenCode Zen") in the prompt footer — same rationale as the provider/model filters. A configured gateway/real provider still shows its model. | Keep the memo + the extra `Show` condition; take upstream's surrounding changes. | | `packages/app/index.html` | `` → "CoDev Code" | Web UI tab title. | Keep our title. | | `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev Code" | Visible menu branding. | Keep our labels. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev Code" (Dev/Beta variants too) | Desktop app display name. | Keep our names. | diff --git a/README.md b/README.md index 1810dc53a9..5b4c7aa400 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CoDev Code -CoDev Code is a fork of [OpenCode](https://github.com/anomalyco/opencode) that tracks upstream while carrying a small set of organization-specific changes. Read [DIVERGENCES.md](DIVERGENCES.md) before editing any upstream file — it defines where changes are allowed to live. +CoDev Code is a fork of [OpenCode](https://github.com/anomalyco/opencode) that tracks upstream while carrying a set of specific changes. Read [DIVERGENCES.md](DIVERGENCES.md) before editing any upstream file — it defines where changes are allowed to live. Upstream's [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md) conventions (commit style, code style, package layout) apply here unchanged. diff --git a/bun.lock b/bun.lock index da736b1f19..c1147a3736 100644 --- a/bun.lock +++ b/bun.lock @@ -154,6 +154,13 @@ "@typescript/native-preview": "catalog:", }, }, + "packages/codev-gateway": { + "name": "@codev/gateway", + "version": "0.0.1", + "devDependencies": { + "@types/bun": "catalog:", + }, + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.17.16", @@ -946,6 +953,7 @@ "name": "@opencode-ai/tui", "version": "1.17.16", "dependencies": { + "@codev/gateway": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -1468,6 +1476,8 @@ "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="], + "@codev/gateway": ["@codev/gateway@workspace:packages/codev-gateway"], + "@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], diff --git a/packages/codev-gateway/package.json b/packages/codev-gateway/package.json new file mode 100644 index 0000000000..7773a5132f --- /dev/null +++ b/packages/codev-gateway/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/package", + "name": "@codev/gateway", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "catalog:" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/codev-gateway/src/auth-file.ts b/packages/codev-gateway/src/auth-file.ts new file mode 100644 index 0000000000..d733c790eb --- /dev/null +++ b/packages/codev-gateway/src/auth-file.ts @@ -0,0 +1,155 @@ +import { randomBytes } from "node:crypto" +import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { dirname, join } from "node:path" + +export interface AuthData { + access_token: string + id_token: string + refresh_token?: string + expires_at: number + user: { + sub: string + email: string + displayName: string + } +} + +export interface ApiKeyCreds { + apiKey: string + baseUrl?: string + model?: string +} + +export interface CodevConfig { + supabaseUrl: string + supabaseAnonKey: string + gatewayUrl: string +} + +// ~/.codev/auth.json is SHARED with the codev hub: identical path and shape +// (hub src/lib/auth.ts AuthFileContents), so a hub installed later reuses the +// session this flow created and vice versa. Three independent blocks — SSO +// tokens, gateway API key, CoDev runtime config — each updated in isolation so +// saving one never clobbers the others. skillhub_cookie is hub-only; the +// read-merge-write pattern preserves it untouched. +interface AuthFileContents { + access_token?: string + id_token?: string + refresh_token?: string + expires_at?: number + user?: AuthData["user"] + api_key?: string + base_url?: string + model?: string + supabase_url?: string + supabase_anon_key?: string + gateway_url?: string + skillhub_cookie?: string +} + +export function authFilePath() { + return join(homedir(), ".codev", "auth.json") +} + +// Written by the hub's `codevhub logout` (revoking tokens doesn't end the +// IdP's browser session); honored here so a post-logout login re-prompts for +// credentials instead of silently riding the session cookie. +export function forceLoginPath() { + return join(homedir(), ".codev", "force-login") +} + +export function readAuthFile(): AuthFileContents | null { + try { + return JSON.parse(readFileSync(authFilePath(), "utf-8")) as AuthFileContents + } catch { + return null + } +} + +function writeAuthFile(data: AuthFileContents): void { + const path = authFilePath() + const dir = dirname(path) + mkdirSync(dir, { recursive: true, mode: 0o700 }) + // mkdirSync's mode is ignored when the directory already exists, and + // writeFileSync's mode is ignored when the file already exists, so re-apply + // permissions explicitly to tighten any pre-existing loose perms. + chmodSync(dir, 0o700) + // Atomic write: stage to a per-process tmp sibling, chmod 0600 before rename + // so the published file never appears world-readable, then rename over the + // live path — concurrent writers (the hub's daemon, another codev process) + // end up with one whole write or the other, never a torn merge. + const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp` + try { + writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 }) + chmodSync(tmp, 0o600) + renameSync(tmp, path) + } catch (err) { + try { + unlinkSync(tmp) + } catch { + // tmp may already be absent. + } + throw err + } + // Re-apply mode after rename for the overwrite case where the renamed inode + // inherits the destination's prior mode on some platforms. + chmodSync(path, 0o600) +} + +export function loadAuth(): AuthData | null { + const raw = readAuthFile() + if (!raw) return null + if (!raw.access_token || !raw.id_token || !raw.expires_at || !raw.user) return null + if (Date.now() > raw.expires_at) return null + return { + access_token: raw.access_token, + id_token: raw.id_token, + refresh_token: raw.refresh_token, + expires_at: raw.expires_at, + user: raw.user, + } +} + +export function saveAuth(data: AuthData): void { + writeAuthFile({ + ...(readAuthFile() ?? {}), + access_token: data.access_token, + id_token: data.id_token, + refresh_token: data.refresh_token, + expires_at: data.expires_at, + user: data.user, + }) +} + +export function saveApiKey(creds: ApiKeyCreds): void { + writeAuthFile({ + ...(readAuthFile() ?? {}), + api_key: creds.apiKey, + base_url: creds.baseUrl, + model: creds.model, + }) +} + +export function loadApiKey(): ApiKeyCreds | null { + const raw = readAuthFile() + if (!raw?.api_key) return null + return { + apiKey: raw.api_key, + baseUrl: raw.base_url, + model: raw.model, + } +} + +export function saveCodevConfig(config: CodevConfig): void { + writeAuthFile({ + ...(readAuthFile() ?? {}), + supabase_url: config.supabaseUrl, + supabase_anon_key: config.supabaseAnonKey, + gateway_url: config.gatewayUrl, + }) +} + +export function loadGatewayUrl(): string | null { + return readAuthFile()?.gateway_url ?? null +} diff --git a/packages/codev-gateway/src/backend.ts b/packages/codev-gateway/src/backend.ts new file mode 100644 index 0000000000..a74598eebb --- /dev/null +++ b/packages/codev-gateway/src/backend.ts @@ -0,0 +1,95 @@ +import { BACKEND_URL } from "./const" +import type { CodevConfig } from "./auth-file" + +interface ErrorResponse { + error?: string +} + +const VALIDATE_TIMEOUT_MS = 5_000 +const MODELS_TIMEOUT_MS = 10_000 +// Backend endpoints are quick: a token exchange and a tiny config blob. Cap +// them so a stalled gateway surfaces as an error instead of wedging the TUI. +const BACKEND_TIMEOUT_MS = 10_000 + +// SSO access token → gateway API key (backend /auth/exchange). An empty key is +// returned, not thrown — callers route to the manual-credentials fallback. +export async function fetchApiKey(accessToken: string): Promise<string> { + const res = await fetch(`${BACKEND_URL}/auth/exchange`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS), + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ErrorResponse + throw new Error(`Backend /auth/exchange failed (${res.status}): ${body.error || res.statusText}`) + } + const data = (await res.json()) as { api_key?: string } + return data.api_key ?? "" +} + +// Pulls the runtime coordinates that are not baked into the binary — most +// importantly the public gateway base URL. Persisted next to the SSO session +// via saveCodevConfig, where the hub also reads it. +export async function fetchCodevConfig(accessToken: string): Promise<CodevConfig> { + const res = await fetch(`${BACKEND_URL}/config`, { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(BACKEND_TIMEOUT_MS), + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ErrorResponse + throw new Error(`Backend /config failed (${res.status}): ${body.error || res.statusText}`) + } + const data = (await res.json()) as Partial<{ supabaseUrl: string; supabaseAnonKey: string; gatewayUrl: string }> + if (!data.supabaseUrl || !data.supabaseAnonKey || !data.gatewayUrl) { + throw new Error(`Backend /config returned incomplete payload: ${JSON.stringify(data)}`) + } + return { + supabaseUrl: data.supabaseUrl, + supabaseAnonKey: data.supabaseAnonKey, + gatewayUrl: data.gatewayUrl, + } +} + +// Build a `<base>/v1/<suffix>` gateway URL. Manual base URLs may or may not +// carry a /v1 suffix — normalize so exactly one /v1 segment precedes the +// suffix either way. +function gatewayV1Url(baseUrl: string, suffix: string): string { + const base = baseUrl.replace(/\/$/, "") + const withV1 = /\/v1$/.test(base) ? base : `${base}/v1` + return `${withV1}/${suffix}` +} + +// Fetches the model ids available to the given key via the OpenAI-compatible +// /v1/models endpoint. Throws on non-2xx, network errors, timeout, or an +// empty result — the caller decides whether to fall back. +export async function fetchModels(apiKey: string, baseUrl: string): Promise<string[]> { + const res = await fetch(gatewayV1Url(baseUrl, "models"), { + headers: { accept: "application/json", Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(MODELS_TIMEOUT_MS), + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ErrorResponse + throw new Error(`Models fetch failed (${res.status}): ${body.error || res.statusText}`) + } + const data = (await res.json()) as { data?: Array<{ id?: string }> } + const ids = (data.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === "string" && id.length > 0) + if (ids.length === 0) throw new Error("Gateway returned no models") + return ids +} + +// Validates a key against the gateway's /key/info endpoint (LiteLLM): true on +// 2xx, false on 401/403 (invalid/revoked), throws on anything else so callers +// can distinguish "key is bad" from "couldn't reach the gateway". /key/info +// lives at the gateway root, so a trailing /v1 is stripped before joining. +export async function validateApiKey(apiKey: string, baseUrl: string): Promise<boolean> { + const stripped = baseUrl.replace(/\/?v1\/?$/, "") + const url = `${stripped.endsWith("/") ? stripped : `${stripped}/`}key/info` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS), + }) + if (res.status === 401 || res.status === 403) return false + if (!res.ok) throw new Error(`Validation failed (${res.status}): ${res.statusText}`) + return true +} diff --git a/packages/codev-gateway/src/config-block.ts b/packages/codev-gateway/src/config-block.ts new file mode 100644 index 0000000000..df088e613d --- /dev/null +++ b/packages/codev-gateway/src/config-block.ts @@ -0,0 +1,48 @@ +import { GATEWAY_COMPACT_RESERVED, GATEWAY_CONTEXT_WINDOW, GATEWAY_MAX_OUTPUT_TOKENS } from "./const" + +// The exact opencode.json fragment the codev hub writes for CoDev Code (hub +// src/lib/configure.ts configureOpenCodeKind), so hub-managed and +// self-configured installs are indistinguishable — including the hub's +// `provider.aigateway` presence check that marks a config as CoDev-managed. +// The per-model `limit` is mandatory: without it opencode sizes the context +// window to 0 and disables auto-compaction. +export function buildGatewayConfig(input: { apiKey: string; baseUrl: string; model: string; models?: string[] }) { + const ids = [...new Set([input.model, ...(input.models ?? [])])] + return { + model: `aigateway/${input.model}`, + compaction: { + auto: true, + reserved: GATEWAY_COMPACT_RESERVED, + }, + provider: { + aigateway: { + npm: "@ai-sdk/openai-compatible", + name: "AI Gateway", + options: { + baseURL: normalizeGatewayBaseUrl(input.baseUrl), + apiKey: input.apiKey, + }, + models: Object.fromEntries( + ids.map((id) => [ + id, + { + name: id, + limit: { + context: GATEWAY_CONTEXT_WINDOW, + output: GATEWAY_MAX_OUTPUT_TOKENS, + }, + }, + ]), + ), + }, + }, + } +} + +// opencode's openai-compatible loader wants the /v1 root in baseURL; users +// paste the gateway origin with or without it — normalize to exactly one +// trailing /v1 (hub normalizeOpenCodeBaseUrl). +export function normalizeGatewayBaseUrl(base: string): string { + const stripped = base.replace(/\/$/, "") + return /\/v1$/.test(stripped) ? stripped : `${stripped}/v1` +} diff --git a/packages/codev-gateway/src/const.ts b/packages/codev-gateway/src/const.ts new file mode 100644 index 0000000000..247ff26f5a --- /dev/null +++ b/packages/codev-gateway/src/const.ts @@ -0,0 +1,18 @@ +// CoDev backend coordinates, mirrored from the codev hub's src/lib/const.ts so +// a standalone CoDev Code install can configure the gateway without the hub. +// Base64 (like the hub) to keep the host out of casual greps, not as a secret. +const BASE_URL = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4=") +export const BACKEND_URL = `${BASE_URL}/codev-backend` +export const SSO_URL = `${BASE_URL}/sso-wrapper` +export const LOGIN_SUCCESS_URL = `${BASE_URL}/codev-landing-page/oauth/success` + +export const CLIENT_ID = atob("bGl0ZWxsbS10ZXN0") + +// The self-hosted gateway model window and the compaction reserve derived from +// firing auto-compaction at ~85% of it — identical to the hub's constants so a +// hub-written and an agent-written opencode.json agree byte-for-byte. +export const GATEWAY_CONTEXT_WINDOW = 196608 +export const GATEWAY_COMPACT_PCT = 85 +export const GATEWAY_COMPACT_TRIGGER = Math.round(GATEWAY_CONTEXT_WINDOW * (GATEWAY_COMPACT_PCT / 100)) +export const GATEWAY_COMPACT_RESERVED = GATEWAY_CONTEXT_WINDOW - GATEWAY_COMPACT_TRIGGER +export const GATEWAY_MAX_OUTPUT_TOKENS = 65536 diff --git a/packages/codev-gateway/src/index.ts b/packages/codev-gateway/src/index.ts new file mode 100644 index 0000000000..124126ef97 --- /dev/null +++ b/packages/codev-gateway/src/index.ts @@ -0,0 +1,14 @@ +export { + authFilePath, + loadApiKey, + loadAuth, + loadGatewayUrl, + saveApiKey, + saveCodevConfig, + type ApiKeyCreds, + type AuthData, + type CodevConfig, +} from "./auth-file" +export { fetchApiKey, fetchCodevConfig, fetchModels, validateApiKey } from "./backend" +export { buildGatewayConfig, normalizeGatewayBaseUrl } from "./config-block" +export { login, parseCallbackParams, silentSso, type LoginOptions, type OnLoginReady } from "./sso" diff --git a/packages/codev-gateway/src/sso.ts b/packages/codev-gateway/src/sso.ts new file mode 100644 index 0000000000..755b815bed --- /dev/null +++ b/packages/codev-gateway/src/sso.ts @@ -0,0 +1,414 @@ +import { existsSync, unlinkSync } from "node:fs" +import { createServer } from "node:http" +import type { AddressInfo } from "node:net" +import { authFilePath, forceLoginPath, loadAuth, readAuthFile, saveAuth, type AuthData } from "./auth-file" +import { CLIENT_ID, LOGIN_SUCCESS_URL, SSO_URL } from "./const" + +// Token/userinfo endpoints are quick handshakes — cap them so a hung IdP +// surfaces as an error instead of wedging the configure dialog indefinitely. +const SSO_FETCH_TIMEOUT_MS = 10_000 +// How long we wait for the authorization code — via the loopback callback or +// a manual paste-back. Generous because a no-browser user may be hopping to +// another device to authenticate and copy the URL back by hand. +const AUTH_CALLBACK_TIMEOUT_MS = 300_000 + +interface TokenResponse { + access_token: string + id_token: string + refresh_token?: string + expires_in: number +} + +// How login() hands the interactive step back to its caller: a function that +// opens the browser, the directly-pasteable /authorize URL for no-browser +// environments, and a manual paste-back submitter that returns an inline error +// string to re-prompt (or null once the code is accepted — the login() promise +// then resolves on its own). +export type OnLoginReady = ( + openBrowserFn: () => void, + authUrl: string, + submitManualCode: (pasted: string) => string | null, +) => void + +export interface LoginOptions { + onLog: (msg: string) => void + onReady: OnLoginReady + // Browser launcher; the TUI passes its own `open` util. Fire-and-forget — + // the loopback server resolves the flow whether or not a browser launched. + openUrl: (url: string) => Promise<unknown> +} + +/** + * OAuth2 Authorization Code flow with PKCE against the CoDev SSO wrapper, + * ported from the hub (src/lib/auth.ts) so both tools share one session in + * ~/.codev/auth.json: + * 1. Reuse cached tokens if still valid + * 2. Try a silent refresh if a refresh_token is on disk + * 3. Otherwise: loopback HTTP server, /authorize with state + nonce + PKCE, + * wait for the callback (or manual paste), exchange code for tokens, fetch + * userinfo, persist. + */ +export async function login(options: LoginOptions): Promise<AuthData> { + const log = options.onLog + log("Starting SSO login...") + + // Dev escape hatch (hub parity): skip OAuth and persist a stub session so + // the rest of the flow can be exercised while the SSO wrapper is down. + if (process.env.CODEV_BYPASS_LOGIN === "1") { + log("CODEV_BYPASS_LOGIN=1 — skipping SSO, using stub session.") + const authData: AuthData = { + access_token: "codev-bypass-no-sso", + id_token: "codev-bypass-no-sso", + expires_at: Date.now() + 3_600_000, + user: { sub: "codev-bypass", email: "bypass@local", displayName: "Bypass User" }, + } + saveAuth(authData) + clearForceLogin() + log(`Logged in as ${authData.user.email}`) + return authData + } + + const existing = loadAuth() + if (existing) { + log(`Already logged in as ${existing.user.email}`) + return existing + } + + const stale = readAuthFile() + if (stale?.refresh_token) { + try { + log("Refreshing session...") + const authData = await refreshToAuthData(stale.refresh_token) + saveAuth(authData) + log(`Logged in as ${authData.user.email}`) + return authData + } catch { + log("Refresh failed, starting full login...") + } + } + + // Force re-auth via the IdP login form (prompt=login bounce) when there is + // no auth.json at all (fresh machine / post-`codevhub remove`) or the hub's + // logout left its force-login sentinel — revoking tokens does not terminate + // the IdP's browser-session cookie. + const forceLogin = !existsSync(authFilePath()) || existsSync(forceLoginPath()) + + const verifier = generateCodeVerifier() + const challenge = await generateCodeChallenge(verifier) + const state = crypto.randomUUID() + const nonce = crypto.randomUUID() + + const { code, redirectUri } = await getAuthCode(options, state, challenge, nonce, forceLogin) + + const tokenRes = await exchangeCode(code, redirectUri, verifier) + const user = await fetchUserInfo(tokenRes.access_token) + + const authData: AuthData = { + access_token: tokenRes.access_token, + id_token: tokenRes.id_token, + refresh_token: tokenRes.refresh_token, + expires_at: Date.now() + tokenRes.expires_in * 1000, + user: { + sub: user.sub, + email: user.email, + displayName: user.displayName || user.name || user.sub, + }, + } + + saveAuth(authData) + clearForceLogin() + log(`Logged in as ${authData.user.email}`) + return authData +} + +// Non-interactive SSO: reuse a non-expired cached session, else silently +// refresh via the stored refresh_token. Returns null when neither works — +// callers then run the interactive login(). +export async function silentSso(): Promise<AuthData | null> { + const cached = loadAuth() + if (cached) return cached + const stale = readAuthFile() + if (!stale?.refresh_token) return null + try { + const authData = await refreshToAuthData(stale.refresh_token) + saveAuth(authData) + return authData + } catch { + return null + } +} + +async function refreshToAuthData(refreshToken: string): Promise<AuthData> { + const refreshed = await refreshTokens(refreshToken) + const user = await fetchUserInfo(refreshed.access_token) + return { + access_token: refreshed.access_token, + id_token: refreshed.id_token, + refresh_token: refreshed.refresh_token || refreshToken, + expires_at: Date.now() + refreshed.expires_in * 1000, + user: { + sub: user.sub, + email: user.email, + displayName: user.displayName || user.name || user.sub, + }, + } +} + +function clearForceLogin() { + try { + unlinkSync(forceLoginPath()) + } catch { + // Fine if it didn't exist. + } +} + +function base64UrlEncode(bytes: Uint8Array): string { + let str = "" + for (const b of bytes) str += String.fromCharCode(b) + return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") +} + +function generateCodeVerifier(): string { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return base64UrlEncode(bytes) +} + +async function generateCodeChallenge(verifier: string): Promise<string> { + const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) + return base64UrlEncode(new Uint8Array(hash)) +} + +// Extracts OAuth callback params from pasted text: a full callback URL, a +// scheme-less "127.0.0.1:PORT/callback?...", a bare query string, or a leading +// "?". Returns null when the paste carries no recognizable params — the caller +// then treats it as a bare authorization code. +export function parseCallbackParams(pasted: string): URLSearchParams | null { + try { + return new URL(pasted).searchParams + } catch { + // Not a full URL — fall through. + } + const query = pasted.includes("?") ? pasted.slice(pasted.indexOf("?") + 1) : pasted + if (/(?:^|&)(?:code|state|error)=/.test(query)) { + return new URLSearchParams(query) + } + return null +} + +function getAuthCode( + options: LoginOptions, + expectedState: string, + codeChallenge: string, + nonce: string, + forceLogin: boolean, +): Promise<{ code: string; redirectUri: string }> { + return new Promise((resolve, reject) => { + let timeoutHandle: ReturnType<typeof setTimeout> | null = null + // Set once the flow resolves or rejects — by the loopback callback or by a + // manual paste — so the two paths can't both fire. + let settled = false + const finish = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle) + timeoutHandle = null + } + } + + // Captured once when listen() completes — server.address() returns null + // after close(), and a stray keep-alive request can arrive that late. + let boundPort = 0 + + // Two authorize targets, mirroring loopback OAuth CLIs (gh, gcloud): the + // local browser is sent straight to the loopback callback; the manual + // paste-back channel gets the public success page, which displays the code + // to copy when the terminal is on another machine. The wrapper keys the + // code to PKCE, so the token exchange can use the loopback redirect_uri + // for either. + const buildAuthorizeUrl = (redirectUri: string) => + `${SSO_URL}/authorize?` + + `response_type=code` + + `&client_id=${encodeURIComponent(CLIENT_ID)}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=openid%20profile%20email%20offline_access` + + `&state=${expectedState}` + + `&nonce=${nonce}` + + `&code_challenge=${codeChallenge}` + + `&code_challenge_method=S256` + + const loopbackAuthorizeUrl = (port: number) => buildAuthorizeUrl(`http://127.0.0.1:${port}/callback`) + const manualAuthorizeUrl = buildAuthorizeUrl(LOGIN_SUCCESS_URL) + + const succeed = (code: string) => { + if (settled) return + settled = true + finish() + server.close() + resolve({ code, redirectUri: `http://127.0.0.1:${boundPort}/callback` }) + } + + const failWith = (err: Error) => { + if (settled) return + settled = true + finish() + server.close() + reject(err) + } + + // Manual paste-back for no-browser environments: accepts a full URL, a + // bare query string, or a bare code. Returns an inline error string to + // re-prompt without restarting, or null once the code is accepted. + const submitManualCode = (pasted: string): string | null => { + if (settled) return null + const trimmed = pasted.trim() + if (!trimmed) return "Paste the callback URL (or code) first." + + const params = parseCallbackParams(trimmed) + const errParam = params?.get("error") + if (errParam) { + return `SSO returned an error: ${params?.get("error_description") || errParam}` + } + + // No params → treat the whole paste as a bare authorization code. There + // is no state to cross-check, but PKCE still binds it to our verifier at + // the token exchange. + const code = params ? params.get("code") : trimmed + if (!code) return "No authorization code found in the pasted URL." + const returnedState = params?.get("state") ?? null + if (returnedState !== null && returnedState !== expectedState) { + return "That URL is from a different login attempt (state mismatch). Use the most recent URL." + } + + succeed(code) + return null + } + + const server = createServer((req, res) => { + const host = req.headers.host ?? "127.0.0.1" + const url = new URL(req.url ?? "/", `http://${host}`) + + // force-login only: the IdP has just killed its session cookie and + // redirected back here — bounce on to /authorize so it shows the + // credential form. + if (url.pathname === "/logout-done") { + res.writeHead(302, { Location: loopbackAuthorizeUrl(boundPort) }) + res.end() + return + } + + if (url.pathname !== "/callback") { + res.writeHead(404, { "Content-Type": "text/plain" }) + res.end("Not found") + return + } + + const code = url.searchParams.get("code") + const error = url.searchParams.get("error") + const returnedState = url.searchParams.get("state") + + // Hand the browser on to the hosted success page as the user-facing + // confirmation; failures carry ?error=... so the page renders an error. + const respond = (ok: boolean, msg?: string) => { + const sep = LOGIN_SUCCESS_URL.includes("?") ? "&" : "?" + const location = ok + ? LOGIN_SUCCESS_URL + : `${LOGIN_SUCCESS_URL}${sep}error=login_failed&error_description=${encodeURIComponent(msg ?? "Unknown error")}` + res.writeHead(302, { Location: location }) + res.end() + } + + if (error) { + const desc = url.searchParams.get("error_description") || error + respond(false, desc) + failWith(new Error(`SSO login failed: ${desc}`)) + return + } + if (!code) { + respond(false, "No authorization code received") + failWith(new Error("No authorization code received")) + return + } + if (returnedState !== expectedState) { + respond(false, "State mismatch") + failWith(new Error("State mismatch (possible CSRF attack)")) + return + } + + respond(true) + succeed(code) + }) + + server.listen(0, "127.0.0.1", () => { + boundPort = (server.address() as AddressInfo).port + // Local browser: loopback authorize (on force-login via the /logout + // bounce that clears the IdP cookie first). Manual channel: the public + // success-page authorize URL a remote browser can complete. + const initialUrl = forceLogin + ? `${SSO_URL}/logout?redirect_uri=${encodeURIComponent(`http://127.0.0.1:${boundPort}/logout-done`)}` + : loopbackAuthorizeUrl(boundPort) + + // Arm the timeout before handing control to the caller so a caller that + // settles synchronously clears a real timer instead of leaking one. + timeoutHandle = setTimeout(() => { + timeoutHandle = null + failWith(new Error("Login timed out after 5 minutes")) + }, AUTH_CALLBACK_TIMEOUT_MS) + + options.onReady( + () => { + options.onLog( + forceLogin ? "Opening browser to end existing SSO session and re-login..." : "Opening browser for SSO login...", + ) + options.openUrl(initialUrl).catch(() => {}) + }, + manualAuthorizeUrl, + submitManualCode, + ) + }) + }) +} + +async function exchangeCode(code: string, redirectUri: string, codeVerifier: string): Promise<TokenResponse> { + const res = await fetch(`${SSO_URL}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: CLIENT_ID, + code_verifier: codeVerifier, + }), + signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`Token exchange failed (${res.status}): ${await res.text()}`) + } + return (await res.json()) as TokenResponse +} + +async function refreshTokens(refreshToken: string): Promise<TokenResponse> { + const res = await fetch(`${SSO_URL}/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + }) + if (!res.ok) throw new Error(`Token refresh failed (${res.status})`) + return (await res.json()) as TokenResponse +} + +async function fetchUserInfo(accessToken: string) { + const res = await fetch(`${SSO_URL}/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(SSO_FETCH_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`Failed to fetch user info (${res.status}): ${await res.text()}`) + } + return (await res.json()) as { sub: string; email: string; displayName?: string; name?: string } +} diff --git a/packages/codev-gateway/test/auth-file.test.ts b/packages/codev-gateway/test/auth-file.test.ts new file mode 100644 index 0000000000..954001b1b8 --- /dev/null +++ b/packages/codev-gateway/test/auth-file.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +let tempDir: string +let originalHome: string | undefined +let originalProfile: string | undefined + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-gateway-auth-")) + originalHome = process.env.HOME + originalProfile = process.env.USERPROFILE + process.env.HOME = tempDir + process.env.USERPROFILE = tempDir +}) + +afterEach(() => { + process.env.HOME = originalHome + process.env.USERPROFILE = originalProfile + rmSync(tempDir, { recursive: true, force: true }) +}) + +describe("auth-file", () => { + test("blocks merge without clobbering each other", async () => { + const { saveApiKey, saveCodevConfig, loadApiKey, authFilePath } = await import("../src/auth-file") + saveApiKey({ apiKey: "sk-1", baseUrl: "https://gw.test", model: "m" }) + saveCodevConfig({ supabaseUrl: "https://sb.test", supabaseAnonKey: "anon", gatewayUrl: "https://gw.test" }) + + expect(loadApiKey()).toEqual({ apiKey: "sk-1", baseUrl: "https://gw.test", model: "m" }) + const raw = JSON.parse(readFileSync(authFilePath(), "utf-8")) + expect(raw.api_key).toBe("sk-1") + expect(raw.gateway_url).toBe("https://gw.test") + }) + + test("preserves unknown fields like the hub's skillhub_cookie", async () => { + const { saveApiKey, authFilePath } = await import("../src/auth-file") + const { mkdirSync, writeFileSync } = await import("node:fs") + mkdirSync(join(tempDir, ".codev"), { recursive: true }) + writeFileSync(authFilePath(), JSON.stringify({ skillhub_cookie: "skill-hub-session=abc" })) + + saveApiKey({ apiKey: "sk-2" }) + const raw = JSON.parse(readFileSync(authFilePath(), "utf-8")) + expect(raw.skillhub_cookie).toBe("skill-hub-session=abc") + expect(raw.api_key).toBe("sk-2") + }) + + test.skipIf(process.platform === "win32")("writes the file 0600", async () => { + const { saveApiKey, authFilePath } = await import("../src/auth-file") + saveApiKey({ apiKey: "sk-3" }) + expect(statSync(authFilePath()).mode & 0o777).toBe(0o600) + }) + + test("loadAuth rejects expired or partial sessions", async () => { + const { loadAuth, saveAuth } = await import("../src/auth-file") + expect(loadAuth()).toBeNull() + saveAuth({ + access_token: "at", + id_token: "it", + expires_at: Date.now() - 1000, + user: { sub: "s", email: "e@x", displayName: "E" }, + }) + expect(loadAuth()).toBeNull() + saveAuth({ + access_token: "at", + id_token: "it", + expires_at: Date.now() + 60_000, + user: { sub: "s", email: "e@x", displayName: "E" }, + }) + expect(loadAuth()?.user.email).toBe("e@x") + }) +}) diff --git a/packages/codev-gateway/test/config-block.test.ts b/packages/codev-gateway/test/config-block.test.ts new file mode 100644 index 0000000000..35821e854c --- /dev/null +++ b/packages/codev-gateway/test/config-block.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { buildGatewayConfig, normalizeGatewayBaseUrl } from "../src/config-block" + +describe("normalizeGatewayBaseUrl", () => { + test("appends /v1 when missing", () => { + expect(normalizeGatewayBaseUrl("https://gw.test")).toBe("https://gw.test/v1") + }) + + test("keeps an existing /v1 and strips a trailing slash", () => { + expect(normalizeGatewayBaseUrl("https://gw.test/v1")).toBe("https://gw.test/v1") + expect(normalizeGatewayBaseUrl("https://gw.test/v1/")).toBe("https://gw.test/v1") + expect(normalizeGatewayBaseUrl("https://gw.test/")).toBe("https://gw.test/v1") + }) +}) + +describe("buildGatewayConfig", () => { + test("matches the hub-written opencode.json shape", () => { + const config = buildGatewayConfig({ + apiKey: "sk-test", + baseUrl: "https://gw.test", + model: "MiniMax/MiniMax-M2.7", + models: ["MiniMax/MiniMax-M2.7", "other/model"], + }) + expect(config).toEqual({ + model: "aigateway/MiniMax/MiniMax-M2.7", + compaction: { auto: true, reserved: 29491 }, + provider: { + aigateway: { + npm: "@ai-sdk/openai-compatible", + name: "AI Gateway", + options: { baseURL: "https://gw.test/v1", apiKey: "sk-test" }, + models: { + "MiniMax/MiniMax-M2.7": { name: "MiniMax/MiniMax-M2.7", limit: { context: 196608, output: 65536 } }, + "other/model": { name: "other/model", limit: { context: 196608, output: 65536 } }, + }, + }, + }, + }) + }) + + test("falls back to a single-model map and dedupes the default", () => { + const config = buildGatewayConfig({ apiKey: "k", baseUrl: "https://gw.test/v1", model: "m" }) + expect(Object.keys(config.provider.aigateway.models)).toEqual(["m"]) + const withDupe = buildGatewayConfig({ apiKey: "k", baseUrl: "https://gw.test", model: "m", models: ["m", "n"] }) + expect(Object.keys(withDupe.provider.aigateway.models)).toEqual(["m", "n"]) + }) +}) diff --git a/packages/codev-gateway/test/sso.test.ts b/packages/codev-gateway/test/sso.test.ts new file mode 100644 index 0000000000..58e8b8f2dd --- /dev/null +++ b/packages/codev-gateway/test/sso.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { parseCallbackParams } from "../src/sso" + +describe("parseCallbackParams", () => { + test("parses a full callback URL", () => { + const params = parseCallbackParams("http://127.0.0.1:5173/callback?code=abc&state=xyz") + expect(params?.get("code")).toBe("abc") + expect(params?.get("state")).toBe("xyz") + }) + + test("parses a scheme-less host/path paste", () => { + const params = parseCallbackParams("127.0.0.1:5173/callback?code=abc&state=xyz") + expect(params?.get("code")).toBe("abc") + }) + + test("parses a bare query string with and without the leading ?", () => { + expect(parseCallbackParams("?code=abc")?.get("code")).toBe("abc") + expect(parseCallbackParams("code=abc&state=s")?.get("state")).toBe("s") + expect(parseCallbackParams("error=denied")?.get("error")).toBe("denied") + }) + + test("returns null for a bare authorization code", () => { + expect(parseCallbackParams("some-opaque-code")).toBeNull() + }) +}) diff --git a/packages/codev-gateway/tsconfig.json b/packages/codev-gateway/tsconfig.json new file mode 100644 index 0000000000..00ef125468 --- /dev/null +++ b/packages/codev-gateway/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noUncheckedIndexedAccess": false + } +} diff --git a/packages/opencode/src/cli/cmd/configure.ts b/packages/opencode/src/cli/cmd/configure.ts new file mode 100644 index 0000000000..950fa12c1f --- /dev/null +++ b/packages/opencode/src/cli/cmd/configure.ts @@ -0,0 +1,19 @@ +import { cmd } from "./cmd" +import { TuiThreadCommand } from "./tui" + +// Fork-only: `codev configure` opens the TUI with the CoDev gateway configure +// dialog (SSO or own API key) already on screen — the standalone equivalent of +// `codevhub config`. The env flag is read by the TUI's app mount +// (packages/tui/src/app.tsx); everything else is the normal TUI startup, so +// finishing (or dismissing) the dialog lands in the regular chat. +export const ConfigureCommand = cmd({ + command: "configure", + describe: "configure the CoDev AI gateway (SSO or API key)", + handler: async () => { + process.env.CODEV_CONFIGURE = "1" + await TuiThreadCommand.handler({ + _: [], + $0: "codev", + } as unknown as Parameters<typeof TuiThreadCommand.handler>[0]) + }, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 29ba3136f0..7e8c170edf 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -21,6 +21,7 @@ import { ImportCommand } from "./cli/cmd/import" import { AttachCommand } from "./cli/cmd/attach" import { TuiThreadCommand } from "./cli/cmd/tui" import { AcpCommand } from "./cli/cmd/acp" +import { ConfigureCommand } from "./cli/cmd/configure" import { EOL } from "os" import { WebCommand } from "./cli/cmd/web" import { PrCommand } from "./cli/cmd/pr" @@ -81,6 +82,7 @@ const cli = yargs(args) .command(AcpCommand) .command(McpCommand) .command(TuiThreadCommand) + .command(ConfigureCommand) .command(AttachCommand) .command(RunCommand) .command(GenerateCommand) diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index 6e1e3a769b..7875b0f20b 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -41,6 +41,19 @@ Options: --pure run without external plugins [boolean]" `; +exports[`codev CLI help-text snapshots every documented command emits stable help text: codev configure --help 1`] = ` +"codev configure + +configure the CoDev AI gateway (SSO or API key) + +Options: + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean]" +`; + exports[`codev CLI help-text snapshots every documented command emits stable help text: codev attach --help 1`] = ` "codev attach <url> diff --git a/packages/opencode/test/cli/help/help-snapshots.test.ts b/packages/opencode/test/cli/help/help-snapshots.test.ts index 390d90dd99..b846547b6a 100644 --- a/packages/opencode/test/cli/help/help-snapshots.test.ts +++ b/packages/opencode/test/cli/help/help-snapshots.test.ts @@ -45,6 +45,7 @@ function normalize(text: string): string { const TOP_LEVEL = [ "acp", "mcp", + "configure", "attach", "run", "debug", diff --git a/packages/tui/package.json b/packages/tui/package.json index cd853f05b5..d7107794f6 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -48,6 +48,7 @@ "./component/register-spinner": "./src/component/register-spinner.ts" }, "dependencies": { + "@codev/gateway": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -62,8 +63,8 @@ "open": "10.1.2", "opentui-spinner": "catalog:", "remeda": "catalog:", - "strip-ansi": "7.1.2", - "solid-js": "catalog:" + "solid-js": "catalog:", + "strip-ansi": "7.1.2" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 515e046c6a..b483656ef2 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -27,6 +27,7 @@ import { import { TuiPathsProvider, TuiStartupProvider, TuiTerminalEnvironmentProvider, useTuiStartup } from "./context/runtime" import { DialogProvider, useDialog } from "./ui/dialog" import { DialogProvider as DialogProviderList } from "./component/dialog-provider" +import { DialogConfigure } from "./component/dialog-configure" import { ErrorComponent } from "./component/error-component" import { PluginRouteMissing } from "./component/plugin-route-missing" import { ProjectProvider, useProject } from "./context/project" @@ -537,18 +538,29 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi }) }) + // Fork: upstream gates on a literally empty provider list, which a fresh + // install never has — the hosted Zen free models register as the `opencode` + // provider. Gate on useConnected() instead (false when only those free + // models exist), so first-run setup goes to the CoDev gateway configure + // flow; upstream's provider dialog stays reachable via /connect. + const connected = useConnected() createEffect( on( - () => sync.status === "complete" && sync.data.provider.length === 0, - (isEmpty, wasEmpty) => { - // only trigger when we transition into an empty-provider state - if (!isEmpty || wasEmpty) return - dialog.replace(() => <DialogProviderList />) + () => sync.status === "complete" && !connected(), + (unconfigured, wasUnconfigured) => { + // only trigger when we transition into an unconfigured state + if (!unconfigured || wasUnconfigured) return + dialog.replace(() => <DialogConfigure />) }, ), ) - const connected = useConnected() + // Fork: `codev configure` re-opens the gateway configure dialog on demand. + onMount(() => { + if (process.env.CODEV_CONFIGURE !== "1") return + dialog.replace(() => <DialogConfigure />) + }) + const currentWorktreeWorkspace = createMemo(() => { const workspaceID = project.workspace.current() if (!workspaceID) return @@ -745,6 +757,16 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi }, category: "Provider", }, + // Fork: the CoDev gateway configure flow (SSO or own API key). + { + name: "codev.configure", + title: "Configure CoDev gateway", + slashName: "configure", + run: () => { + dialog.replace(() => <DialogConfigure />) + }, + category: "Provider", + }, ...(sync.data.console_state.switchableOrgCount > 1 ? [ { diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx new file mode 100644 index 0000000000..985f28c751 --- /dev/null +++ b/packages/tui/src/component/dialog-configure.tsx @@ -0,0 +1,359 @@ +import { + buildGatewayConfig, + fetchApiKey, + fetchCodevConfig, + fetchModels, + login, + saveApiKey, + saveCodevConfig, + silentSso, +} from "@codev/gateway" +import { TextareaRenderable } from "@opentui/core" +import open from "open" +import { createSignal, onMount, Show, type Accessor } from "solid-js" +import { useClipboard } from "../context/clipboard" +import { useEpilogue } from "../context/epilogue" +import { useExit } from "../context/exit" +import { useSync } from "../context/sync" +import { useSDK } from "../context/sdk" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" +import { DialogPrompt } from "../ui/dialog-prompt" +import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select" +import { Link } from "../ui/link" +import { useToast } from "../ui/toast" + +// Fork-only: the standalone configure flow for the CoDev AI gateway — the +// in-agent equivalent of `codevhub config`. Login with SSO (gateway key minted +// via the CoDev backend) or paste an existing key, pick a default model, then +// write the same opencode.json provider block the hub writes. The SSO session +// and key land in the hub-shared ~/.codev/auth.json. Upstream's /connect +// dialog remains the escape hatch for non-gateway providers. +// +// Architecture note: dialog.replace stores the element FACTORY, and solid can +// re-invoke it on unrelated re-renders (a toast is enough) — remounting the +// component with fresh state. So all flow state lives in the awaited +// controller functions below (runSsoFlow / runModelFlow); the components the +// factories produce are pure views over controller-owned signals, and a +// remount is invisible. + +interface FlowContext { + dialog: ReturnType<typeof useDialog> + sdk: ReturnType<typeof useSDK> + sync: ReturnType<typeof useSync> + toast: ReturnType<typeof useToast> + exit: ReturnType<typeof useExit> + epilogue: ReturnType<typeof useEpilogue> +} + +export function DialogConfigure() { + const ctx: FlowContext = { + dialog: useDialog(), + sdk: useSDK(), + sync: useSync(), + toast: useToast(), + exit: useExit(), + epilogue: useEpilogue(), + } + + // The entry chooser is a non-dismissable setup gate: no escape, no + // click-outside. The next replace() (a flow step) resets the flag, so only + // this first screen is locked. Re-arm on mount so a remount stays locked. + onMount(() => ctx.dialog.setPersistent(true)) + + return ( + <DialogSelect + title="Configure CoDev Code" + persistent + renderFilter={false} + options={[ + { + title: "Login with SSO", + value: "sso", + onSelect: () => void runSsoFlow(ctx), + }, + { + title: "I have my own API key", + value: "manual", + async onSelect() { + const creds = await promptManualCredentials(ctx.dialog) + if (!creds) return + await runModelFlow(ctx, { + apiKey: creds.apiKey, + gatewayBaseUrl: creds.baseUrl, + authFileBaseUrl: creds.baseUrl, + }) + }, + }, + ]} + /> + ) +} + +async function promptManualCredentials(dialog: FlowContext["dialog"]) { + const baseUrl = await DialogPrompt.show(dialog, "API URL", { + placeholder: "https://gateway.example.com", + persistent: true, + }) + if (!baseUrl?.trim()) return null + const apiKey = await DialogPrompt.show(dialog, "API Key", { placeholder: "sk-...", persistent: true }) + if (!apiKey?.trim()) return null + return { baseUrl: baseUrl.trim(), apiKey: apiKey.trim() } +} + +// Renders a select and resolves with the chosen value. The whole configure +// flow is a non-dismissable gate, so the select is persistent (no esc hint, no +// escape/backdrop close); ctrl+c quits the app instead. resolve(null) only ever +// fires if a caller clears the dialog out from under it. +function showSelect<T>( + dialog: FlowContext["dialog"], + title: string, + options: (Omit<DialogSelectOption<T>, "onSelect"> & { value: T })[], +): Promise<T | null> { + return new Promise((resolve) => { + dialog.replace( + () => ( + <DialogSelect + title={title} + persistent + options={options.map((option) => ({ ...option, onSelect: () => resolve(option.value) }))} + /> + ), + () => resolve(null), + ) + // replace() resets the store's persistent flag; re-arm it for this step. + dialog.setPersistent(true) + }) +} + +function StatusBox(props: { text: string }) { + const { theme } = useTheme() + return ( + <box paddingLeft={2} paddingRight={2} paddingBottom={1}> + <text fg={theme.textMuted}>{props.text}</text> + </box> + ) +} + +const RETRIES = 3 + +// Auto-retries a transient network step (timeouts, flaky gateway) up to RETRIES +// times, surfacing the countdown in the status line. Throws the last error once +// the budget is spent, so the caller can quit. +async function withRetries<T>(label: string, onStatus: (text: string) => void, op: () => Promise<T>): Promise<T> { + for (let attempt = 0; ; attempt++) { + try { + return await op() + } catch (err) { + if (attempt >= RETRIES) throw err + onStatus(`${label} failed — retrying (${attempt + 1}/${RETRIES})...`) + } + } +} + +// The configure flow is a forced setup gate, so an unrecoverable failure must +// not silently drop the user into an unconfigured chat. Print the reason as an +// exit epilogue (shown after the TUI tears down) and quit the app. +function failAndQuit(ctx: FlowContext, message: string) { + ctx.epilogue(`CoDev Code setup failed: ${message}\nRun \`codev configure\` to try again.`) + ctx.exit() +} + +async function runSsoFlow(ctx: FlowContext) { + const [status, setStatus] = createSignal("Starting SSO login...") + const [authUrl, setAuthUrl] = createSignal<string>() + const [pasteError, setPasteError] = createSignal<string>() + // Locks the paste field once a code is accepted (or the browser callback + // lands): the token exchange and every gateway call after it are async, so + // without this the input stays live and it looks like Enter did nothing. + const [busy, setBusy] = createSignal(false) + let submitManualCode: ((pasted: string) => string | null) | undefined + + ctx.dialog.replace(() => ( + <SsoView + status={status} + authUrl={authUrl} + pasteError={pasteError} + busy={busy} + onPaste={(value) => { + if (busy() || !submitManualCode) return + const error = submitManualCode(value) + setPasteError(error ?? undefined) + // A null result means the code was accepted and login() is exchanging + // it now — lock the field so Enter visibly took effect. + if (!error) setBusy(true) + }} + /> + )) + // replace() resets the store's persistent flag; keep the login step locked. + ctx.dialog.setPersistent(true) + + try { + const session = + (await silentSso()) ?? + (await login({ + onLog: setStatus, + onReady(openBrowserFn, url, submit) { + submitManualCode = submit + setAuthUrl(url) + openBrowserFn() + }, + openUrl: open, + })) + + // Authenticated: everything below is non-interactive network work. Lock the + // view (covers the browser-callback path, which never touched the field). + setBusy(true) + setStatus("Fetching gateway configuration...") + const config = await withRetries("Fetching gateway configuration", setStatus, () => + fetchCodevConfig(session.access_token), + ) + saveCodevConfig(config) + + setStatus("Fetching gateway API key...") + const apiKey = await withRetries("Fetching gateway API key", setStatus, () => fetchApiKey(session.access_token)) + // The backend returns an empty key for accounts without a provisioned + // gateway key — same as the hub, route those to manual entry. + if (!apiKey) { + ctx.toast.show({ + variant: "info", + message: "Your account has no gateway API key. Enter it manually.", + }) + const pasted = await DialogPrompt.show(ctx.dialog, "API Key", { placeholder: "sk-...", persistent: true }) + if (!pasted?.trim()) return + await runModelFlow(ctx, { + apiKey: pasted.trim(), + gatewayBaseUrl: config.gatewayUrl, + authFileBaseUrl: config.gatewayUrl, + }) + return + } + + await runModelFlow(ctx, { apiKey, gatewayBaseUrl: config.gatewayUrl }) + } catch (err) { + // login() is single-attempt (its auth code is single-use, so a silent + // retry can't reuse it); the retryable backend steps have already spent + // their budget by the time they throw here. Either way, quit rather than + // fall through to chat. + failAndQuit(ctx, err instanceof Error ? err.message : String(err)) + } +} + +// Pure view over the SSO controller's signals; safe to remount at any time. +function SsoView(props: { + status: Accessor<string> + authUrl: Accessor<string | undefined> + pasteError: Accessor<string | undefined> + busy: Accessor<boolean> + onPaste: (value: string) => void +}) { + const { theme } = useTheme() + const clipboard = useClipboard() + const [copied, setCopied] = createSignal(false) + let textarea: TextareaRenderable | undefined + + // Lone "c" is the copy shortcut for the sign-in URL (parity with the codev + // hub's Login): a pasted callback URL always arrives as one long string, so a + // field that settles on a bare "c" can only be the shortcut. Copy the URL and + // clear the field so the user can then paste the callback back. + const onInput = (value: string) => { + const url = props.authUrl() + if (value !== "c" || !url || !clipboard.write) return + void clipboard.write(url).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + textarea?.clear() + } + + return ( + <DialogPrompt + title="Login with SSO" + persistent + busy={props.busy()} + busyText="Signing in..." + placeholder="Paste the callback URL or code here if the browser can't reach this machine" + onConfirm={props.onPaste} + onInput={onInput} + ref={(t) => (textarea = t)} + description={() => ( + <box gap={1}> + <text fg={theme.textMuted}>{props.status()}</text> + <Show when={props.authUrl()}> + <text fg={theme.textMuted}>Complete the login in your browser, or open:</text> + <Link href={props.authUrl()!} fg={theme.primary} /> + <text fg={copied() ? theme.success : theme.textMuted}> + {copied() ? "Copied to clipboard" : "(press C to copy the link)"} + </text> + </Show> + <Show when={props.pasteError()}> + <text fg={theme.error}>{props.pasteError()}</text> + </Show> + </box> + )} + /> + ) +} + +interface GatewayCreds { + apiKey: string + // Where the provider block's baseURL points (normalized to /v1 at write). + gatewayBaseUrl: string + // Written to auth.json's base_url — set on the manual path only; SSO-minted + // keys store no base_url, matching the hub (the gateway URL lives in the + // config block of auth.json instead). + authFileBaseUrl?: string +} + +async function runModelFlow(ctx: FlowContext, creds: GatewayCreds) { + // Replaces the current step with a locked status view (replace() clears the + // persistent flag, so re-arm it). Used for every non-interactive step so the + // gate stays put and nothing looks clickable while work is in flight. + const setStatus = (text: string) => { + ctx.dialog.replace(() => <StatusBox text={text} />) + ctx.dialog.setPersistent(true) + } + + setStatus("Fetching models...") + let models: string[] + try { + // No fallback model: a config pointing at an unreachable gateway or a bad + // key would just fail on the first chat. Silently retry, then quit rather + // than drop into an unconfigured chat. + models = await withRetries("Fetching models", setStatus, () => fetchModels(creds.apiKey, creds.gatewayBaseUrl)) + } catch (err) { + failAndQuit(ctx, `couldn't fetch models after ${RETRIES} retries: ${err instanceof Error ? err.message : String(err)}`) + return + } + + const model = await showSelect( + ctx.dialog, + "Choose default model", + models.map((id) => ({ title: id, value: id })), + ) + if (model === null) return + + // Swap the still-interactive list for a status view: writing auth.json and + // PATCHing the global config is async, and a live list makes it look like the + // selection didn't register. + setStatus(`Configuring ${model}...`) + try { + saveApiKey({ apiKey: creds.apiKey, baseUrl: creds.authFileBaseUrl, model }) + await withRetries("Applying configuration", setStatus, async () => { + const result = await ctx.sdk.client.global.config.update({ + config: buildGatewayConfig({ + apiKey: creds.apiKey, + baseUrl: creds.gatewayBaseUrl, + model, + models, + }), + }) + if (result.error) throw new Error(JSON.stringify(result.error)) + }) + await ctx.sync.bootstrap() + ctx.toast.show({ variant: "info", message: `CoDev Code configured — using ${model}` }) + ctx.dialog.clear() + } catch (err) { + failAndQuit(ctx, err instanceof Error ? err.message : String(err)) + } +} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 115c10296e..b153cddd1f 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -209,6 +209,14 @@ export function Prompt(props: PromptProps) { const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID }) const [cursorVersion, setCursorVersion] = createSignal(0) const currentProviderLabel = createMemo(() => local.model.parsed().provider) + // Fork: hide the model/provider footer indicator for upstream's hosted Zen + // offerings (the `opencode`/`opencode-go` providers) — they're detours for + // gateway users, and the same ids are filtered from the provider/model + // pickers. A configured gateway/real provider still shows its model. + const isHostedZenModel = createMemo(() => { + const id = local.model.current()?.providerID + return id === "opencode" || id === "opencode-go" + }) const hasRightContent = createMemo(() => Boolean(props.right)) function promptModelWarning() { @@ -1447,7 +1455,7 @@ export function Prompt(props: PromptProps) { <Show when={store.mode === "normal" && local.permission.mode === "auto"}> <text fg={fadeColor(theme.textMuted, agentMetaAlpha())}>auto</text> </Show> - <Show when={store.mode === "normal"}> + <Show when={store.mode === "normal" && !isHostedZenModel()}> <box flexDirection="row" gap={1}> <text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text> <text diff --git a/packages/tui/src/feature-plugins/home/tips-view.tsx b/packages/tui/src/feature-plugins/home/tips-view.tsx index 32dd86f7a7..2276deece6 100644 --- a/packages/tui/src/feature-plugins/home/tips-view.tsx +++ b/packages/tui/src/feature-plugins/home/tips-view.tsx @@ -68,7 +68,9 @@ function parse(tip: string): TipPart[] { return parts } -const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding" +// Fork: point first-run users at the CoDev gateway configure flow; /connect +// (upstream's provider dialog) remains available for other providers. +const NO_MODELS_TIP = "Run {highlight}/configure{/highlight} to set up the CoDev gateway and start coding" const NO_MODELS_PARTS = parse(NO_MODELS_TIP) function shortcutText(value: string) { diff --git a/packages/tui/src/ui/dialog-prompt.tsx b/packages/tui/src/ui/dialog-prompt.tsx index f518fb2950..22341554cc 100644 --- a/packages/tui/src/ui/dialog-prompt.tsx +++ b/packages/tui/src/ui/dialog-prompt.tsx @@ -15,6 +15,13 @@ export type DialogPromptProps = { busyText?: string onConfirm?: (value: string) => void onCancel?: () => void + // Fork: hide the "esc" hint on non-dismissable prompts (the dialog store's + // persistent flag blocks the actual close). + persistent?: boolean + // Fork: fires on every content change; the configure flow watches it for the + // lone-"c" copy shortcut on the SSO paste-back link. + onInput?: (value: string) => void + ref?: (textarea: TextareaRenderable) => void } export function DialogPrompt(props: DialogPromptProps) { @@ -78,9 +85,11 @@ export function DialogPrompt(props: DialogPromptProps) { <text attributes={TextAttributes.BOLD} fg={theme.text}> {props.title} </text> - <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> - esc - </text> + <Show when={!props.persistent}> + <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> + esc + </text> + </Show> </box> <box gap={1}> {props.description?.()} @@ -89,7 +98,9 @@ export function DialogPrompt(props: DialogPromptProps) { ref={(val: TextareaRenderable) => { textarea = val setTextareaTarget(val) + props.ref?.(val) }} + onContentChange={() => props.onInput?.(textarea.plainText)} initialValue={props.value} placeholder={props.placeholder ?? "Enter text"} placeholderColor={theme.textMuted} @@ -122,5 +133,8 @@ DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<Dialog ), () => resolve(null), ) + // replace() resets the store's persistent flag; re-arm it for a + // non-dismissable prompt (the configure flow's forced steps). + if (options?.persistent) dialog.setPersistent(true) }) } diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index dab9103d24..e99706a4a2 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -34,6 +34,9 @@ export interface DialogSelectProps<T> { onSelect?: (option: DialogSelectOption<T>) => void skipFilter?: boolean renderFilter?: boolean + // Fork: hide the "esc" hint — set on non-dismissable dialogs (the dialog + // store's persistent flag blocks the actual escape/backdrop close). + persistent?: boolean locked?: boolean preserveSelection?: boolean actions?: { @@ -563,9 +566,11 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { {props.title} </text> )} - <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> - esc - </text> + <Show when={!props.persistent}> + <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> + esc + </text> + </Show> </box> <Show when={props.renderFilter !== false}> <box paddingTop={1}> diff --git a/packages/tui/src/ui/dialog.tsx b/packages/tui/src/ui/dialog.tsx index 50281630bc..6aa82f54f8 100644 --- a/packages/tui/src/ui/dialog.tsx +++ b/packages/tui/src/ui/dialog.tsx @@ -5,7 +5,7 @@ import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" import { useToast } from "./toast" import { Flag } from "@opencode-ai/core/flag/flag" -import { useBindings, useOpencodeModeStack } from "../keymap" +import { useBindings, useOpencodeModeStack, useOpencodeKeymap } from "../keymap" import { useClipboard } from "../context/clipboard" export function Dialog( @@ -73,10 +73,16 @@ function init() { onClose?: () => void }[], size: "medium" as "medium" | "large" | "xlarge", + // Fork: a persistent dialog can't be dismissed — escape, ctrl+c, and the + // click-outside backdrop all no-op. Reset to false on every replace/clear + // so dialogs are dismissable by default; a dialog opts in via + // setPersistent(true) (the CoDev gateway configure gate uses this). + persistent: false, }) const renderer = useRenderer() const modeStack = useOpencodeModeStack() + const keymap = useOpencodeKeymap() createEffect(() => { if (store.stack.length === 0) return @@ -110,6 +116,7 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { + if (store.persistent) return if (renderer.getSelection()) { renderer.clearSelection() } @@ -124,6 +131,14 @@ function init() { desc: "Close dialog", group: "Dialog", cmd: () => { + // A persistent dialog is a forced gate (the CoDev configure flow) — + // it can't be dismissed, so ctrl+c is the escape hatch and quits the + // app instead. The global app_exit binding is base-mode only and the + // open dialog is in modal mode, so dispatch app.exit explicitly. + if (store.persistent) { + keymap.dispatchCommand("app.exit") + return + } if (renderer.getSelection()) { renderer.clearSelection() } @@ -143,6 +158,7 @@ function init() { } batch(() => { setStore("size", "medium") + setStore("persistent", false) setStore("stack", []) }) refocus() @@ -156,6 +172,9 @@ function init() { if (item.onClose) item.onClose() } setStore("size", "medium") + // Fork: a new dialog is dismissable until it opts back into persistence; + // the configure gate re-arms it in DialogConfigure's onMount. + setStore("persistent", false) setStore("stack", [ { element: input, @@ -172,6 +191,12 @@ function init() { setSize(size: "medium" | "large" | "xlarge") { setStore("size", size) }, + get persistent() { + return store.persistent + }, + setPersistent(value: boolean) { + setStore("persistent", value) + }, } } @@ -213,7 +238,14 @@ export function DialogProvider(props: ParentProps) { onMouseUp={!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? copySelection : undefined} > <Show when={value.stack.length}> - <Dialog onClose={() => value.clear()} size={value.size}> + <Dialog + onClose={() => { + // Fork: click-outside can't dismiss a persistent dialog. + if (value.persistent) return + value.clear() + }} + size={value.size} + > {value.stack.at(-1)!.element} </Dialog> </Show>