From 710469cf098ad8ffc44f8239447ca238b20774a9 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:45:35 +1000 Subject: [PATCH] feat: add local server opencode setup flow --- .../src/components/dialog-local-server.tsx | 50 ++++++ packages/app/src/context/platform.tsx | 9 ++ packages/app/src/index.ts | 1 + packages/desktop-electron/src/main/index.ts | 3 +- packages/desktop-electron/src/main/ipc.ts | 2 + .../desktop-electron/src/main/local-server.ts | 142 +++++++++++++++++- packages/desktop-electron/src/main/wsl.ts | 16 +- .../desktop-electron/src/preload/index.ts | 1 + .../desktop-electron/src/preload/types.ts | 9 ++ .../desktop-electron/src/renderer/index.tsx | 1 + 10 files changed, 227 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index a810415079..8f41c90c94 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -252,6 +252,56 @@ export function DialogLocalServer() { + + +
+
+
+
OpenCode
+
+ {current()?.checks.opencode?.error ?? current()?.checks.opencode?.resolvedPath ?? "Not checked yet"} +
+
+
+ + +
+
+ + + {(check) => ( +
+
Path: {check().resolvedPath ?? "not found"}
+
+ Version: {check().version ?? "unknown"} + + {(expected) => {` ยท desktop ${expected()}`}} + +
+ +
+ Installed version does not match the desktop app version. +
+
+
+ )} +
+
+
0}> diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 38c742301b..80b942dc84 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -46,6 +46,13 @@ export type LocalServerDistroCheck = { selected: LocalServerDistroProbe | null error: string | null } +export type LocalServerOpencodeCheck = { + resolvedPath: string | null + version: string | null + expectedVersion: string | null + matchesDesktop: boolean | null + error: string | null +} export type LocalServerTranscriptLine = { stream: "stdout" | "stderr" | "system" text: string @@ -81,6 +88,7 @@ export type LocalServerState = { checks: { wsl: LocalServerWslCheck | null distro: LocalServerDistroCheck | null + opencode: LocalServerOpencodeCheck | null } transcript: LocalServerTranscriptLine[] } @@ -95,6 +103,7 @@ export type LocalServerPlatform = { cancelJob(): Promise installWsl(): Promise installDistro(name: string): Promise + installOpencode(): Promise openTerminal(): Promise subscribe(cb: (event: LocalServerEvent) => void): () => void } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 4e01e764fe..9a11def8fd 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -7,6 +7,7 @@ export { type LocalServerConfig, type LocalServerEvent, type LocalServerMode, + type LocalServerOpencodeCheck, type LocalServerPlatform, type LocalServerState, type LocalServerStep, diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index edc6a08cde..d5d92ee07b 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -56,7 +56,7 @@ const loadingComplete = defer() const pendingDeepLinks: string[] = [] const serverReady = defer() -const localServer = createLocalServerController() +const localServer = createLocalServerController(app.getVersion()) const logger = initLogging() logger.log("app starting", { @@ -257,6 +257,7 @@ registerIpcHandlers({ cancelLocalServerJob: () => localServer.cancelJob(), installLocalServerWsl: () => localServer.installWsl(), installLocalServerDistro: (name) => localServer.installDistro(name), + installLocalServerOpencode: () => localServer.installOpencode(), openLocalServerTerminal: () => localServer.openTerminal(), onLocalServerEvent: (listener) => localServer.subscribe(listener), getDefaultServerUrl: () => getDefaultServerUrl(), diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 5c6a187e9f..8c325bb6d6 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -29,6 +29,7 @@ type Deps = { cancelLocalServerJob: () => Promise | void installLocalServerWsl: () => Promise | void installLocalServerDistro: (name: string) => Promise | void + installLocalServerOpencode: () => Promise | void openLocalServerTerminal: () => Promise | void onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void getDefaultServerUrl: () => Promise | string | null @@ -72,6 +73,7 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("local-server-install-distro", (_event: IpcMainInvokeEvent, name: string) => deps.installLocalServerDistro(name), ) + ipcMain.handle("local-server-install-opencode", () => deps.installLocalServerOpencode()) ipcMain.handle("local-server-open-terminal", () => deps.openLocalServerTerminal()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => diff --git a/packages/desktop-electron/src/main/local-server.ts b/packages/desktop-electron/src/main/local-server.ts index d15c83238d..3c4f5d111b 100644 --- a/packages/desktop-electron/src/main/local-server.ts +++ b/packages/desktop-electron/src/main/local-server.ts @@ -2,6 +2,7 @@ import type { LocalServerConfig, LocalServerDistroCheck, LocalServerEvent, + LocalServerOpencodeCheck, LocalServerState, LocalServerStep, LocalServerTranscriptLine, @@ -10,12 +11,16 @@ import { LOCAL_SERVER_KEY } from "./constants" import { store } from "./store" import { installWslDistro, + installWslOpencode, installWslRuntimeElevated, listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, probeWslDistro, probeWslRuntime, + readWslCommandVersion, + resolveWslCommand, + upgradeWslOpencode, wslNeedsRestart, } from "./wsl" @@ -35,7 +40,7 @@ export function defaultLocalServerConfig(): LocalServerConfig { } } -export function createLocalServerController() { +export function createLocalServerController(appVersion: string) { let state = toState(readLocalServerConfig()) const listeners = new Set<(event: LocalServerEvent) => void>() let jobAbort: AbortController | undefined @@ -160,6 +165,42 @@ export function createLocalServerController() { return } + if (step === "opencode") { + if (!state.config.distro) { + update({ + ...state, + job: null, + status: { kind: "failed", step, message: "No WSL distro selected" }, + }) + return + } + + const resolvedPath = await resolveWslCommand("opencode", state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + const version = resolvedPath + ? await readWslCommandVersion(resolvedPath, state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + : null + if (jobAbort !== abort) return + + const opencode = opencodeCheck(resolvedPath, version, appVersion) + update({ + ...state, + job: null, + status: opencode.error ? { kind: "failed", step, message: opencode.error } : { kind: "ready" }, + checks: { + ...state.checks, + opencode, + }, + }) + return + } + update({ ...state, job: null, @@ -389,6 +430,80 @@ export function createLocalServerController() { if (jobAbort === abort) jobAbort = undefined } }, + async installOpencode() { + if (!state.config.distro) throw new Error("No WSL distro selected") + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + clearTranscript() + appendTranscript({ stream: "system", text: `Installing OpenCode in ${state.config.distro}` }) + update({ + ...state, + job: { step: "opencode", startedAt: Date.now() }, + status: { kind: "running", step: "opencode" }, + }) + + try { + const resolvedPath = await resolveWslCommand("opencode", state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + + const result = resolvedPath + ? await upgradeWslOpencode(appVersion, state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + : await installWslOpencode(appVersion, state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + if (result.code !== 0) throw new Error(commandFailure(result, "OpenCode installation failed")) + + const nextPath = await resolveWslCommand("opencode", state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + if (jobAbort !== abort) return + const version = nextPath + ? await readWslCommandVersion(nextPath, state.config.distro, { + signal: abort.signal, + onLine: (line) => appendTranscript(line), + }) + : null + if (jobAbort !== abort) return + + const opencode = opencodeCheck(nextPath, version, appVersion) + update({ + ...state, + job: null, + status: opencode.error ? { kind: "failed", step: "opencode", message: opencode.error } : { kind: "ready" }, + checks: { + ...state.checks, + opencode, + }, + }) + } catch (error) { + if (jobAbort !== abort) return + if (error instanceof Error && error.name === "AbortError") { + update({ + ...state, + job: null, + status: { kind: "idle" }, + }) + return + } + update({ + ...state, + job: null, + status: { kind: "failed", step: "opencode", message: error instanceof Error ? error.message : String(error) }, + }) + } finally { + if (jobAbort === abort) jobAbort = undefined + } + }, async openTerminal() { if (!state.config.distro) throw new Error("No WSL distro selected") await openWslTerminal(state.config.distro) @@ -418,7 +533,7 @@ function toState(config: LocalServerConfig, current?: LocalServerState): LocalSe runtime: current?.runtime ?? windowsRuntime(), status: current?.status ?? { kind: "idle" }, job: current?.job ?? null, - checks: current?.checks ?? { wsl: null, distro: null }, + checks: current?.checks ?? { wsl: null, distro: null, opencode: null }, transcript: current?.transcript ?? [], } } @@ -517,3 +632,26 @@ function commandFailure(result: { stdout: string; stderr: string }, fallback: st .join("\n") return output || fallback } + +function opencodeCheck( + resolvedPath: string | null, + version: string | null, + expectedVersion: string, +): LocalServerOpencodeCheck { + if (!resolvedPath) { + return { + resolvedPath: null, + version: null, + expectedVersion, + matchesDesktop: null, + error: "opencode is not installed in the selected distro", + } + } + return { + resolvedPath, + version, + expectedVersion, + matchesDesktop: version ? version === expectedVersion : null, + error: null, + } +} diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts index 314f7bfd28..a0f6e97852 100644 --- a/packages/desktop-electron/src/main/wsl.ts +++ b/packages/desktop-electron/src/main/wsl.ts @@ -161,6 +161,14 @@ export async function installWslDistro(name: string, opts?: RunWslOptions) { return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) } +export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) { + return runWslBash( + `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`, + distro, + opts, + ) +} + export function wslNeedsRestart(result: WslCommandResult) { return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`) } @@ -202,13 +210,13 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis } } -export async function resolveWslCommand(command: string, distro: string) { - const result = await runWslSh(`command -v ${shellEscape(command)} 2>/dev/null || true`, distro) +export async function resolveWslCommand(command: string, distro: string, opts?: RunWslOptions) { + const result = await runWslSh(`command -v ${shellEscape(command)} 2>/dev/null || true`, distro, opts) return summarize(result.stdout) || null } -export async function readWslCommandVersion(command: string, distro: string) { - const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro) +export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) { + const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts) return firstLine(result.stdout) } diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 9b69a571e9..3d7db1e6d9 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -18,6 +18,7 @@ const api: ElectronAPI = { cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"), installWsl: () => ipcRenderer.invoke("local-server-install-wsl"), installDistro: (name) => ipcRenderer.invoke("local-server-install-distro", name), + installOpencode: () => ipcRenderer.invoke("local-server-install-opencode"), openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"), subscribe: (cb) => { const handler = (_: unknown, event: LocalServerEvent) => cb(event) diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index ec4de47023..f2bd4adce3 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -45,6 +45,13 @@ export type LocalServerDistroCheck = { selected: LocalServerDistroProbe | null error: string | null } +export type LocalServerOpencodeCheck = { + resolvedPath: string | null + version: string | null + expectedVersion: string | null + matchesDesktop: boolean | null + error: string | null +} export type LocalServerTranscriptLine = { stream: "stdout" | "stderr" | "system" text: string @@ -80,6 +87,7 @@ export type LocalServerState = { checks: { wsl: LocalServerWslCheck | null distro: LocalServerDistroCheck | null + opencode: LocalServerOpencodeCheck | null } transcript: LocalServerTranscriptLine[] } @@ -94,6 +102,7 @@ export type LocalServerAPI = { cancelJob: () => Promise installWsl: () => Promise installDistro: (name: string) => Promise + installOpencode: () => Promise openTerminal: () => Promise subscribe: (cb: (event: LocalServerEvent) => void) => () => void } diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 1b3c409340..3cd3124396 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -221,6 +221,7 @@ const createPlatform = (): Platform => { cancelJob: () => window.api.localServer.cancelJob(), installWsl: () => window.api.localServer.installWsl(), installDistro: (name) => window.api.localServer.installDistro(name), + installOpencode: () => window.api.localServer.installOpencode(), openTerminal: () => window.api.localServer.openTerminal(), subscribe: (cb) => window.api.localServer.subscribe(cb), },