Browse Source

#5 gui-only fixes for windows

paviko 1 day ago
parent
commit
608ed659be

+ 13 - 41
.github/workflows/release.yml

@@ -191,25 +191,13 @@ jobs:
           # Clean up staging
           rm -rf "$STAGE_DIR"
 
-      - name: Build JetBrains plugin distribution (standard)
+      - name: Build JetBrains plugin distribution
         uses: gradle/gradle-build-action@v2
         with:
           gradle-version: 8.7
           arguments: buildPlugin
           build-root-directory: hosts/jetbrains-plugin
 
-      - name: Stash JetBrains standard artifact
-        run: |
-          mkdir -p /tmp/jetbrains-standard
-          find hosts/jetbrains-plugin/build/distributions -name "*.zip" -type f -exec cp {} /tmp/jetbrains-standard/ \;
-
-      - name: Build JetBrains plugin distribution (gui-only)
-        uses: gradle/gradle-build-action@v2
-        with:
-          gradle-version: 8.7
-          arguments: clean buildPlugin -PguiOnly=true -PwebguiDist=../../packages/opencode/webgui-dist
-          build-root-directory: hosts/jetbrains-plugin
-
       - name: Prepare release artifacts
         run: |
           mkdir -p release-artifacts
@@ -226,18 +214,16 @@ jobs:
               "release-artifacts/opencode-vscode-gui-only-${{ steps.version.outputs.version }}.vsix"
           fi
 
-          # Copy JetBrains standard plugin (stashed before gui-only build)
-          for file in /tmp/jetbrains-standard/*.zip; do
+          # Find and copy JetBrains plugin
+          find hosts/jetbrains-plugin/build/distributions -name "*.zip" -type f -exec cp {} release-artifacts/ \;
+          cd release-artifacts
+          for file in *.zip; do
             if [ -f "$file" ]; then
-              cp "$file" "release-artifacts/opencode-jetbrains-${{ steps.version.outputs.version }}.zip"
+              mv "$file" "opencode-jetbrains-${{ steps.version.outputs.version }}.zip"
             fi
           done
 
-          # Copy JetBrains gui-only plugin
-          find hosts/jetbrains-plugin/build/distributions -name "*gui-only*" -type f -exec cp {} \
-            "release-artifacts/opencode-jetbrains-gui-only-${{ steps.version.outputs.version }}.zip" \;
-
-          ls -la release-artifacts/
+          ls -la
 
       - name: Create Release
         uses: softprops/action-gh-release@v1
@@ -254,8 +240,7 @@ jobs:
             This release includes:
             - **VSCode Extension (standard)**: `opencode-vscode-${{ steps.version.outputs.version }}.vsix` — bundles opencode binaries
             - **VSCode Extension (gui-only)**: `opencode-vscode-gui-only-${{ steps.version.outputs.version }}.vsix` — lightweight, uses system `opencode` binary
-            - **JetBrains Plugin (standard)**: `opencode-jetbrains-${{ steps.version.outputs.version }}.zip` — bundles opencode binaries
-            - **JetBrains Plugin (gui-only)**: `opencode-jetbrains-gui-only-${{ steps.version.outputs.version }}.zip` — lightweight, uses system `opencode` binary
+            - **JetBrains Plugin**: `opencode-jetbrains-${{ steps.version.outputs.version }}.zip`
 
             ### Installation Instructions
 
@@ -268,17 +253,11 @@ jobs:
             2. Download `opencode-vscode-gui-only-${{ steps.version.outputs.version }}.vsix`
             3. Install using: `code --install-extension opencode-vscode-gui-only-${{ steps.version.outputs.version }}.vsix`
 
-            #### JetBrains Plugin (standard — includes binaries)
+            #### JetBrains Plugin
             1. Download `opencode-jetbrains-${{ steps.version.outputs.version }}.zip`
             2. In your JetBrains IDE, go to Settings → Plugins → Install Plugin from Disk
             3. Select the downloaded zip file
 
-            #### JetBrains Plugin (gui-only — requires system opencode)
-            1. Ensure `opencode` is installed and available in your PATH
-            2. Download `opencode-jetbrains-gui-only-${{ steps.version.outputs.version }}.zip`
-            3. In your JetBrains IDE, go to Settings → Plugins → Install Plugin from Disk
-            4. Select the downloaded zip file
-
             ### What's Changed
             See the auto-generated release notes below for detailed changes.
 
@@ -314,16 +293,9 @@ jobs:
             echo "✗ VSCode extension (gui-only) missing"
           fi
 
-          # Check if JetBrains standard plugin exists
-          if ls ./test-artifacts/opencode-jetbrains-v*.zip 1> /dev/null 2>&1; then
-            echo "✓ JetBrains plugin (standard) found"
-          else
-            echo "✗ JetBrains plugin (standard) missing"
-          fi
-
-          # Check if JetBrains gui-only plugin exists
-          if ls ./test-artifacts/opencode-jetbrains-gui-only-*.zip 1> /dev/null 2>&1; then
-            echo "✓ JetBrains plugin (gui-only) found"
+          # Check if JetBrains plugin exists
+          if ls ./test-artifacts/*.zip 1> /dev/null 2>&1; then
+            echo "✓ JetBrains plugin found"
           else
-            echo "✗ JetBrains plugin (gui-only) missing"
+            echo "✗ JetBrains plugin missing"
           fi

+ 1 - 1
hosts/jetbrains-plugin/build.gradle.kts

@@ -135,7 +135,7 @@ tasks {
                 val webguiDir = layout.buildDirectory.dir("resources/main/webgui-app").get().asFile
                 val files = webguiDir.walkTopDown()
                     .filter { it.isFile && it.name != "file-list.txt" }
-                    .map { it.relativeTo(webguiDir).path }
+                    .map { it.relativeTo(webguiDir).invariantSeparatorsPath }
                     .sorted()
                     .toList()
                 File(webguiDir, "file-list.txt").writeText(files.joinToString("\n") + "\n")

+ 1 - 2
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/backendprocess/BackendLauncher.kt

@@ -38,8 +38,7 @@ object BackendLauncher {
             "launchBackend must not be called from EDT - it performs heavy I/O operations"
         }
         val isWin = System.getProperty("os.name").lowercase().contains("win")
-        val binName = if (isWin) "opencode.exe" else "opencode"
-        val bin = findBundledBinary(binName) ?: binName // fallback to PATH
+        val bin = findBundledBinary(if (isWin) "opencode.exe" else "opencode") ?: "opencode" // fallback to PATH
 
         val settings = OpenCodeSettings.getInstance()
 

+ 43 - 7
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/ChatToolWindowFactory.kt

@@ -274,21 +274,57 @@ class ChatToolWindowFactory : ToolWindowFactory, DumbAware {
      */
     private fun extractWebguiResources(): String {
         val dest = File(System.getProperty("java.io.tmpdir"), "opencode-webgui")
-        dest.deleteRecursively()
-        dest.mkdirs()
+        runCatching {
+            val deleted = dest.deleteRecursively()
+            if (!deleted && dest.exists()) {
+                logger.warn("Could not fully delete webgui temp directory ${dest.absolutePath}, continuing")
+            }
+        }.onFailure {
+            logger.warn("Failed to delete webgui temp directory ${dest.absolutePath}, continuing", it)
+        }
+
+        runCatching {
+            val created = dest.mkdirs()
+            if (!created && !dest.exists()) {
+                logger.warn("Could not create webgui temp directory ${dest.absolutePath}, continuing")
+            }
+        }.onFailure {
+            logger.warn("Failed to create webgui temp directory ${dest.absolutePath}, continuing", it)
+        }
 
         val listing = javaClass.classLoader.getResourceAsStream("webgui-app/file-list.txt")
-            ?.bufferedReader()?.readLines()?.filter { it.isNotBlank() }
+            ?.bufferedReader()
+            ?.useLines { lines ->
+                lines
+                    .map { it.trim() }
+                    .filter { it.isNotBlank() }
+                    .map { it.removePrefix("./").trimStart('/').replace('\\', '/') }
+                    .toList()
+            }
             ?: throw RuntimeException("webgui-app/file-list.txt not found in classpath")
 
+        var copied = 0
         for (rel in listing) {
-            val input = javaClass.classLoader.getResourceAsStream("webgui-app/$rel") ?: continue
+            val input = javaClass.classLoader.getResourceAsStream("webgui-app/$rel")
+            if (input == null) {
+                logger.warn("Missing bundled webgui resource webgui-app/$rel, skipping")
+                continue
+            }
             val target = File(dest, rel)
-            target.parentFile.mkdirs()
-            input.use { src -> target.outputStream().use { out -> src.copyTo(out) } }
+            runCatching {
+                val parent = target.parentFile
+                val parentCreated = parent.mkdirs()
+                if (!parentCreated && !parent.exists()) {
+                    logger.warn("Could not create parent directory ${parent.absolutePath} for $rel, continuing")
+                }
+                input.use { src -> target.outputStream().use { out -> src.copyTo(out) } }
+                copied++
+            }.onFailure {
+                logger.warn("Failed to extract webgui resource $rel to ${target.absolutePath}, continuing", it)
+            }
         }
 
-        logger.info("Extracted ${listing.size} webgui files to ${dest.absolutePath}")
+        logger.info("Extracted $copied/${listing.size} webgui files to ${dest.absolutePath}")
         return dest.absolutePath
     }
 }

+ 8 - 5
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/ui/WebguiStaticServer.kt

@@ -114,11 +114,14 @@ object WebguiStaticServer {
 
         // Check if file exists
         if (!file.exists() || file.isDirectory) {
-            // SPA fallback: serve index.html for non-asset paths
-            val index = File(rootDir, "index.html")
-            if (index.exists()) {
-                serveFile(exchange, index, true)
-                return
+            val leaf = relative.substringAfterLast('/')
+            val isSpaRoute = !leaf.contains('.')
+            if (isSpaRoute) {
+                val index = File(rootDir, "index.html")
+                if (index.exists()) {
+                    serveFile(exchange, index, true)
+                    return
+                }
             }
             exchange.sendResponseHeaders(404, -1)
             exchange.close()

+ 58 - 10
hosts/jetbrains-plugin/src/main/kotlin/paviko/opencode/util/ResourceExtractor.kt

@@ -4,6 +4,8 @@ import com.intellij.openapi.application.ApplicationManager
 import com.intellij.openapi.diagnostic.Logger
 import java.io.File
 import java.io.InputStream
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
 
 object ResourceExtractor {
     private const val STABLE_DIR = "opencode-bin"
@@ -44,25 +46,71 @@ object ResourceExtractor {
 
             // 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
+            runCatching {
+                val deleted = if (stableDir.exists()) {
+                    stableDir.deleteRecursively()
+                } else {
+                    true
+                }
+                logger.info("ResourceExtractor: delete result for $stableDir = $deleted")
+                if (!deleted) logger.warn("ResourceExtractor: could not fully delete $stableDir, continuing")
+            }.onFailure {
+                logger.warn("ResourceExtractor: failed deleting stable directory $stableDir, continuing", it)
+            }
+
+            runCatching {
+                val created = stableDir.mkdirs()
+                if (!created && !stableDir.exists()) {
+                    logger.warn("ResourceExtractor: could not create stable directory $stableDir, continuing")
+                }
+            }.onFailure {
+                logger.warn("ResourceExtractor: failed creating stable directory $stableDir, continuing", it)
             }
-            logger.info("ResourceExtractor: delete result for $stableDir = $deleted")
-            stableDir.mkdirs()
 
             val dest = File(stableDir, targetName)
+            val temp = File(stableDir, "$targetName.new")
             logger.info("ResourceExtractor: writing bundled binary to ${dest.absolutePath}")
-            runCatching {
-                dest.writeBytes(bytes)
+            val writeOk = runCatching {
+                temp.writeBytes(bytes)
+                runCatching {
+                    Files.move(
+                        temp.toPath(),
+                        dest.toPath(),
+                        StandardCopyOption.REPLACE_EXISTING,
+                        StandardCopyOption.ATOMIC_MOVE,
+                    )
+                }.recoverCatching {
+                    Files.move(
+                        temp.toPath(),
+                        dest.toPath(),
+                        StandardCopyOption.REPLACE_EXISTING,
+                    )
+                }.getOrThrow()
             }.onSuccess {
                 logger.info("ResourceExtractor: successfully wrote binary to ${dest.absolutePath}")
             }.onFailure {
                 logger.warn("ResourceExtractor: failed writing binary to ${dest.absolutePath}", it)
-                throw it
+            }.isSuccess
+
+            if (!writeOk) {
+                runCatching {
+                    if (temp.exists()) temp.delete()
+                }
+
+                if (dest.exists() && dest.length() > 0L) {
+                    logger.warn("ResourceExtractor: continuing with existing binary at ${dest.absolutePath}")
+                } else {
+                    logger.warn("ResourceExtractor: no extracted binary available at ${dest.absolutePath}")
+                    return null
+                }
+            }
+
+            runCatching {
+                val executable = dest.setExecutable(true)
+                if (!executable) logger.warn("ResourceExtractor: could not mark ${dest.absolutePath} as executable, continuing")
+            }.onFailure {
+                logger.warn("ResourceExtractor: failed setting executable flag for ${dest.absolutePath}, continuing", it)
             }
-            dest.setExecutable(true)
 
             // Best-effort cleanup of stale random temp dirs from previous versions
             cleanupStaleTempDirs()

+ 190 - 32
hosts/scripts/build_vscode.bat

@@ -1,6 +1,12 @@
 @echo off
 REM Opencode VSCode Extension Build Script for Windows
-REM Handles the complete build process for the Opencode VSCode extension
+REM Handles the complete build process for the Opencode VSCode extension.
+REM Supports building two variants:
+REM   - Standard:  bundles opencode binaries (default)
+REM   - GUI-only:  no binaries, uses system opencode, embeds webgui-dist
+REM
+REM By default both variants are built. Use --gui-only or --standard-only to
+REM build a single variant.
 
 setlocal enabledelayedexpansion
 
@@ -9,6 +15,8 @@ set "SCRIPT_DIR=%~dp0"
 set "ROOT_DIR=%SCRIPT_DIR%..\.."
 for %%I in ("%ROOT_DIR%") do set "ROOT_DIR=%%~fI"
 set "PLUGIN_DIR=%ROOT_DIR%\hosts\vscode-plugin"
+set "WEBGUI_DIR=%ROOT_DIR%\packages\opencode\webgui"
+set "WEBGUI_DIST=%ROOT_DIR%\packages\opencode\webgui-dist"
 
 if not exist "%PLUGIN_DIR%\package.json" (
     echo [ERROR] package.json not found. Please run this script from the repository root.
@@ -23,6 +31,8 @@ set "BUILD_TYPE=development"
 set "SKIP_BINARIES=false"
 set "SKIP_TESTS=false"
 set "PACKAGE_ONLY=false"
+set "BUILD_STANDARD=true"
+set "BUILD_GUI_ONLY=true"
 
 :parse_args
 if "%~1"=="" goto args_done
@@ -46,12 +56,26 @@ if "%~1"=="--package-only" (
     shift
     goto parse_args
 )
+if "%~1"=="--gui-only" (
+    set "BUILD_STANDARD=false"
+    set "BUILD_GUI_ONLY=true"
+    shift
+    goto parse_args
+)
+if "%~1"=="--standard-only" (
+    set "BUILD_STANDARD=true"
+    set "BUILD_GUI_ONLY=false"
+    shift
+    goto parse_args
+)
 if "%~1"=="--help" (
     echo Usage: %0 [OPTIONS]
     echo   --production      Build for production (default: development)
     echo   --skip-binaries   Skip building backend binaries
     echo   --skip-tests      Skip running tests
     echo   --package-only    Only create the .vsix package (skip compilation)
+    echo   --gui-only        Build only the gui-only variant (no binaries)
+    echo   --standard-only   Build only the standard variant (with binaries)
     echo   --help            Show this help message
     exit /b 0
 )
@@ -61,13 +85,27 @@ exit /b 1
 :args_done
 
 echo [INFO] Building VSCode extension in %BUILD_TYPE% mode
+if "%BUILD_STANDARD%"=="true" echo [INFO]   Variant: standard (with binaries)
+if "%BUILD_GUI_ONLY%"=="true" echo [INFO]   Variant: gui-only (system opencode)
 
 cd /d "%PLUGIN_DIR%"
 
+REM --- Shared preparation (compile once, package per-variant) ---
+
 if "%PACKAGE_ONLY%"=="false" (
     echo [INFO] Cleaning previous build artifacts...
-    call pnpm run clean 2>nul
-    if errorlevel 1 echo [WARN] Clean command failed, continuing...
+    if not exist "node_modules" (
+        echo [WARN] Dependencies not installed; skipping script clean and removing artifacts manually.
+        if exist "out" rmdir /s /q "out"
+        del /f /q *.vsix 2>nul
+    ) else (
+        call pnpm run clean 2>nul
+        if errorlevel 1 (
+            echo [WARN] Clean command failed, applying fallback removal...
+            if exist "out" rmdir /s /q "out"
+            del /f /q *.vsix 2>nul
+        )
+    )
 )
 
 if "%PACKAGE_ONLY%"=="false" (
@@ -87,15 +125,17 @@ if "%PACKAGE_ONLY%"=="false" (
 
 if "%SKIP_BINARIES%"=="false" (
     if "%PACKAGE_ONLY%"=="false" (
-        echo [INFO] Building backend binaries...
-        cd /d "%ROOT_DIR%"
-        if exist "hosts\scripts\build_opencode.bat" (
-            call hosts\scripts\build_opencode.bat
-        ) else (
-            echo [ERROR] Backend build script not found at hosts\scripts\build_opencode.bat
-            exit /b 1
+        if "%BUILD_STANDARD%"=="true" (
+            echo [INFO] Building backend binaries...
+            cd /d "%ROOT_DIR%"
+            if exist "hosts\scripts\build_opencode.bat" (
+                call hosts\scripts\build_opencode.bat
+            ) else (
+                echo [ERROR] Backend build script not found at hosts\scripts\build_opencode.bat
+                exit /b 1
+            )
+            cd /d "%PLUGIN_DIR%"
         )
-        cd /d "%PLUGIN_DIR%"
     )
 )
 
@@ -122,6 +162,62 @@ if "%SKIP_TESTS%"=="false" (
     )
 )
 
+REM --- Resolve vsce command ---
+
+set "VSCE_CMD=vsce"
+where vsce >nul 2>&1
+if errorlevel 1 (
+    where npx >nul 2>&1
+    if not errorlevel 1 (
+        set "VSCE_CMD=npx -y @vscode/vsce"
+    ) else (
+        echo [WARN] vsce not found and npx unavailable; attempting global install via npm
+        call npm install -g @vscode/vsce
+    )
+)
+
+REM Generate timestamp
+for /f "tokens=2-4 delims=/ " %%a in ('date /t') do (
+    for /f "tokens=1-2 delims=/" %%c in ("%%a") do (
+        set "MONTH=%%c"
+        set "DAY=%%d"
+    )
+    set "YEAR=%%b"
+)
+for /f "tokens=1-2 delims=: " %%a in ('time /t') do (
+    set "HOUR=%%a"
+    set "MINUTE=%%b"
+)
+set "TIMESTAMP=%YEAR%%MONTH%%DAY%-%HOUR%%MINUTE%"
+
+REM --- Build helper: package a single variant ---
+
+if "%BUILD_STANDARD%"=="true" (
+    call :build_variant_standard
+    if errorlevel 1 exit /b 1
+)
+
+if "%BUILD_GUI_ONLY%"=="true" (
+    call :build_variant_gui_only
+    if errorlevel 1 exit /b 1
+)
+
+echo [INFO] Build completed successfully!
+echo [INFO] Extension packages created in: %PLUGIN_DIR%
+
+echo Packages created:
+for %%F in ("%PLUGIN_DIR%\*.vsix") do echo   %%~nxF
+
+endlocal
+exit /b 0
+
+REM --- Build variant: standard ---
+:build_variant_standard
+echo [INFO] === Packaging STANDARD variant ===
+
+cd /d "%PLUGIN_DIR%"
+
+REM Check for required binaries
 echo [INFO] Checking for required binaries...
 set "MISSING_BINARIES=false"
 if not exist "resources\bin\windows\amd64\opencode.exe" (
@@ -149,34 +245,96 @@ if "%MISSING_BINARIES%"=="true" (
     echo [WARN] Run 'hosts\scripts\build_opencode.bat' from the repository root to build all binaries.
 )
 
-echo [INFO] Creating VSCode extension package
-REM Prefer local vsce (node_modules/.bin) to avoid global installs.
-where pnpm >nul 2>&1
+REM Package with original package.json and .vscodeignore
+if "%BUILD_TYPE%"=="production" (
+    call %VSCE_CMD% package --no-dependencies --out "opencode-vscode-%TIMESTAMP%.vsix"
+) else (
+    call %VSCE_CMD% package --pre-release --no-dependencies --out "opencode-vscode-dev-%TIMESTAMP%.vsix"
+)
+if errorlevel 1 exit /b 1
+
+echo [INFO] Standard variant packaged successfully
+exit /b 0
+
+REM --- Build variant: gui-only ---
+:build_variant_gui_only
+echo [INFO] === Packaging GUI-ONLY variant ===
+
+cd /d "%PLUGIN_DIR%"
+
+REM Always rebuild webgui to pick up source changes
+REM The monorepo uses bun workspaces a deps are already installed at root level
+echo [INFO] Building webgui...
+cd /d "%WEBGUI_DIR%"
+where bun >nul 2>&1
 if not errorlevel 1 (
-    set "VSCE_CMD=pnpm exec vsce"
+    call bun run build
 ) else (
-    set "VSCE_CMD=npx --yes vsce"
+    call npm run build
 )
+cd /d "%PLUGIN_DIR%"
 
-REM Use package.json version in the output .vsix name.
-set "PLUGIN_VERSION="
-for /f "usebackq delims=" %%V in (`node -p "require('./package.json').version" 2^>nul`) do set "PLUGIN_VERSION=%%V"
-if "%PLUGIN_VERSION%"=="" set "PLUGIN_VERSION=0.0.0"
+if not exist "%WEBGUI_DIST%" (
+    echo [ERROR] webgui-dist not found at %WEBGUI_DIST% after build
+    exit /b 1
+)
 
-set "VSIX_NAME=opencode-vscode-%PLUGIN_VERSION%-dev.vsix"
-if "%BUILD_TYPE%"=="production" set "VSIX_NAME=opencode-vscode-%PLUGIN_VERSION%.vsix"
+REM Copy webgui-dist into plugin resources for embedding
+echo [INFO] Embedding webgui-dist into plugin resources...
+if exist "resources\webgui-app" rmdir /s /q "resources\webgui-app"
+xcopy /s /e /i "%WEBGUI_DIST%" "resources\webgui-app\" >nul
 
-if "%BUILD_TYPE%"=="production" goto package_production
+REM Move binaries completely outside the plugin tree so vsce cannot bundle them
+set "BIN_STASH=%TEMP%\opencode_bin_stash_%RANDOM%"
+if exist "resources\bin" (
+    mkdir "%BIN_STASH%"
+    move "resources\bin" "%BIN_STASH%\bin" >nul
+)
 
-call %VSCE_CMD% package --pre-release --no-dependencies --out "%VSIX_NAME%"
-if errorlevel 1 exit /b 1
-goto package_complete
+REM Temporarily swap package.json with gui-only overrides and .vscodeignore
+copy "%PLUGIN_DIR%\package.json" "%PLUGIN_DIR%\package.json.bak" >nul
+copy "%PLUGIN_DIR%\.vscodeignore" "%PLUGIN_DIR%\.vscodeignore.bak" >nul
 
-:package_production
-call %VSCE_CMD% package --no-dependencies --out "%VSIX_NAME%"
-if errorlevel 1 exit /b 1
+REM Deep-merge gui-only overrides into package.json using PowerShell's built-in JSON support
+powershell -NoProfile -Command "$b=Get-Content 'package.json'|ConvertFrom-Json;$o=Get-Content 'package.gui-only.json'|ConvertFrom-Json;function m($t,$s){foreach($p in $s.PSObject.Properties){$v=$p.Value;if($v-is[array]-and$t.($p.Name)-is[array]){$a=$t.($p.Name);for($i=0;$i-lt$v.Count;$i++){if($i-lt$a.Count-and$v[$i]-is[pscustomobject]){m $a[$i] $v[$i]}else{$a[$i]=$v[$i]}}}elseif($v-is[pscustomobject]-and$t.($p.Name)-is[pscustomobject]){m $t.($p.Name) $v}else{$t|Add-Member -MemberType NoteProperty -Name $p.Name -Value $v -Force}}};m $b $o;$b|ConvertTo-Json -Depth 100|Set-Content 'package.json'"
+if errorlevel 1 (
+    echo [ERROR] Failed to merge gui-only package.json overrides
+    call :gui_only_cleanup
+    exit /b 1
+)
 
-:package_complete
+REM Swap .vscodeignore
+copy "%PLUGIN_DIR%\.vscodeignore.gui-only" "%PLUGIN_DIR%\.vscodeignore" >nul
 
-echo [INFO] Build completed successfully!
-endlocal
+REM Package gui-only variant
+if "%BUILD_TYPE%"=="production" (
+    call %VSCE_CMD% package --no-dependencies --out "opencode-vscode-gui-only-%TIMESTAMP%.vsix"
+) else (
+    call %VSCE_CMD% package --pre-release --no-dependencies --out "opencode-vscode-gui-only-dev-%TIMESTAMP%.vsix"
+)
+set "PACKAGE_RESULT=%ERRORLEVEL%"
+
+REM Restore originals
+call :gui_only_cleanup
+
+if "%PACKAGE_RESULT%"=="1" exit /b 1
+
+echo [INFO] GUI-only variant packaged successfully
+exit /b 0
+
+REM --- Cleanup helper for gui-only ---
+:gui_only_cleanup
+cd /d "%PLUGIN_DIR%"
+if exist "package.json.bak" (
+    move /y "package.json.bak" "package.json" >nul
+)
+if exist ".vscodeignore.bak" (
+    move /y ".vscodeignore.bak" ".vscodeignore" >nul
+)
+if exist "%BIN_STASH%\bin" (
+    if exist "resources\bin" rmdir /s /q "resources\bin"
+    move "%BIN_STASH%\bin" "resources\bin" >nul
+)
+if exist "%BIN_STASH%" rmdir /s /q "%BIN_STASH%"
+if exist "resources\webgui-app" rmdir /s /q "resources\webgui-app"
+exit /b 0

+ 1 - 1
hosts/vscode-plugin/src/backend/BackendLauncher.ts

@@ -208,7 +208,7 @@ export class BackendLauncher {
    * @returns The binary name to be resolved via PATH
    */
   private resolveSystemBinary(): string {
-    const name = process.platform === "win32" ? "opencode.exe" : "opencode"
+    const name = "opencode"
     logger.appendLine(`Using system binary: ${name}`)
     return name
   }

+ 65 - 15
hosts/vscode-plugin/src/backend/ResourceExtractor.ts

@@ -39,6 +39,7 @@ export class ResourceExtractor {
     const binaryPath = path.join(extensionPath, "resources", "bin", osType, arch, binaryName)
 
     if (!fs.existsSync(binaryPath)) {
+      console.log(`[ResourceExtractor] Binary not found for platform ${osType}/${arch} at ${binaryPath}`)
       throw new Error(`Binary not found for platform ${osType}/${arch} at ${binaryPath}`)
     }
 
@@ -46,24 +47,75 @@ export class ResourceExtractor {
 
     // 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 })
+    await this.runBestEffort(`delete stable directory ${stableDir}`, () =>
+      fs.promises.rm(stableDir, { recursive: true, force: true }),
+    )
+    await this.runBestEffort(`create stable directory ${stableDir}`, () =>
+      fs.promises.mkdir(stableDir, { recursive: true }),
+    )
 
     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}`)
+    const extractedPath = await this.copyWithFallback(binaryPath, destPath)
 
     if (osType !== "windows") {
-      await this.makeExecutable(destPath)
+      await this.makeExecutable(extractedPath)
     }
 
     // Best-effort cleanup of stale random temp files from previous versions
-    this.cleanupStaleTempFiles().catch(() => {})
+    this.cleanupStaleTempFiles().catch((error) => {
+      this.logFsError("cleanup stale temp files", error)
+    })
+
+    console.log(`[ResourceExtractor] Extraction complete, binary at ${extractedPath}`)
+    return extractedPath
+  }
+
+  private static async copyWithFallback(binaryPath: string, destPath: string): Promise<string> {
+    console.log(`[ResourceExtractor] Writing binary to ${destPath}`)
+    const copied = await fs.promises
+      .copyFile(binaryPath, destPath)
+      .then(() => true)
+      .catch((error) => {
+        this.logFsError(`copy binary to ${destPath}`, error)
+        return false
+      })
+
+    if (copied) {
+      console.log(`[ResourceExtractor] Finished writing binary to ${destPath}`)
+      return destPath
+    }
+
+    const hasDest = await fs.promises
+      .access(destPath, fs.constants.F_OK)
+      .then(() => true)
+      .catch(() => false)
+
+    if (hasDest) {
+      console.log(`[ResourceExtractor] Continuing with existing extracted binary at ${destPath}`)
+      return destPath
+    }
+
+    console.log(`[ResourceExtractor] Continuing with bundled binary at ${binaryPath}`)
+    return binaryPath
+  }
+
+  private static async runBestEffort(label: string, op: () => Promise<unknown>): Promise<void> {
+    await op()
+      .then(() => {
+        console.log(`[ResourceExtractor] Completed ${label}`)
+      })
+      .catch((error) => {
+        this.logFsError(label, error)
+      })
+  }
+
+  private static logFsError(label: string, error: unknown): void {
+    console.log(`[ResourceExtractor] Could not ${label}: ${this.errorMessage(error)}`)
+  }
 
-    console.log(`[ResourceExtractor] Extraction complete, binary at ${destPath}`)
-    return destPath
+  private static errorMessage(error: unknown): string {
+    if (error instanceof Error) return error.message
+    return String(error)
   }
 
   /**
@@ -131,10 +183,8 @@ export class ResourceExtractor {
    * @param filePath Path to the file to make executable
    */
   private static async makeExecutable(filePath: string): Promise<void> {
-    try {
-      await fs.promises.chmod(filePath, 0o755)
-    } catch (error) {
-      throw new Error(`Failed to make file executable: ${error}`)
-    }
+    await fs.promises.chmod(filePath, 0o755).catch((error) => {
+      this.logFsError(`make ${filePath} executable`, error)
+    })
   }
 }