Add OpenCode binary selection with version detection
- Add binary selector component with dropdown and validation - Support custom OpenCode binary paths and system PATH binary - Async version checking for all binaries when selector opens - Store recent binaries with timestamps and version info - Enhanced instance creation to use selected binary - Improved storage system for binary preferences - Fixed validation to handle system PATH binaries properly
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { ipcMain, BrowserWindow } from "electron"
|
||||
import { ipcMain, BrowserWindow, dialog } from "electron"
|
||||
import { processManager } from "./process-manager"
|
||||
import { randomBytes } from "crypto"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { execSync } from "child_process"
|
||||
import ignore from "ignore"
|
||||
|
||||
interface Instance {
|
||||
@@ -23,7 +24,20 @@ function generateId(): string {
|
||||
export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
processManager.setMainWindow(mainWindow)
|
||||
|
||||
ipcMain.handle("instance:create", async (event, id: string, folder: string) => {
|
||||
ipcMain.handle("dialog:selectFolder", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: "Select Project Folder",
|
||||
properties: ["openDirectory"],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle("instance:create", async (event, id: string, folder: string, binaryPath?: string) => {
|
||||
const instance: Instance = {
|
||||
id,
|
||||
folder,
|
||||
@@ -35,13 +49,13 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
instances.set(id, instance)
|
||||
|
||||
try {
|
||||
const { pid, port, binaryPath } = await processManager.spawn(folder, id)
|
||||
const { pid, port, binaryPath: actualBinaryPath } = await processManager.spawn(folder, id, binaryPath)
|
||||
|
||||
instance.port = port
|
||||
instance.pid = pid
|
||||
instance.status = "ready"
|
||||
|
||||
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath })
|
||||
mainWindow.webContents.send("instance:started", { id, port, pid, binaryPath: actualBinaryPath })
|
||||
|
||||
const meta = processManager.getAllProcesses().get(pid)
|
||||
if (meta) {
|
||||
@@ -51,7 +65,7 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
})
|
||||
}
|
||||
|
||||
return { id, port, pid, binaryPath }
|
||||
return { id, port, pid, binaryPath: actualBinaryPath }
|
||||
} catch (error) {
|
||||
instance.status = "error"
|
||||
instance.error = error instanceof Error ? error.message : String(error)
|
||||
@@ -128,4 +142,88 @@ export function setupInstanceIPC(mainWindow: BrowserWindow) {
|
||||
|
||||
return scanDir(workspaceFolder, workspaceFolder)
|
||||
})
|
||||
|
||||
// OpenCode binary operations
|
||||
ipcMain.handle("dialog:selectOpenCodeBinary", async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow!, {
|
||||
title: "Select OpenCode Binary",
|
||||
filters: [
|
||||
{ name: "Executable Files", extensions: ["exe", "cmd", "bat", "sh", "command", "app", ""] },
|
||||
{ name: "All Files", extensions: ["*"] },
|
||||
],
|
||||
properties: ["openFile"],
|
||||
})
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle("opencode:validateBinary", async (event, binaryPath: string) => {
|
||||
try {
|
||||
// Special handling for system PATH binary
|
||||
const isSystemPath = binaryPath === "opencode"
|
||||
|
||||
if (!isSystemPath) {
|
||||
// Check if file exists and is executable for custom paths
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return { valid: false, error: "File does not exist" }
|
||||
}
|
||||
|
||||
const stats = fs.statSync(binaryPath)
|
||||
if (!stats.isFile()) {
|
||||
return { valid: false, error: "Path is not a file" }
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get version
|
||||
let version: string | undefined
|
||||
try {
|
||||
// Try -v flag first (opencode uses this)
|
||||
let versionOutput = execSync(`${binaryPath} -v`, {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
version = versionOutput.trim()
|
||||
} catch (error) {
|
||||
// Version check failed, but binary might still be valid
|
||||
|
||||
try {
|
||||
let versionOutput = execSync(`${binaryPath} --version`, {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
version = versionOutput.trim()
|
||||
} catch (fallbackError) {}
|
||||
}
|
||||
|
||||
// Try to run help command to verify it's actually opencode
|
||||
try {
|
||||
const helpOutput = execSync(`${binaryPath} --help`, {
|
||||
stdio: "pipe",
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
if (!helpOutput.toLowerCase().includes("opencode")) {
|
||||
return { valid: false, error: "Not an OpenCode binary" }
|
||||
}
|
||||
} catch (error) {
|
||||
return { valid: false, error: "Binary failed to execute" }
|
||||
}
|
||||
|
||||
return { valid: true, version }
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { createApplicationMenu } from "./menu"
|
||||
import { setupInstanceIPC } from "./ipc"
|
||||
import { setupStorageIPC } from "./storage"
|
||||
|
||||
// Setup IPC handlers before creating windows
|
||||
setupStorageIPC()
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
function createWindow() {
|
||||
@@ -28,33 +31,13 @@ function createWindow() {
|
||||
|
||||
createApplicationMenu(mainWindow)
|
||||
setupInstanceIPC(mainWindow)
|
||||
setupStorageIPC()
|
||||
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
function setupIPC() {
|
||||
ipcMain.handle("dialog:selectFolder", async () => {
|
||||
if (!mainWindow) return null
|
||||
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: "Select Project Folder",
|
||||
buttonLabel: "Select",
|
||||
properties: ["openDirectory"],
|
||||
})
|
||||
|
||||
if (result.canceled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.filePaths[0] || null
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
setupIPC()
|
||||
createWindow()
|
||||
|
||||
app.on("activate", () => {
|
||||
|
||||
@@ -50,14 +50,14 @@ class ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
async spawn(folder: string, instanceId: string): Promise<ProcessInfo> {
|
||||
async spawn(folder: string, instanceId: string, binaryPath?: string): Promise<ProcessInfo> {
|
||||
this.validateFolder(folder)
|
||||
const binaryPath = this.validateOpenCodeBinary()
|
||||
const actualBinaryPath = binaryPath ? this.validateCustomBinary(binaryPath) : this.validateOpenCodeBinary()
|
||||
|
||||
this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder}...`)
|
||||
this.sendLog(instanceId, "info", `Starting OpenCode server for ${folder} using ${actualBinaryPath}...`)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("opencode", ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
|
||||
const child = spawn(actualBinaryPath, ["serve", "--port", "0", "--print-logs", "--log-level", "DEBUG"], {
|
||||
cwd: folder,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: process.env,
|
||||
@@ -103,7 +103,7 @@ class ProcessManager {
|
||||
}
|
||||
|
||||
this.processes.set(child.pid!, meta)
|
||||
resolve({ pid: child.pid!, port, binaryPath })
|
||||
resolve({ pid: child.pid!, port, binaryPath: actualBinaryPath })
|
||||
}
|
||||
|
||||
const meta = this.processes.get(child.pid!)
|
||||
@@ -221,6 +221,28 @@ class ProcessManager {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private validateCustomBinary(binaryPath: string): string {
|
||||
if (!existsSync(binaryPath)) {
|
||||
throw new Error(`OpenCode binary not found: ${binaryPath}`)
|
||||
}
|
||||
|
||||
const stats = statSync(binaryPath)
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${binaryPath}`)
|
||||
}
|
||||
|
||||
// Check if executable (on Unix systems)
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
execSync(`test -x "${binaryPath}"`, { stdio: "pipe" })
|
||||
} catch {
|
||||
throw new Error(`Binary is not executable: ${binaryPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
return binaryPath
|
||||
}
|
||||
}
|
||||
|
||||
export const processManager = new ProcessManager()
|
||||
|
||||
@@ -51,8 +51,8 @@ function invalidateConfigCache() {
|
||||
export function setupStorageIPC() {
|
||||
ensureDirectories()
|
||||
|
||||
ipcMain.handle("storage:getConfigPath", () => CONFIG_FILE)
|
||||
ipcMain.handle("storage:getInstancesDir", () => INSTANCES_DIR)
|
||||
ipcMain.handle("storage:getConfigPath", async () => CONFIG_FILE)
|
||||
ipcMain.handle("storage:getInstancesDir", async () => INSTANCES_DIR)
|
||||
|
||||
ipcMain.handle("storage:readConfigFile", async () => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user