mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 11:55:46 +00:00
feat: add local server opencode setup flow
This commit is contained in:
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export {
|
||||
type LocalServerConfig,
|
||||
type LocalServerEvent,
|
||||
type LocalServerMode,
|
||||
type LocalServerOpencodeCheck,
|
||||
type LocalServerPlatform,
|
||||
type LocalServerState,
|
||||
type LocalServerStep,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user