Преглед изворни кода

fix(core): plugins are always reinstalled (#9675)

Filip пре 2 месеци
родитељ
комит
d116c227e0

+ 12 - 1
packages/opencode/src/bun/index.ts

@@ -6,6 +6,7 @@ import { Filesystem } from "../util/filesystem"
 import { NamedError } from "@opencode-ai/util/error"
 import { readableStreamToText } from "bun"
 import { Lock } from "../util/lock"
+import { PackageRegistry } from "./registry"
 
 export namespace BunProc {
   const log = Log.create({ service: "bun" })
@@ -73,7 +74,17 @@ export namespace BunProc {
     const dependencies = parsed.dependencies ?? {}
     if (!parsed.dependencies) parsed.dependencies = dependencies
     const modExists = await Filesystem.exists(mod)
-    if (dependencies[pkg] === version && modExists) return mod
+    const cachedVersion = dependencies[pkg]
+
+    if (!modExists || !cachedVersion) {
+      // continue to install
+    } else if (version !== "latest" && cachedVersion === version) {
+      return mod
+    } else if (version === "latest") {
+      const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
+      if (!isOutdated) return mod
+      log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
+    }
 
     const proxied = !!(
       process.env.HTTP_PROXY ||

+ 48 - 0
packages/opencode/src/bun/registry.ts

@@ -0,0 +1,48 @@
+import { readableStreamToText, semver } from "bun"
+import { Log } from "../util/log"
+
+export namespace PackageRegistry {
+  const log = Log.create({ service: "bun" })
+
+  function which() {
+    return process.execPath
+  }
+
+  export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
+    const result = Bun.spawn([which(), "info", pkg, field], {
+      cwd,
+      stdout: "pipe",
+      stderr: "pipe",
+      env: {
+        ...process.env,
+        BUN_BE_BUN: "1",
+      },
+    })
+
+    const code = await result.exited
+    const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
+    const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
+
+    if (code !== 0) {
+      log.warn("bun info failed", { pkg, field, code, stderr })
+      return null
+    }
+
+    const value = stdout.trim()
+    if (!value) return null
+    return value
+  }
+
+  export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
+    const latestVersion = await info(pkg, "version", cwd)
+    if (!latestVersion) {
+      log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
+      return false
+    }
+
+    const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
+    if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
+
+    return semver.order(cachedVersion, latestVersion) === -1
+  }
+}

+ 37 - 9
packages/opencode/src/config/config.ts

@@ -28,6 +28,7 @@ import { existsSync } from "fs"
 import { Bus } from "@/bus"
 import { GlobalBus } from "@/bus/global"
 import { Event } from "../server/event"
+import { PackageRegistry } from "@/bun/registry"
 
 export namespace Config {
   const log = Log.create({ service: "config" })
@@ -154,9 +155,10 @@ export namespace Config {
         }
       }
 
-      const exists = existsSync(path.join(dir, "node_modules"))
-      const installing = installDependencies(dir)
-      if (!exists) await installing
+      const shouldInstall = await needsInstall(dir)
+      if (shouldInstall) {
+        await installDependencies(dir)
+      }
 
       result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
       result.agent = mergeDeep(result.agent, await loadAgent(dir))
@@ -235,6 +237,7 @@ export namespace Config {
 
   export async function installDependencies(dir: string) {
     const pkg = path.join(dir, "package.json")
+    const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
 
     if (!(await Bun.file(pkg).exists())) {
       await Bun.write(pkg, "{}")
@@ -244,18 +247,43 @@ export namespace Config {
     const hasGitIgnore = await Bun.file(gitignore).exists()
     if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
 
-    await BunProc.run(
-      ["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"],
-      {
-        cwd: dir,
-      },
-    ).catch(() => {})
+    await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
+      cwd: dir,
+    }).catch(() => {})
 
     // Install any additional dependencies defined in the package.json
     // This allows local plugins and custom tools to use external packages
     await BunProc.run(["install"], { cwd: dir }).catch(() => {})
   }
 
+  async function needsInstall(dir: string) {
+    const nodeModules = path.join(dir, "node_modules")
+    if (!existsSync(nodeModules)) return true
+
+    const pkg = path.join(dir, "package.json")
+    const pkgFile = Bun.file(pkg)
+    const pkgExists = await pkgFile.exists()
+    if (!pkgExists) return true
+
+    const parsed = await pkgFile.json().catch(() => null)
+    const dependencies = parsed?.dependencies ?? {}
+    const depVersion = dependencies["@opencode-ai/plugin"]
+    if (!depVersion) return true
+
+    const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
+    if (targetVersion === "latest") {
+      const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
+      if (!isOutdated) return false
+      log.info("Cached version is outdated, proceeding with install", {
+        pkg: "@opencode-ai/plugin",
+        cachedVersion: depVersion,
+      })
+      return true
+    }
+    if (depVersion === targetVersion) return false
+    return true
+  }
+
   function rel(item: string, patterns: string[]) {
     for (const pattern of patterns) {
       const index = item.indexOf(pattern)

+ 15 - 27
packages/opencode/test/mcp/oauth-browser.test.ts

@@ -8,6 +8,7 @@ let openCalledWith: string | undefined
 mock.module("open", () => ({
   default: async (url: string) => {
     openCalledWith = url
+
     // Return a mock subprocess that emits an error if openShouldFail is true
     const subprocess = new EventEmitter()
     if (openShouldFail) {
@@ -133,20 +134,17 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
       })
 
       // Run authenticate with a timeout to avoid waiting forever for the callback
-      const authPromise = MCP.authenticate("test-oauth-server")
+      // Attach a handler immediately so callback shutdown rejections
+      // don't show up as unhandled between tests.
+      const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
 
-      // Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published)
-      await new Promise((resolve) => setTimeout(resolve, 200))
+      // Config.get() can be slow in tests, so give it plenty of time.
+      await new Promise((resolve) => setTimeout(resolve, 2_000))
 
       // Stop the callback server and cancel any pending auth
       await McpOAuthCallback.stop()
 
-      // Wait for authenticate to reject (due to server stopping)
-      try {
-        await authPromise
-      } catch {
-        // Expected to fail
-      }
+      await authPromise
 
       unsubscribe()
 
@@ -187,20 +185,15 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
       })
 
       // Run authenticate with a timeout to avoid waiting forever for the callback
-      const authPromise = MCP.authenticate("test-oauth-server-2")
+      const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
 
-      // Wait for the browser open attempt and the 500ms error detection timeout
-      await new Promise((resolve) => setTimeout(resolve, 700))
+      // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
+      await new Promise((resolve) => setTimeout(resolve, 2_000))
 
       // Stop the callback server and cancel any pending auth
       await McpOAuthCallback.stop()
 
-      // Wait for authenticate to reject (due to server stopping)
-      try {
-        await authPromise
-      } catch {
-        // Expected to fail
-      }
+      await authPromise
 
       unsubscribe()
 
@@ -237,20 +230,15 @@ test("open() is called with the authorization URL", async () => {
       openCalledWith = undefined
 
       // Run authenticate with a timeout to avoid waiting forever for the callback
-      const authPromise = MCP.authenticate("test-oauth-server-3")
+      const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
 
-      // Wait for the browser open attempt and the 500ms error detection timeout
-      await new Promise((resolve) => setTimeout(resolve, 700))
+      // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
+      await new Promise((resolve) => setTimeout(resolve, 2_000))
 
       // Stop the callback server and cancel any pending auth
       await McpOAuthCallback.stop()
 
-      // Wait for authenticate to reject (due to server stopping)
-      try {
-        await authPromise
-      } catch {
-        // Expected to fail
-      }
+      await authPromise
 
       // Verify open was called with a URL
       expect(openCalledWith).toBeDefined()