From 65e1186efed178ccc5da0858077e7e7b2d48c89b Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Tue, 27 Jan 2026 16:53:35 -0600 Subject: [PATCH] wip(app): global config --- .../src/components/dialog-custom-provider.tsx | 26 +++- packages/opencode/src/auth/index.ts | 7 +- packages/opencode/src/config/config.ts | 43 +++--- packages/opencode/src/mcp/auth.ts | 7 +- packages/opencode/src/project/instance.ts | 20 ++- packages/opencode/src/project/state.ts | 21 ++- packages/opencode/src/server/routes/global.ts | 4 +- 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 d5ffb68a84..28a947f3b3 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) { /> { + 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 7f7dbd156c..0f91a35b87 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 { 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 { diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index ddaa90f1e2..e5a88101c0 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("instance") const cache = new Map>() +const DISPOSE_TIMEOUT_MS = 10_000 + export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { 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 34a5dbb3e7..f2cf599461 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>() export function create(root: () => string, init: () => S, dispose?: (state: Awaited) => Promise) { @@ -46,14 +48,21 @@ export namespace State { }, 10000).unref() const tasks: Promise[] = [] - 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 e5450e9474..5e2df052ec 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 302c5376d2..e6afc563be 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({