fix: harden local server WSL onboarding checks

This commit is contained in:
LukeParkerDev
2026-04-16 16:31:54 +10:00
parent 1baa87bf0d
commit 76da54c596
6 changed files with 391 additions and 162 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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