fix: harden local server WSL onboarding checks

This commit is contained in:
LukeParkerDev
2026-04-19 13:15:26 +10:00
parent 1baa87bf0d
commit 76da54c596
6 changed files with 391 additions and 162 deletions
@@ -1,6 +1,7 @@
import { Button } from "@opencode-ai/ui/button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, reconcile, unwrap } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { LocalServerConfig, LocalServerState, LocalServerStep } from "@/context/platform"
@@ -15,6 +16,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
state: undefined as LocalServerState | undefined,
loading: true,
step: undefined as LocalServerStep | undefined,
installTarget: undefined as string | undefined,
})
createEffect(() => {
@@ -47,16 +49,54 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
const targetMode = createMemo<"windows" | "wsl">(
() => props.targetMode ?? (current()?.config.mode === "wsl" ? "wsl" : "wsl"),
)
const configuredDistro = createMemo(() => current()?.config.distro ?? null)
const busy = createMemo(() => !!current()?.job)
const selectedProbe = createMemo(() => current()?.checks.distro?.selected)
const selectedProbe = createMemo(() => {
const probe = current()?.checks.distro?.selected
return probe?.name === configuredDistro() ? probe : null
})
const selectedInstalled = createMemo(() =>
(current()?.checks.distro?.installed ?? []).find((item) => item.name === current()?.config.distro),
)
const otherDistros = createMemo(() =>
(current()?.checks.distro?.online ?? [])
.filter((item) => item.name !== "Debian" && item.name !== "Ubuntu-24.04")
.slice(0, 8),
const defaultInstalledDistro = createMemo(
() => (current()?.checks.distro?.installed ?? []).find((item) => item.isDefault) ?? null,
)
const opencodeCheck = createMemo(() => {
const check = current()?.checks.opencode
return check?.distro === configuredDistro() ? check : null
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady() && !probe.isRoot) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = configuredDistro()
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return `${distro} is not installed yet.`
return `Open ${distro} once to finish setup.`
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const opencodeMismatchCheck = createMemo(() => {
const check = opencodeCheck()
return check?.matchesDesktop === false ? check : null
})
const installableDistros = createMemo(() => {
const online = current()?.checks.distro?.online ?? []
const installed = new Set((current()?.checks.distro?.installed ?? []).map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
const configuredRuntime = createMemo(() => {
const state = current()
if (!state) return { mode: "windows" as const, distro: null as string | null }
@@ -77,7 +117,10 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => !!current()?.checks.opencode?.resolvedPath)
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error && check.matchesDesktop !== false
})
const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const recommendedStep = createMemo<LocalServerStep>(() => {
if (targetMode() === "windows") return "switch"
@@ -86,12 +129,102 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
if (!opencodeReady()) return "opencode"
return "switch"
})
const activeStep = createMemo(() => store.step ?? current()?.job?.step ?? recommendedStep())
const activeStep = createMemo(() => current()?.job?.step ?? store.step ?? recommendedStep())
createEffect(
on(recommendedStep, (next) => {
setStore("step", next)
}),
)
const autoProbe = createMemo(() => {
const state = current()
if (!state || !localServer() || busy() || targetMode() === "windows") return null
if (state.config.onboarding.pendingRestart) return null
if (!state.checks.wsl) return { key: "wsl", step: "wsl" as const }
if (!wslReady()) return null
if (!state.checks.distro) return { key: "distro:list", step: "distro" as const }
if (state.config.distro && !selectedProbe()) {
return { key: `distro:${state.config.distro}`, step: "distro" as const }
}
if (!state.config.distro || !distroReady()) return null
if (!opencodeCheck()) {
return { key: `opencode:${state.config.distro}`, step: "opencode" as const }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
lastAutoProbe = probe.key
void run(() => localServer()!.runStep(probe.step))
})
createEffect(() => {
const next = current()?.job?.step ?? recommendedStep()
if (!store.step || stepIndex(store.step) > stepIndex(next)) {
setStore("step", next)
const state = current()
const distro = defaultInstalledDistro()
if (!state || !distro || !localServer() || busy() || targetMode() !== "wsl") return
if (state.config.distro) return
void selectDistro(distro.name)
})
createEffect(() => {
const distros = installableDistros()
if (!distros.length) {
if (store.installTarget) setStore("installTarget", undefined)
return
}
if (store.installTarget && distros.some((item) => item.name === store.installTarget)) return
setStore("installTarget", distros[0]!.name)
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.step === "wsl") return "Checking WSL..."
if (state.config.onboarding.pendingRestart) return "Windows needs a restart to finish installing WSL."
if (state.checks.wsl?.available) return state.checks.wsl.version ?? "WSL is ready."
return state.checks.wsl?.error ?? "WSL is required to continue."
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return "Checking distros..."
if (state.job?.step === "distro") {
if (state.config.distro && !selectedInstalled()) return `Installing ${state.config.distro}...`
return state.config.distro ? `Checking ${state.config.distro}...` : "Checking distros..."
}
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (state.checks.distro?.error && !selectedProbe()) return state.checks.distro.error
if (selectedProbe() && distroReady()) return `${selectedProbe()!.name} is ready.`
if (state.config.distro) return `Finishing setup for ${state.config.distro}.`
return "Pick a distro or install one below."
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return "Checking OpenCode..."
if (state.job?.step === "opencode") {
return state.config.distro ? `Checking OpenCode in ${state.config.distro}...` : "Checking OpenCode..."
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return state.config.distro ? `Update OpenCode in ${state.config.distro}.` : "Update OpenCode."
}
if (opencodeReady())
return state.config.distro ? `OpenCode is ready in ${state.config.distro}.` : "OpenCode is ready."
return state.config.distro ? `Install OpenCode in ${state.config.distro}.` : "Choose a distro first."
})
const installProgress = createMemo(() => {
const state = current()
if (!state?.job || state.status.kind !== "running") return null
const transcript = state.transcript.filter((line) => line.text.trim())
const title = transcript[0]?.text
if (!title?.startsWith("Installing ")) return null
return {
title,
lines: transcript.slice(1).slice(-8),
}
})
@@ -192,16 +325,8 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
<Match when={activeStep() === "wsl"}>
<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="text-14-medium text-text-strong">Verify WSL</div>
<div class="flex gap-2 shrink-0">
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.runStep("wsl"))}
>
Check WSL
</Button>
<div class="text-14-medium text-text-strong">WSL</div>
<Show when={current()?.checks.wsl && !wslReady() && !current()?.config.onboarding.pendingRestart}>
<Button
variant="secondary"
size="large"
@@ -210,14 +335,9 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
>
Install WSL
</Button>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">
{current()?.checks.wsl?.error ??
current()?.checks.wsl?.status ??
current()?.checks.wsl?.version ??
"WSL has not been checked yet."}
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.config.onboarding.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
@@ -231,61 +351,17 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
<Match when={activeStep() === "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="text-14-medium text-text-strong">Choose a distro</div>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.runStep("distro"))}
>
Check distros
</Button>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">
{current()?.checks.distro?.error ?? current()?.config.distro ?? "Pick a distro or install one below."}
</div>
<div class="flex flex-wrap gap-2">
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installDistro("Debian"))}
>
Install Debian
</Button>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installDistro("Ubuntu-24.04"))}
>
Install Ubuntu 24
</Button>
</div>
<Show when={otherDistros().length > 0}>
<div class="flex flex-wrap gap-2">
<For each={otherDistros()}>
{(item) => (
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installDistro(item.name))}
>
{item.label}
</Button>
)}
</For>
</div>
</Show>
<div class="text-14-medium text-text-strong">Choose a distro</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
<Show
when={(current()?.checks.distro?.installed.length ?? 0) > 0}
fallback={<div class="text-12-regular text-text-weak">No distros detected yet.</div>}
fallback={
<div class="text-12-regular text-text-weak">
{current()?.checks.distro ? "No distros detected yet." : "Checking distros..."}
</div>
}
>
<For each={current()?.checks.distro?.installed ?? []}>
{(item) => (
@@ -307,29 +383,90 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
</Show>
</div>
<Show when={selectedProbe()}>
{(probe) => (
<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">
User: {probe().username ?? "unknown"}
{probe().isRoot ? " · root" : ""}
{selectedInstalled()?.version === 1 ? " · WSL 1" : ""}
</div>
<div class="text-12-regular text-text-weak">
bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "}
{probe().canExecute ? "yes" : "no"}
</div>
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
</Show>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">Install</div>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => localServer()!.installDistro(installTarget()!.name))}
>
Install
</Button>
</div>
)}
<div
role="radiogroup"
aria-label="Install distro"
class="max-h-44 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => store.installTarget === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-start gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1">
<div class="text-13-medium text-text-strong break-words">{item.label}</div>
<Show when={item.label !== item.name}>
<div class="text-12-regular text-text-weak break-words">{item.name}</div>
</Show>
</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show
when={
selectedInstalled()?.version === 1 ||
distroUnavailableMessage() ||
distroMissingTools() ||
distroWarningProbe()?.isRoot
}
>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">WSL 2 is required.</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">This distro needs bash and curl.</div>
</Show>
<Show when={distroWarningProbe()?.isRoot}>
<div class="text-12-regular text-text-warning-base">
This distro is using the root user right now.
</div>
</Show>
</div>
</Show>
<Button
variant="secondary"
size="large"
disabled={busy() || !current()?.config.distro}
disabled={busy() || !selectedInstalled()}
onClick={() => void run(() => localServer()!.openTerminal())}
>
Open terminal
@@ -340,32 +477,20 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
<Match when={activeStep() === "opencode"}>
<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="text-14-medium text-text-strong">Install OpenCode</div>
<div class="flex gap-2 shrink-0">
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.runStep("opencode"))}
>
Check OpenCode
</Button>
<div class="text-14-medium text-text-strong">OpenCode</div>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installOpencode())}
>
Install OpenCode
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
</div>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">
{current()?.checks.opencode?.error ??
current()?.checks.opencode?.resolvedPath ??
"OpenCode has not been checked in this distro yet."}
</div>
<Show when={current()?.checks.opencode}>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeMismatchCheck()}>
{(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>
@@ -375,11 +500,9 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
{(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 class="text-12-regular text-text-warning-base">
Installed version does not match the desktop app version.
</div>
</div>
)}
</Show>
@@ -389,9 +512,7 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
<Match when={activeStep() === "switch"}>
<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="text-14-medium text-text-strong">
{targetMode() === "windows" ? "Swap to Windows" : "Switch Local Server"}
</div>
<div class="text-14-medium text-text-strong">Switch</div>
<div class="flex gap-2 shrink-0">
<Show when={targetMode() === "windows" && configuredRuntime().mode !== "windows"}>
<Button variant="secondary" size="large" onClick={() => void swapToWindows()}>
@@ -436,7 +557,42 @@ export function DialogLocalServer(props: { targetMode?: "windows" | "wsl" }) {
</Match>
</Switch>
<Show when={(current()?.transcript.length ?? 0) > 0}>
<Show when={installProgress()}>
{(progress) => (
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
<div class="flex items-center gap-2 text-14-medium text-text-strong">
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
<div>Progress</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{progress().title}</div>
<div
data-scrollable
class="max-h-32 overflow-y-auto rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular whitespace-pre-wrap break-words"
>
<For
each={
progress().lines.length
? progress().lines
: [{ stream: "system" as const, text: "Waiting for output...", at: 0 }]
}
>
{(line) => (
<div
classList={{
"text-text-warning-base": line.stream === "stderr",
"text-text-weak": line.stream !== "stderr",
}}
>
{line.text}
</div>
)}
</For>
</div>
</div>
)}
</Show>
<Show when={current()?.status.kind === "failed" && (current()?.transcript.length ?? 0) > 0}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
<div class="text-14-medium text-text-strong">Diagnostics</div>
<div class="max-h-56 overflow-y-auto rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular text-text-weak whitespace-pre-wrap break-words">
@@ -463,9 +619,9 @@ function stepIndex(step: LocalServerStep) {
}
function stepTitle(step: LocalServerStep) {
if (step === "wsl") return "Verify WSL"
if (step === "wsl") return "WSL"
if (step === "distro") return "Choose distro"
if (step === "opencode") return "Install OpenCode"
if (step === "opencode") return "OpenCode"
return "Switch"
}
+1
View File
@@ -47,6 +47,7 @@ export type LocalServerDistroCheck = {
error: string | null
}
export type LocalServerOpencodeCheck = {
distro: string | null
resolvedPath: string | null
version: string | null
expectedVersion: string | null