summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamelmore <[email protected]>2026-01-27 16:53:35 -0600
committeradamelmore <[email protected]>2026-01-28 07:28:03 -0600
commit65e1186efed178ccc5da0858077e7e7b2d48c89b (patch)
tree5675a50ab018055dc617ebc2077e4ed49775b4b9
parent8faa2ffcf8d65a5372baf5a877a02a6cff2223ec (diff)
downloadopencode-65e1186efed178ccc5da0858077e7e7b2d48c89b.tar.gz
opencode-65e1186efed178ccc5da0858077e7e7b2d48c89b.zip
wip(app): global config
-rw-r--r--packages/app/src/components/dialog-custom-provider.tsx26
-rw-r--r--packages/opencode/src/auth/index.ts7
-rw-r--r--packages/opencode/src/config/config.ts43
-rw-r--r--packages/opencode/src/mcp/auth.ts7
-rw-r--r--packages/opencode/src/project/instance.ts20
-rw-r--r--packages/opencode/src/project/state.ts21
-rw-r--r--packages/opencode/src/server/routes/global.ts4
-rw-r--r--packages/opencode/src/server/server.ts124
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({