diff options
| author | adamelmore <[email protected]> | 2026-01-27 16:53:35 -0600 |
|---|---|---|
| committer | adamelmore <[email protected]> | 2026-01-28 07:28:03 -0600 |
| commit | 65e1186efed178ccc5da0858077e7e7b2d48c89b (patch) | |
| tree | 5675a50ab018055dc617ebc2077e4ed49775b4b9 | |
| parent | 8faa2ffcf8d65a5372baf5a877a02a6cff2223ec (diff) | |
| download | opencode-65e1186efed178ccc5da0858077e7e7b2d48c89b.tar.gz opencode-65e1186efed178ccc5da0858077e7e7b2d48c89b.zip | |
wip(app): global config
| -rw-r--r-- | packages/app/src/components/dialog-custom-provider.tsx | 26 | ||||
| -rw-r--r-- | packages/opencode/src/auth/index.ts | 7 | ||||
| -rw-r--r-- | packages/opencode/src/config/config.ts | 43 | ||||
| -rw-r--r-- | packages/opencode/src/mcp/auth.ts | 7 | ||||
| -rw-r--r-- | packages/opencode/src/project/instance.ts | 20 | ||||
| -rw-r--r-- | packages/opencode/src/project/state.ts | 21 | ||||
| -rw-r--r-- | packages/opencode/src/server/routes/global.ts | 4 | ||||
| -rw-r--r-- | packages/opencode/src/server/server.ts | 124 |
8 files changed, 146 insertions, 106 deletions
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index d5ffb68a8..28a947f3b 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -8,6 +8,7 @@ import { showToast } from "@opencode-ai/ui/toast" import { For } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" +import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { DialogSelectProvider } from "./dialog-select-provider" @@ -22,6 +23,7 @@ type Props = { export function DialogCustomProvider(props: Props) { const dialog = useDialog() const globalSync = useGlobalSync() + const globalSDK = useGlobalSDK() const language = useLanguage() const [form, setForm] = createStore({ @@ -118,6 +120,9 @@ export function DialogCustomProvider(props: Props) { const baseURL = form.baseURL.trim() const apiKey = form.apiKey.trim() + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + const idError = !providerID ? "Provider ID is required" : !PROVIDER_ID.test(providerID) @@ -196,16 +201,17 @@ export function DialogCustomProvider(props: Props) { const options = { baseURL, - ...(apiKey ? { apiKey } : {}), ...(Object.keys(headers).length ? { headers } : {}), } return { providerID, name, + key, config: { npm: OPENAI_COMPATIBLE, name, + ...(env ? { env: [env] } : {}), options, models, }, @@ -224,8 +230,20 @@ export function DialogCustomProvider(props: Props) { const disabledProviders = globalSync.data.config.disabled_providers ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) - globalSync - .updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }) + const auth = result.key + ? globalSDK.client.auth.set({ + providerID: result.providerID, + auth: { + type: "api", + key: result.key, + }, + }) + : Promise.resolve() + + auth + .then(() => + globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }), + ) .then(() => { dialog.close() showToast({ @@ -301,7 +319,7 @@ export function DialogCustomProvider(props: Props) { /> <TextField label="API key" - placeholder="{env:MYPROVIDER_API_KEY}" + placeholder="API key" description="Optional. Leave empty if you manage auth via headers." value={form.apiKey} onChange={setForm.bind(null, "apiKey")} diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 3fd283053..ce948b92a 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,6 +1,5 @@ import path from "path" import { Global } from "../global" -import fs from "fs/promises" import z from "zod" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" @@ -59,15 +58,13 @@ export namespace Auth { export async function set(key: string, info: Info) { const file = Bun.file(filepath) const data = await all() - await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2)) - await fs.chmod(file.name!, 0o600) + await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2), { mode: 0o600 }) } export async function remove(key: string) { const file = Bun.file(filepath) const data = await all() delete data[key] - await Bun.write(file, JSON.stringify(data, null, 2)) - await fs.chmod(file.name!, 0o600) + await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 }) } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cb..f7b1a4c1e 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1341,24 +1341,35 @@ export namespace Config { throw new JsonError({ path: filepath }, { cause: err }) }) - if (!filepath.endsWith(".jsonc")) { - const existing = parseConfig(before, filepath) - await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2)) - } else { - const next = patchJsonc(before, config) - parseConfig(next, filepath) - await Bun.write(filepath, next) - } + const next = await (async () => { + if (!filepath.endsWith(".jsonc")) { + const existing = parseConfig(before, filepath) + const merged = mergeDeep(existing, config) + await Bun.write(filepath, JSON.stringify(merged, null, 2)) + return merged + } + + const updated = patchJsonc(before, config) + const merged = parseConfig(updated, filepath) + await Bun.write(filepath, updated) + return merged + })() global.reset() - await Instance.disposeAll() - GlobalBus.emit("event", { - directory: "global", - payload: { - type: Event.Disposed.type, - properties: {}, - }, - }) + + void Instance.disposeAll() + .catch(() => undefined) + .finally(() => { + GlobalBus.emit("event", { + directory: "global", + payload: { + type: Event.Disposed.type, + properties: {}, + }, + }) + }) + + return next } export async function directories() { diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 7f7dbd156..0f91a35b8 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,5 +1,4 @@ import path from "path" -import fs from "fs/promises" import z from "zod" import { Global } from "../global" @@ -65,16 +64,14 @@ export namespace McpAuth { if (serverUrl) { entry.serverUrl = serverUrl } - await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2)) - await fs.chmod(file.name!, 0o600) + await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2), { mode: 0o600 }) } export async function remove(mcpName: string): Promise<void> { const file = Bun.file(filepath) const data = await all() delete data[mcpName] - await Bun.write(file, JSON.stringify(data, null, 2)) - await fs.chmod(file.name!, 0o600) + await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 }) } export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> { diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index ddaa90f1e..e5a88101c 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -5,6 +5,7 @@ import { State } from "./state" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { Filesystem } from "@/util/filesystem" +import { withTimeout } from "@/util/timeout" interface Context { directory: string @@ -14,6 +15,8 @@ interface Context { const context = Context.create<Context>("instance") const cache = new Map<string, Promise<Context>>() +const DISPOSE_TIMEOUT_MS = 10_000 + export const Instance = { async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> { let existing = cache.get(input.directory) @@ -78,13 +81,18 @@ export const Instance = { }, async disposeAll() { Log.Default.info("disposing all instances") - for (const [_key, value] of cache) { - const awaited = await value.catch(() => {}) - if (awaited) { - await context.provide(await value, async () => { - await Instance.dispose() - }) + for (const [key, value] of cache) { + const ctx = await withTimeout(value, DISPOSE_TIMEOUT_MS).catch((error) => { + Log.Default.warn("instance dispose timed out", { key, error }) + return undefined + }) + if (!ctx) { + cache.delete(key) + continue } + await context.provide(ctx, async () => { + await Instance.dispose() + }) } cache.clear() }, diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index 34a5dbb3e..f2cf59946 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -1,4 +1,5 @@ import { Log } from "@/util/log" +import { withTimeout } from "@/util/timeout" export namespace State { interface Entry { @@ -7,6 +8,7 @@ export namespace State { } const log = Log.create({ service: "state" }) + const DISPOSE_TIMEOUT_MS = 10_000 const recordsByKey = new Map<string, Map<any, Entry>>() export function create<S>(root: () => string, init: () => S, dispose?: (state: Awaited<S>) => Promise<void>) { @@ -46,14 +48,21 @@ export namespace State { }, 10000).unref() const tasks: Promise<void>[] = [] - for (const entry of entries.values()) { + for (const [init, entry] of entries) { if (!entry.dispose) continue - const task = Promise.resolve(entry.state) - .then((state) => entry.dispose!(state)) - .catch((error) => { - log.error("Error while disposing state:", { error, key }) - }) + const label = typeof init === "function" ? init.name : String(init) + + const task = withTimeout( + Promise.resolve(entry.state).then((state) => entry.dispose!(state)), + DISPOSE_TIMEOUT_MS, + ).catch((error) => { + if (error instanceof Error && error.message.includes("Operation timed out")) { + log.warn("state disposal timed out", { key, init: label }) + return + } + log.error("Error while disposing state:", { error, key, init: label }) + }) tasks.push(task) } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index e5450e947..5e2df052e 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -147,8 +147,8 @@ export const GlobalRoutes = lazy(() => validator("json", Config.Info), async (c) => { const config = c.req.valid("json") - await Config.updateGlobal(config) - return c.json(await Config.getGlobal()) + const next = await Config.updateGlobal(config) + return c.json(next) }, ) .post( diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 302c5376d..e6afc563b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -122,6 +122,68 @@ export namespace Server { }), ) .route("/global", GlobalRoutes()) + .put( + "/auth/:providerID", + describeRoute({ + summary: "Set auth credentials", + description: "Set authentication credentials", + operationId: "auth.set", + responses: { + 200: { + description: "Successfully set authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + validator("json", Auth.Info), + async (c) => { + const providerID = c.req.valid("param").providerID + const info = c.req.valid("json") + await Auth.set(providerID, info) + return c.json(true) + }, + ) + .delete( + "/auth/:providerID", + describeRoute({ + summary: "Remove auth credentials", + description: "Remove authentication credentials", + operationId: "auth.remove", + responses: { + 200: { + description: "Successfully removed authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await Auth.remove(providerID) + return c.json(true) + }, + ) .use(async (c, next) => { let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() try { @@ -409,68 +471,6 @@ export namespace Server { return c.json(await Format.status()) }, ) - .put( - "/auth/:providerID", - describeRoute({ - summary: "Set auth credentials", - description: "Set authentication credentials", - operationId: "auth.set", - responses: { - 200: { - description: "Successfully set authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: z.string(), - }), - ), - validator("json", Auth.Info), - async (c) => { - const providerID = c.req.valid("param").providerID - const info = c.req.valid("json") - await Auth.set(providerID, info) - return c.json(true) - }, - ) - .delete( - "/auth/:providerID", - describeRoute({ - summary: "Remove auth credentials", - description: "Remove authentication credentials", - operationId: "auth.remove", - responses: { - 200: { - description: "Successfully removed authentication credentials", - content: { - "application/json": { - schema: resolver(z.boolean()), - }, - }, - }, - ...errors(400), - }, - }), - validator( - "param", - z.object({ - providerID: z.string(), - }), - ), - async (c) => { - const providerID = c.req.valid("param").providerID - await Auth.remove(providerID) - return c.json(true) - }, - ) .get( "/event", describeRoute({ |
