feat: add local server install actions

This commit is contained in:
LukeParkerDev
2026-04-16 14:35:58 +10:00
parent c0e449d3ac
commit c575415ec4
6 changed files with 263 additions and 8 deletions

View File

@@ -255,6 +255,8 @@ registerIpcHandlers({
setLocalServerConfig: (config) => localServer.setConfig(config),
runLocalServerStep: (step) => localServer.runStep(step),
cancelLocalServerJob: () => localServer.cancelJob(),
installLocalServerWsl: () => localServer.installWsl(),
installLocalServerDistro: (name) => localServer.installDistro(name),
openLocalServerTerminal: () => localServer.openTerminal(),
onLocalServerEvent: (listener) => localServer.subscribe(listener),
getDefaultServerUrl: () => getDefaultServerUrl(),

View File

@@ -27,6 +27,8 @@ type Deps = {
setLocalServerConfig: (config: LocalServerConfig) => Promise<void> | void
runLocalServerStep: (step: LocalServerStep) => Promise<void> | void
cancelLocalServerJob: () => Promise<void> | void
installLocalServerWsl: () => Promise<void> | void
installLocalServerDistro: (name: string) => Promise<void> | void
openLocalServerTerminal: () => Promise<void> | void
onLocalServerEvent: (listener: (event: LocalServerEvent) => void) => () => void
getDefaultServerUrl: () => Promise<string | null> | string | null
@@ -66,6 +68,10 @@ export function registerIpcHandlers(deps: Deps) {
deps.runLocalServerStep(step),
)
ipcMain.handle("local-server-cancel-job", () => deps.cancelLocalServerJob())
ipcMain.handle("local-server-install-wsl", () => deps.installLocalServerWsl())
ipcMain.handle("local-server-install-distro", (_event: IpcMainInvokeEvent, name: string) =>
deps.installLocalServerDistro(name),
)
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

@@ -8,7 +8,16 @@ import type {
} from "../preload/types"
import { LOCAL_SERVER_KEY } from "./constants"
import { store } from "./store"
import { listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, probeWslDistro, probeWslRuntime } from "./wsl"
import {
installWslDistro,
installWslRuntimeElevated,
listInstalledWslDistros,
listOnlineWslDistros,
openWslTerminal,
probeWslDistro,
probeWslRuntime,
wslNeedsRestart,
} from "./wsl"
export function defaultLocalServerConfig(): LocalServerConfig {
return {
@@ -54,17 +63,22 @@ export function createLocalServerController() {
})
}
const persistConfig = (config: LocalServerConfig) => {
const next = normalizeLocalServerConfig(config)
store.set(LOCAL_SERVER_KEY, next)
update({
...state,
config: next,
})
return next
}
return {
getState() {
return state
},
setConfig(config: LocalServerConfig) {
const next = normalizeLocalServerConfig(config)
store.set(LOCAL_SERVER_KEY, next)
update({
...state,
config: next,
})
persistConfig(config)
},
subscribe(listener: (event: LocalServerEvent) => void) {
listeners.add(listener)
@@ -180,6 +194,201 @@ export function createLocalServerController() {
status: { kind: "idle" },
})
},
async installWsl() {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
clearTranscript()
appendTranscript({ stream: "system", text: "Installing WSL runtime" })
persistConfig({
...state.config,
mode: "wsl",
onboarding: {
...state.config.onboarding,
step: "wsl",
complete: false,
pendingRestart: false,
},
})
update({
...state,
job: { step: "wsl", startedAt: Date.now() },
status: { kind: "running", step: "wsl" },
})
try {
const result = await installWslRuntimeElevated({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
if (result.code !== 0) throw new Error(commandFailure(result, "WSL installation failed"))
const pendingRestart = wslNeedsRestart(result)
const nextConfig = persistConfig({
...state.config,
mode: "wsl",
onboarding: {
...state.config.onboarding,
step: pendingRestart ? "wsl" : "distro",
complete: false,
pendingRestart,
},
})
if (pendingRestart) {
const message = "Windows restart required to finish WSL installation"
update({
...state,
config: nextConfig,
job: null,
status: { kind: "failed", step: "wsl", message },
checks: {
...state.checks,
wsl: {
available: false,
version: null,
status: null,
error: message,
},
},
})
return
}
const wsl = await probeWslRuntime({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
update({
...state,
config: nextConfig,
job: null,
status: wsl.available
? { kind: "ready" }
: { kind: "failed", step: "wsl", message: wsl.error ?? "WSL is unavailable" },
checks: {
...state.checks,
wsl,
},
})
} 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: "wsl", message: error instanceof Error ? error.message : String(error) },
})
} finally {
if (jobAbort === abort) jobAbort = undefined
}
},
async installDistro(name: string) {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
clearTranscript()
appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` })
persistConfig({
...state.config,
mode: "wsl",
distro: name,
onboarding: {
...state.config.onboarding,
step: "distro",
complete: false,
pendingRestart: false,
},
})
update({
...state,
job: { step: "distro", startedAt: Date.now() },
status: { kind: "running", step: "distro" },
})
try {
const result = await installWslDistro(name, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
if (result.code !== 0) throw new Error(commandFailure(result, `Failed to install distro: ${name}`))
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
}),
listOnlineWslDistros({
signal: abort.signal,
onLine: (line) => appendTranscript(line),
}),
])
if (jobAbort !== abort) return
const installed = installedResult.status === "fulfilled" ? installedResult.value : []
const online = onlineResult.status === "fulfilled" ? onlineResult.value : []
const selected = await probeWslDistro(name, {
signal: abort.signal,
onLine: (line) => appendTranscript(line),
})
if (jobAbort !== abort) return
const error = distroError(name, installed, selected, installedResult, onlineResult)
const nextConfig = persistConfig({
...state.config,
mode: "wsl",
distro: name,
onboarding: {
...state.config.onboarding,
step: error ? "distro" : "opencode",
complete: false,
pendingRestart: false,
},
})
update({
...state,
config: nextConfig,
job: null,
status: error ? { kind: "failed", step: "distro", message: error } : { kind: "ready" },
checks: {
...state.checks,
distro: {
installed,
online,
selected,
error,
},
},
})
} 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: "distro", 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)
@@ -299,3 +508,12 @@ function distroError(
if (selected?.error) return selected.error
return null
}
function commandFailure(result: { stdout: string; stderr: string }, fallback: string) {
const output = `${result.stderr}\n${result.stdout}`
.split(/\r?\n/g)
.map((line) => line.trim())
.filter(Boolean)
.join("\n")
return output || fallback
}

View File

@@ -29,8 +29,20 @@ export function wslArgs(args: string[], distro?: string | null) {
}
export function runWsl(args: string[], opts: RunWslOptions = {}) {
return runCommand("wsl", args, opts)
}
function runPowerShell(command: string, opts: RunWslOptions = {}) {
return runCommand(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command],
opts,
)
}
function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = spawn("wsl", args, {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
signal: opts.signal,
@@ -136,10 +148,23 @@ export async function installWslRuntime(opts?: RunWslOptions) {
return runWsl(["--install", "--no-distribution"], opts)
}
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
const script = [
"$ErrorActionPreference = 'Stop'",
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
].join("; ")
return runPowerShell(script, opts)
}
export async function installWslDistro(name: string, opts?: RunWslOptions) {
return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts)
}
export function wslNeedsRestart(result: WslCommandResult) {
return /restart|reboot/i.test(`${result.stdout}\n${result.stderr}`)
}
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<LocalServerDistroProbe> {
const executable = await runWslInDistro(["/bin/true"], name, opts).catch((error) => ({
code: 1,

View File

@@ -16,6 +16,8 @@ const api: ElectronAPI = {
setConfig: (config) => ipcRenderer.invoke("local-server-set-config", config),
runStep: (step) => ipcRenderer.invoke("local-server-run-step", step),
cancelJob: () => ipcRenderer.invoke("local-server-cancel-job"),
installWsl: () => ipcRenderer.invoke("local-server-install-wsl"),
installDistro: (name) => ipcRenderer.invoke("local-server-install-distro", name),
openTerminal: () => ipcRenderer.invoke("local-server-open-terminal"),
subscribe: (cb) => {
const handler = (_: unknown, event: LocalServerEvent) => cb(event)

View File

@@ -92,6 +92,8 @@ export type LocalServerAPI = {
setConfig: (config: LocalServerConfig) => Promise<void>
runStep: (step: LocalServerStep) => Promise<void>
cancelJob: () => Promise<void>
installWsl: () => Promise<void>
installDistro: (name: string) => Promise<void>
openTerminal: () => Promise<void>
subscribe: (cb: (event: LocalServerEvent) => void) => () => void
}