feat: add local server opencode setup flow

This commit is contained in:
LukeParkerDev
2026-04-16 14:45:35 +10:00
parent e897883f91
commit 710469cf09
10 changed files with 227 additions and 7 deletions

View File

@@ -252,6 +252,56 @@ export function DialogLocalServer() {
</Button>
</div>
</div>
<Show when={current()?.config.distro}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col gap-1 min-w-0">
<div class="text-14-medium text-text-strong">OpenCode</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">
{current()?.checks.opencode?.error ?? current()?.checks.opencode?.resolvedPath ?? "Not checked yet"}
</div>
</div>
<div class="flex gap-2">
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.runStep("opencode"))}
>
Check OpenCode
</Button>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installOpencode())}
>
Install OpenCode
</Button>
</div>
</div>
<Show when={current()?.checks.opencode}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">Path: {check().resolvedPath ?? "not found"}</div>
<div class="text-12-regular text-text-weak">
Version: {check().version ?? "unknown"}
<Show when={check().expectedVersion}>
{(expected) => <span>{` · desktop ${expected()}`}</span>}
</Show>
</div>
<Show when={check().matchesDesktop === false}>
<div class="text-12-regular text-text-warning-base">
Installed version does not match the desktop app version.
</div>
</Show>
</div>
)}
</Show>
</div>
</Show>
</Show>
<Show when={(current()?.transcript.length ?? 0) > 0}>

View File

@@ -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<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
installOpencode(): Promise<void>
openTerminal(): Promise<void>
subscribe(cb: (event: LocalServerEvent) => void): () => void
}

View File

@@ -7,6 +7,7 @@ export {
type LocalServerConfig,
type LocalServerEvent,
type LocalServerMode,
type LocalServerOpencodeCheck,
type LocalServerPlatform,
type LocalServerState,
type LocalServerStep,

View File

@@ -56,7 +56,7 @@ const loadingComplete = defer<void>()
const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
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(),

View File

@@ -29,6 +29,7 @@ type Deps = {
cancelLocalServerJob: () => Promise<void> | void
installLocalServerWsl: () => Promise<void> | void
installLocalServerDistro: (name: string) => Promise<void> | void
installLocalServerOpencode: () => Promise<void> | void
openLocalServerTerminal: () => Promise<void> | void
onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void
getDefaultServerUrl: () => Promise<string | null> | 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) =>

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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<void>
installWsl: () => Promise<void>
installDistro: (name: string) => Promise<void>
installOpencode: () => Promise<void>
openTerminal: () => Promise<void>
subscribe: (cb: (event: LocalServerEvent) => void) => () => void
}

View File

@@ -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),
},