Browse Source

#5 Add version gate to ensure compatibility with OpenCode server version

paviko 2 months ago
parent
commit
563009b9a8

+ 5 - 0
.github/workflows/release.yml

@@ -62,9 +62,14 @@ jobs:
               echo "prerelease=false" >> $GITHUB_OUTPUT
             fi
           fi
+          # version without leading 'v' for use as OPENCODE_VERSION
+          VER="$(grep -oP '"version":\s*"\K[^"]+' packages/opencode/package.json)"
+          echo "version_number=$VER" >> $GITHUB_OUTPUT
 
       - name: Build backend binaries
         run: ./hosts/scripts/build_opencode.sh
+        env:
+          OPENCODE_VERSION: ${{ steps.version.outputs.version_number }}
 
       - name: Install VSCode plugin dependencies (all)
         working-directory: hosts/vscode-plugin

+ 8 - 0
hosts/jetbrains-plugin/build.gradle.kts

@@ -105,6 +105,14 @@ intellijPlatform {
 }
 
 tasks {
+    processResources {
+        val minVersion = project.findProperty("opencode.min.version")?.toString() ?: "1.1.1"
+        inputs.property("opencodeMinVersion", minVersion)
+        filesMatching("opencode-build.properties") {
+            expand("opencodeMinVersion" to minVersion)
+        }
+    }
+
     // Ensure no upper build bound is set in plugin.xml so the plugin stays compatible with newer IDEs
     patchPluginXml {
         // keep sinceBuild from pluginConfiguration, but expand upper bound to newer IDE builds

+ 1 - 0
hosts/jetbrains-plugin/gradle.properties

@@ -1 +1,2 @@
 kotlin.stdlib.default.dependency=false
+opencode.min.version=1.1.1

+ 12 - 1
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/IdeBridge.kt

@@ -42,6 +42,14 @@ object IdeBridge {
     @Volatile private var executor = Executors.newCachedThreadPool()
     private var keepaliveTimer: java.util.Timer? = null
 
+    private val minVersion: String by lazy {
+        try {
+            val props = java.util.Properties()
+            IdeBridge::class.java.getResourceAsStream("/opencode-build.properties")?.use { props.load(it) }
+            props.getProperty("opencode.min.version", "1.1.1")
+        } catch (_: Throwable) { "1.1.1" }
+    }
+
     @Synchronized
     fun start() {
         if (server != null) return
@@ -222,8 +230,11 @@ object IdeBridge {
         
         // Send initial connection event
         try {
+            val data = JsonObject().apply {
+                addProperty("minVersion", minVersion)
+            }
             val writer = OutputStreamWriter(exchange.responseBody)
-            writer.write("event: connected\ndata: {}\n\n")
+            writer.write("event: connected\ndata: ${gson.toJson(data)}\n\n")
             writer.flush()
         } catch (e: Exception) {
             synchronized(session.sseClients) {

+ 47 - 18
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/util/ResourceExtractor.kt

@@ -9,39 +9,68 @@ object ResourceExtractor {
     private const val STABLE_DIR = "opencode-bin"
     private const val STALE_PREFIX = "opencode-"
     private val logger = Logger.getInstance(ResourceExtractor::class.java)
+    private val lock = Any()
+    @Volatile
+    private var cached: String? = null
 
     /**
      * Extracts a resource to a deterministic temporary location.
-     * Reuses the existing binary when the file size matches, and only
-     * re-copies after an extension update changes the bundled binary.
+     * On the first call per IDE session the entire stable directory is
+     * deleted so the new bundled binary always replaces the old one.
+     * Subsequent calls (e.g. from other project windows) return the
+     * cached path without re-extracting.
      * IMPORTANT: This method performs heavy I/O (file copy) and must NOT be called from EDT.
      */
     fun extractToTemp(resourcePath: String, targetName: String): String? {
         require(!ApplicationManager.getApplication().isDispatchThread) {
             "extractToTemp must not be called from EDT - it performs heavy file I/O operations"
         }
-        val stream: InputStream = javaClass.classLoader.getResourceAsStream(resourcePath) ?: return null
 
-        val stableDir = File(System.getProperty("java.io.tmpdir"), STABLE_DIR)
-        stableDir.mkdirs()
-        val dest = File(stableDir, targetName)
+        cached?.let {
+            logger.info("ResourceExtractor: skipping extraction, using cached binary at $it")
+            return it
+        }
 
-        // Read resource into memory so we can check size before writing
-        val bytes = stream.use { it.readBytes() }
+        synchronized(lock) {
+            cached?.let {
+                logger.info("ResourceExtractor: skipping extraction inside lock, using cached binary at $it")
+                return it
+            }
 
-        try {
-            dest.writeBytes(bytes)
-        } catch (e: Exception) {
-            // Binary may be in use – continue with existing copy
-            logger.info("Could not overwrite binary (may be in use): ${e.message}")
-        }
+            val stream: InputStream = javaClass.classLoader.getResourceAsStream(resourcePath) ?: return null
+            val bytes = stream.use { it.readBytes() }
 
-        dest.setExecutable(true)
+            val stableDir = File(System.getProperty("java.io.tmpdir"), STABLE_DIR)
 
-        // Best-effort cleanup of stale random temp dirs from previous versions
-        cleanupStaleTempDirs()
+            // Wipe the previous directory so a stale binary is never reused
+            logger.info("ResourceExtractor: deleting stable directory $stableDir")
+            val deleted = if (stableDir.exists()) {
+                stableDir.deleteRecursively()
+            } else {
+                true
+            }
+            logger.info("ResourceExtractor: delete result for $stableDir = $deleted")
+            stableDir.mkdirs()
+
+            val dest = File(stableDir, targetName)
+            logger.info("ResourceExtractor: writing bundled binary to ${dest.absolutePath}")
+            runCatching {
+                dest.writeBytes(bytes)
+            }.onSuccess {
+                logger.info("ResourceExtractor: successfully wrote binary to ${dest.absolutePath}")
+            }.onFailure {
+                logger.warn("ResourceExtractor: failed writing binary to ${dest.absolutePath}", it)
+                throw it
+            }
+            dest.setExecutable(true)
 
-        return dest.absolutePath
+            // Best-effort cleanup of stale random temp dirs from previous versions
+            cleanupStaleTempDirs()
+
+            cached = dest.absolutePath
+            logger.info("ResourceExtractor: extraction complete, cached path ${cached}")
+            return cached
+        }
     }
 
     /**

+ 1 - 0
hosts/jetbrains-plugin/src/main/resources/opencode-build.properties

@@ -0,0 +1 @@
+opencode.min.version=${opencodeMinVersion}

+ 5 - 1
hosts/scripts/build_opencode.bat

@@ -26,7 +26,11 @@ if errorlevel 1 (
 call :prepare_output_dir "%JETBRAINS_BIN_DIR%"
 call :prepare_output_dir "%VSCODE_BIN_DIR%"
 
-echo => Building opencode distribution
+if not defined OPENCODE_VERSION (
+  for /f "delims=" %%V in ('node -p "require('%OPENCODE_DIR:\=/%/package.json').version"') do set "OPENCODE_VERSION=%%V"
+)
+
+echo => Building opencode distribution (version %OPENCODE_VERSION%)
 pushd "%OPENCODE_DIR%"
 bun script/build.ts
 if errorlevel 1 (

+ 4 - 1
hosts/scripts/build_opencode.sh

@@ -33,7 +33,10 @@ prepare_output_dir() {
   mkdir -p "$dir"
 }
 
-echo "=> Building opencode distribution"
+OPENCODE_VERSION="${OPENCODE_VERSION:-$(node -p "require('$OPENCODE_DIR/package.json').version")}"
+export OPENCODE_VERSION
+
+echo "=> Building opencode distribution (version $OPENCODE_VERSION)"
 (
   cd "$OPENCODE_DIR"
   bun script/build.ts

+ 5 - 0
hosts/vscode-plugin/package.json

@@ -172,6 +172,11 @@
           "type": "string",
           "default": "",
           "description": "Custom command to run in the terminal (optional)"
+        },
+        "opencode.minVersion": {
+          "type": "string",
+          "default": "1.1.1",
+          "description": "Minimum required OpenCode server version"
         }
       }
     }

+ 26 - 10
hosts/vscode-plugin/src/backend/ResourceExtractor.ts

@@ -9,15 +9,28 @@ import * as os from "os"
 export class ResourceExtractor {
   private static readonly STABLE_DIR = "opencode-bin"
   private static readonly STALE_PREFIX = "opencode-"
+  private static pending: Promise<string> | null = null
 
   /**
    * Extract the appropriate opencode binary for the current platform.
-   * Uses a deterministic path so the binary is reused across launches and
-   * only re-copied when the source file size changes (e.g. extension update).
+   * On the first call per extension host process the entire stable
+   * directory is deleted so the new bundled binary always replaces
+   * the old one.  Subsequent calls return the cached result.
    * @param extensionPath Path to the extension directory
    * @returns Promise resolving to the path of the extracted binary
    */
-  static async extractBinary(extensionPath: string): Promise<string> {
+  static extractBinary(extensionPath: string): Promise<string> {
+    if (this.pending) {
+      console.log("[ResourceExtractor] Skipping extraction, using cached binary promise")
+      return this.pending
+    }
+
+    console.log("[ResourceExtractor] Starting extraction")
+    this.pending = this.doExtract(extensionPath)
+    return this.pending
+  }
+
+  private static async doExtract(extensionPath: string): Promise<string> {
     const osType = this.detectOS()
     const arch = this.detectArchitecture()
 
@@ -30,15 +43,17 @@ export class ResourceExtractor {
     }
 
     const stableDir = path.join(os.tmpdir(), this.STABLE_DIR)
+
+    // Wipe the previous directory so a stale binary is never reused
+    console.log(`[ResourceExtractor] Deleting stable directory ${stableDir}`)
+    await fs.promises.rm(stableDir, { recursive: true, force: true })
+    console.log(`[ResourceExtractor] Deleted stable directory ${stableDir}`)
     await fs.promises.mkdir(stableDir, { recursive: true })
-    const destPath = path.join(stableDir, binaryName)
 
-    try {
-      await fs.promises.copyFile(binaryPath, destPath)
-    } catch (e: any) {
-      // Binary may be in use – continue with existing copy
-      console.log(`[ResourceExtractor] Could not overwrite binary (may be in use): ${e?.code || e}`)
-    }
+    const destPath = path.join(stableDir, binaryName)
+    console.log(`[ResourceExtractor] Writing binary to ${destPath}`)
+    await fs.promises.copyFile(binaryPath, destPath)
+    console.log(`[ResourceExtractor] Finished writing binary to ${destPath}`)
 
     if (osType !== "windows") {
       await this.makeExecutable(destPath)
@@ -47,6 +62,7 @@ export class ResourceExtractor {
     // Best-effort cleanup of stale random temp files from previous versions
     this.cleanupStaleTempFiles().catch(() => {})
 
+    console.log(`[ResourceExtractor] Extraction complete, binary at ${destPath}`)
     return destPath
   }
 

+ 5 - 1
hosts/vscode-plugin/src/ui/IdeBridgeServer.ts

@@ -16,6 +16,7 @@ export interface SessionHandlers {
 
 interface SessionMetadata {
   guiOnly?: boolean
+  minVersion?: string
 }
 
 interface Session {
@@ -177,7 +178,10 @@ class IdeBridgeServer {
 
     // Send initial connected event with optional metadata
     try {
-      const connected = session.metadata.guiOnly ? JSON.stringify({ customApi: false }) : "{}"
+      const data: Record<string, any> = {}
+      if (session.metadata.guiOnly) data.customApi = false
+      if (session.metadata.minVersion) data.minVersion = session.metadata.minVersion
+      const connected = JSON.stringify(data)
       res.write(`event: connected\ndata: ${connected}\n\n`)
     } catch (e) {
       logger.appendLine(`IdeBridgeServer failed to init SSE: ${e}`)

+ 1 - 1
hosts/vscode-plugin/src/ui/WebviewController.ts

@@ -83,7 +83,7 @@ export class WebviewController {
           uiGetState: this.uiGetState,
           uiSetState: this.uiSetState,
         },
-        { guiOnly: this.isGuiOnly },
+        { guiOnly: this.isGuiOnly, minVersion: vscode.workspace.getConfiguration("opencode").get<string>("minVersion", "1.1.1") },
       )
       this.bridgeSessionId = session.sessionId
 

+ 111 - 0
packages/opencode/webgui/src/components/VersionGate.tsx

@@ -0,0 +1,111 @@
+import { useEffect, useState, type ReactNode } from "react"
+import { ideBridge } from "../lib/ideBridge"
+import { serverBase } from "../lib/api/sdkClient"
+
+function compareVersions(a: string, b: string): number {
+  const pa = a.split(".").map(Number)
+  const pb = b.split(".").map(Number)
+  const len = Math.max(pa.length, pb.length)
+  for (let i = 0; i < len; i++) {
+    const na = pa[i] ?? 0
+    const nb = pb[i] ?? 0
+    if (na !== nb) return na - nb
+  }
+  return 0
+}
+
+type State =
+  | { status: "loading" }
+  | { status: "ok" }
+  | { status: "outdated"; installed: string; required: string }
+
+export function VersionGate({ children }: { children: ReactNode }) {
+  const [state, setState] = useState<State>({ status: "loading" })
+
+  useEffect(() => {
+    // If no ideBridge installed, skip the gate entirely
+    if (!ideBridge.isInstalled()) {
+      setState({ status: "ok" })
+      return
+    }
+
+    let cancelled = false
+
+    const check = async () => {
+      const min = ideBridge.minVersion
+      if (!min) {
+        if (!cancelled) setState({ status: "ok" })
+        return
+      }
+
+      try {
+        const res = await fetch(`${serverBase}/global/health`)
+        if (!res.ok) {
+          if (!cancelled) setState({ status: "ok" })
+          return
+        }
+        const json = await res.json() as { healthy: boolean; version: string }
+        if (!json.version) {
+          if (!cancelled) setState({ status: "ok" })
+          return
+        }
+        if (compareVersions(json.version, min) < 0) {
+          if (!cancelled) setState({ status: "outdated", installed: json.version, required: min })
+        } else {
+          if (!cancelled) setState({ status: "ok" })
+        }
+      } catch {
+        if (!cancelled) setState({ status: "ok" })
+      }
+    }
+
+    // If minVersion is already available (reconnect scenario), check immediately
+    if (ideBridge.minVersion) {
+      check()
+      return () => { cancelled = true }
+    }
+
+    // Wait for the SSE "connected" event which populates minVersion
+    const listener = () => { check() }
+    window.addEventListener("opencode:idebridge-connected", listener, { once: true })
+
+    return () => {
+      cancelled = true
+      window.removeEventListener("opencode:idebridge-connected", listener)
+    }
+  }, [])
+
+  if (state.status === "loading") return null
+
+  if (state.status === "outdated") {
+    return (
+      <div className="flex items-center justify-center h-screen bg-white dark:bg-gray-950 p-8">
+        <div className="max-w-md w-full text-center space-y-6">
+          <div className="text-5xl">⚠️</div>
+          <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
+            Incorrect OpenCode Version
+          </h1>
+          <p className="text-gray-600 dark:text-gray-400 leading-relaxed">
+            The installed OpenCode server version is incompatible with this plugin.
+          </p>
+          <div className="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 space-y-2 text-sm">
+            <div className="flex justify-between">
+              <span className="text-gray-500 dark:text-gray-400">Minimum required</span>
+              <span className="font-mono font-semibold text-gray-900 dark:text-gray-100">{state.required}</span>
+            </div>
+            <div className="flex justify-between">
+              <span className="text-gray-500 dark:text-gray-400">Currently installed</span>
+              <span className="font-mono font-semibold text-red-600 dark:text-red-400">{state.installed}</span>
+            </div>
+          </div>
+          <p className="text-sm text-gray-500 dark:text-gray-400">
+            Please update OpenCode to version <span className="font-mono font-semibold">{state.required}</span> or
+            later to continue.
+          </p>
+        </div>
+      </div>
+    )
+  }
+
+  return <>{children}</>
+}

+ 7 - 0
packages/opencode/webgui/src/lib/ideBridge.ts

@@ -18,6 +18,7 @@ const token = params.get("ideBridgeToken")
 class IdeBridge {
   ready = false
   customApi = true
+  minVersion: string | null = null
   private queue: Message[] = []
   private handlers: Set<Handler> = new Set()
   private pending = new Map<string, { resolve: (m: Message) => void; reject: (e: any) => void }>()
@@ -54,8 +55,14 @@ class IdeBridge {
         if (typeof data.customApi === "boolean") {
           this.customApi = data.customApi
         }
+        if (typeof data.minVersion === "string") {
+          this.minVersion = data.minVersion
+        }
       } catch {
       }
+      try {
+        window.dispatchEvent(new Event("opencode:idebridge-connected"))
+      } catch {}
     })
 
     this.eventSource.onopen = () => {

+ 14 - 11
packages/opencode/webgui/src/main.tsx

@@ -12,6 +12,7 @@ import { ProjectProvider } from "./state/ProjectContext.tsx"
 import { IdeBridgeProvider } from "./state/IdeBridgeContext"
 import { ProvidersProvider } from "./state/ProvidersContext"
 import { initGlobalDnD } from "./lib/dnd"
+import { VersionGate } from "./components/VersionGate"
 
 window.addEventListener(
   "opencode:ui-bridge-state",
@@ -30,17 +31,19 @@ initGlobalDnD()
 createRoot(document.getElementById("root")!).render(
   <StrictMode>
     <ErrorBoundary>
-      <ProjectProvider>
-        <SessionProvider>
-          <ToastProvider>
-            <IdeBridgeProvider>
-              <ProvidersProvider>
-                <App />
-              </ProvidersProvider>
-            </IdeBridgeProvider>
-          </ToastProvider>
-        </SessionProvider>
-      </ProjectProvider>
+      <VersionGate>
+        <ProjectProvider>
+          <SessionProvider>
+            <ToastProvider>
+              <IdeBridgeProvider>
+                <ProvidersProvider>
+                  <App />
+                </ProvidersProvider>
+              </IdeBridgeProvider>
+            </ToastProvider>
+          </SessionProvider>
+        </ProjectProvider>
+      </VersionGate>
     </ErrorBoundary>
   </StrictMode>,
 )