mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 03:45:23 +00:00
fix: harden local server WSL onboarding checks
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
probeWslDistro,
|
||||
probeWslRuntime,
|
||||
readWslCommandVersion,
|
||||
resolveWslCommand,
|
||||
resolveWslOpencode,
|
||||
upgradeWslOpencode,
|
||||
wslNeedsRestart,
|
||||
} from "./wsl"
|
||||
@@ -175,7 +175,7 @@ export function createLocalServerController(appVersion: string) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPath = await resolveWslCommand("opencode", state.config.distro, {
|
||||
const resolvedPath = await resolveWslOpencode(state.config.distro, {
|
||||
signal: abort.signal,
|
||||
onLine: (line) => appendTranscript(line),
|
||||
})
|
||||
@@ -188,7 +188,7 @@ export function createLocalServerController(appVersion: string) {
|
||||
: null
|
||||
if (jobAbort !== abort) return
|
||||
|
||||
const opencode = opencodeCheck(resolvedPath, version, appVersion)
|
||||
const opencode = opencodeCheck(resolvedPath, version, appVersion, state.config.distro)
|
||||
update({
|
||||
...state,
|
||||
job: null,
|
||||
@@ -444,30 +444,38 @@ export function createLocalServerController(appVersion: string) {
|
||||
})
|
||||
|
||||
try {
|
||||
const resolvedPath = await resolveWslCommand("opencode", state.config.distro, {
|
||||
const resolvedPath = await resolveWslOpencode(state.config.distro, {
|
||||
signal: abort.signal,
|
||||
onLine: (line) => appendTranscript(line),
|
||||
})
|
||||
if (jobAbort !== abort) return
|
||||
const currentVersion = resolvedPath
|
||||
? await readWslCommandVersion(resolvedPath, state.config.distro, {
|
||||
signal: abort.signal,
|
||||
onLine: (line) => appendTranscript(line),
|
||||
})
|
||||
: null
|
||||
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),
|
||||
})
|
||||
const result =
|
||||
resolvedPath && currentVersion
|
||||
? await upgradeWslOpencode(appVersion, resolvedPath, 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, {
|
||||
const nextPath = await resolveWslOpencode(state.config.distro, {
|
||||
signal: abort.signal,
|
||||
onLine: (line) => appendTranscript(line),
|
||||
})
|
||||
if (jobAbort !== abort) return
|
||||
const version = nextPath
|
||||
const nextVersion = nextPath
|
||||
? await readWslCommandVersion(nextPath, state.config.distro, {
|
||||
signal: abort.signal,
|
||||
onLine: (line) => appendTranscript(line),
|
||||
@@ -475,7 +483,7 @@ export function createLocalServerController(appVersion: string) {
|
||||
: null
|
||||
if (jobAbort !== abort) return
|
||||
|
||||
const opencode = opencodeCheck(nextPath, version, appVersion)
|
||||
const opencode = opencodeCheck(nextPath, nextVersion, appVersion, state.config.distro)
|
||||
update({
|
||||
...state,
|
||||
job: null,
|
||||
@@ -637,9 +645,11 @@ function opencodeCheck(
|
||||
resolvedPath: string | null,
|
||||
version: string | null,
|
||||
expectedVersion: string,
|
||||
distro: string | null,
|
||||
): LocalServerOpencodeCheck {
|
||||
if (!resolvedPath) {
|
||||
return {
|
||||
distro,
|
||||
resolvedPath: null,
|
||||
version: null,
|
||||
expectedVersion,
|
||||
@@ -647,7 +657,18 @@ function opencodeCheck(
|
||||
error: "opencode is not installed in the selected distro",
|
||||
}
|
||||
}
|
||||
if (!version) {
|
||||
return {
|
||||
distro,
|
||||
resolvedPath,
|
||||
version: null,
|
||||
expectedVersion,
|
||||
matchesDesktop: null,
|
||||
error: "opencode is installed but could not run in the selected distro",
|
||||
}
|
||||
}
|
||||
return {
|
||||
distro,
|
||||
resolvedPath,
|
||||
version,
|
||||
expectedVersion,
|
||||
|
||||
@@ -52,6 +52,8 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
||||
let stderr = ""
|
||||
let stdoutPending = ""
|
||||
let stderrPending = ""
|
||||
const stdoutDecoder = createOutputDecoder()
|
||||
const stderrDecoder = createOutputDecoder()
|
||||
|
||||
const flush = (stream: WslCommandLine["stream"], pending: string) => {
|
||||
if (!pending) return ""
|
||||
@@ -59,27 +61,36 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
||||
return ""
|
||||
}
|
||||
|
||||
child.stdout.setEncoding("utf8")
|
||||
child.stdout.on("data", (chunk: string) => {
|
||||
stdout += chunk
|
||||
stdoutPending += chunk
|
||||
const lines = stdoutPending.split(/\r?\n/g)
|
||||
stdoutPending = lines.pop() ?? ""
|
||||
for (const line of lines) opts.onLine?.({ stream: "stdout", text: line })
|
||||
})
|
||||
child.stdout.on("end", () => {
|
||||
stdoutPending = flush("stdout", stdoutPending)
|
||||
})
|
||||
|
||||
child.stderr.setEncoding("utf8")
|
||||
child.stderr.on("data", (chunk: string) => {
|
||||
const append = (stream: WslCommandLine["stream"], chunk: string) => {
|
||||
if (!chunk) return
|
||||
if (stream === "stdout") {
|
||||
stdout += chunk
|
||||
stdoutPending += chunk
|
||||
const lines = stdoutPending.split(/\r?\n/g)
|
||||
stdoutPending = lines.pop() ?? ""
|
||||
for (const line of lines) opts.onLine?.({ stream: "stdout", text: line })
|
||||
return
|
||||
}
|
||||
stderr += chunk
|
||||
stderrPending += chunk
|
||||
const lines = stderrPending.split(/\r?\n/g)
|
||||
stderrPending = lines.pop() ?? ""
|
||||
for (const line of lines) opts.onLine?.({ stream: "stderr", text: line })
|
||||
}
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
append("stdout", stdoutDecoder.decode(chunk))
|
||||
})
|
||||
child.stdout.on("end", () => {
|
||||
append("stdout", stdoutDecoder.flush())
|
||||
stdoutPending = flush("stdout", stdoutPending)
|
||||
})
|
||||
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
append("stderr", stderrDecoder.decode(chunk))
|
||||
})
|
||||
child.stderr.on("end", () => {
|
||||
append("stderr", stderrDecoder.flush())
|
||||
stderrPending = flush("stderr", stderrPending)
|
||||
})
|
||||
|
||||
@@ -90,6 +101,28 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function createOutputDecoder() {
|
||||
let decoder: TextDecoder | undefined
|
||||
return {
|
||||
decode(chunk: Buffer) {
|
||||
decoder ??= new TextDecoder(detectOutputEncoding(chunk))
|
||||
return decoder.decode(chunk, { stream: true })
|
||||
},
|
||||
flush() {
|
||||
return decoder?.decode() ?? ""
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function detectOutputEncoding(chunk: Uint8Array) {
|
||||
if (chunk[0] === 0xff && chunk[1] === 0xfe) return "utf-16le"
|
||||
const pairs = Math.floor(chunk.length / 2)
|
||||
if (pairs < 2) return "utf-8"
|
||||
const oddZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2 + 1] === 0).length
|
||||
const evenZeroes = Array.from({ length: pairs }).filter((_, index) => chunk[index * 2] === 0).length
|
||||
return oddZeroes >= Math.ceil(pairs / 3) && evenZeroes * 2 <= oddZeroes ? "utf-16le" : "utf-8"
|
||||
}
|
||||
|
||||
export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) {
|
||||
return runWsl(wslArgs(args, distro), opts)
|
||||
}
|
||||
@@ -210,9 +243,25 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
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 resolveWslOpencode(distro: string, opts?: RunWslOptions) {
|
||||
const result = await runWslBash(
|
||||
[
|
||||
'path="$(command -v opencode 2>/dev/null || true)"',
|
||||
'for candidate in "$path" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" "$HOME/bin/opencode" "$HOME/.opencode/bin/opencode" "/usr/local/bin/opencode"; do',
|
||||
' [ -n "$candidate" ] || continue',
|
||||
' case "$candidate" in',
|
||||
" /mnt/*) continue ;;",
|
||||
" esac",
|
||||
' if [ -x "$candidate" ]; then',
|
||||
' printf "%s\\n" "$candidate"',
|
||||
" exit 0",
|
||||
" fi",
|
||||
"done",
|
||||
].join("\n"),
|
||||
distro,
|
||||
opts,
|
||||
)
|
||||
return firstLine(result.stdout)
|
||||
}
|
||||
|
||||
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
|
||||
@@ -220,8 +269,8 @@ export async function readWslCommandVersion(command: string, distro: string, opt
|
||||
return firstLine(result.stdout)
|
||||
}
|
||||
|
||||
export async function upgradeWslOpencode(target: string, distro: string, opts?: RunWslOptions) {
|
||||
return runWslBash(`opencode upgrade ${shellEscape(target)}`, distro, opts)
|
||||
export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) {
|
||||
return runWslBash(`${shellEscape(command)} upgrade ${shellEscape(target)}`, distro, opts)
|
||||
}
|
||||
|
||||
export function openWslTerminal(distro?: string | null) {
|
||||
|
||||
@@ -51,6 +51,7 @@ export type LocalServerDistroCheck = {
|
||||
error: string | null
|
||||
}
|
||||
export type LocalServerOpencodeCheck = {
|
||||
distro: string | null
|
||||
resolvedPath: string | null
|
||||
version: string | null
|
||||
expectedVersion: string | null
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 280px;
|
||||
overflow: auto;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
|
||||
/* Hide scrollbar */
|
||||
@@ -102,7 +102,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
Reference in New Issue
Block a user