adamelmore 3 недель назад
Родитель
Сommit
65e1186efe

+ 22 - 4
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")}

+ 2 - 5
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 })
   }
 }

+ 27 - 16
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() {

+ 2 - 5
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> {

+ 14 - 6
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()
   },

+ 15 - 6
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)
     }

+ 2 - 2
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(

+ 62 - 62
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({