From 421293180822911875128514bfdcadc1655dc890 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:24:44 +1000 Subject: [PATCH] feat: add Electron WSL command helpers --- packages/desktop-electron/src/main/apps.ts | 10 +- packages/desktop-electron/src/main/wsl.ts | 267 +++++++++++++++++++++ 2 files changed, 270 insertions(+), 7 deletions(-) create mode 100644 packages/desktop-electron/src/main/wsl.ts diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index d0d4618c29..d0fd835b8e 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -1,6 +1,7 @@ import { execFileSync } from "node:child_process" import { existsSync, readFileSync, readdirSync } from "node:fs" import { dirname, extname, join } from "node:path" +import { wslArgs } from "./wsl" export function checkAppExists(appName: string): boolean { if (process.platform === "win32") return true @@ -21,22 +22,17 @@ export function wslPath(path: string, mode: "windows" | "linux" | null, distro?: if (path.startsWith("~")) { const suffix = path.slice(1) const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"` - const output = execFileSync("wsl", inDistro(["sh", "-lc", cmd], distro)) + const output = execFileSync("wsl", wslArgs(["sh", "-lc", cmd], distro)) return output.toString().trim() } - const output = execFileSync("wsl", inDistro(["wslpath", flag, path], distro)) + const output = execFileSync("wsl", wslArgs(["wslpath", flag, path], distro)) return output.toString().trim() } catch (error) { throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) } } -function inDistro(args: string[], distro?: string | null) { - if (!distro) return ["-e", ...args] - return ["-d", distro, "--", ...args] -} - function checkMacosApp(appName: string) { const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`] diff --git a/packages/desktop-electron/src/main/wsl.ts b/packages/desktop-electron/src/main/wsl.ts new file mode 100644 index 0000000000..3059e99a0e --- /dev/null +++ b/packages/desktop-electron/src/main/wsl.ts @@ -0,0 +1,267 @@ +import { spawn } from "node:child_process" + +export type WslCommandLine = { + stream: "stdout" | "stderr" + text: string +} + +export type WslCommandResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +export type WslRuntimeProbe = { + available: boolean + version: string | null + status: string | null + error: string | null +} + +export type WslInstalledDistro = { + name: string + state: string | null + version: number | null + isDefault: boolean +} + +export type WslOnlineDistro = { + name: string + label: string +} + +export type WslDistroProbe = { + name: string + canExecute: boolean + hasBash: boolean + hasCurl: boolean + username: string | null + isRoot: boolean | null + error: string | null +} + +type RunWslOptions = { + onLine?: (line: WslCommandLine) => void + signal?: AbortSignal +} + +export function wslArgs(args: string[], distro?: string | null) { + if (distro) return ["-d", distro, "--", ...args] + return ["--", ...args] +} + +export function runWsl(args: string[], opts: RunWslOptions = {}) { + return new Promise((resolve, reject) => { + const child = spawn("wsl", args, { + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + signal: opts.signal, + }) + + let stdout = "" + let stderr = "" + let stdoutPending = "" + let stderrPending = "" + + const flush = (stream: WslCommandLine["stream"], pending: string) => { + if (!pending) return "" + opts.onLine?.({ stream, text: pending }) + 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) => { + 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.stderr.on("end", () => { + stderrPending = flush("stderr", stderrPending) + }) + + child.once("error", reject) + child.once("close", (code, signal) => { + resolve({ code, signal, stdout, stderr }) + }) + }) +} + +export function runWslInDistro(args: string[], distro?: string | null, opts?: RunWslOptions) { + return runWsl(wslArgs(args, distro), opts) +} + +export function runWslSh(script: string, distro?: string | null, opts?: RunWslOptions) { + return runWslInDistro(["sh", "-lc", script], distro, opts) +} + +export function runWslBash(script: string, distro?: string | null, opts?: RunWslOptions) { + return runWslInDistro(["bash", "-lc", script], distro, opts) +} + +export async function probeWslRuntime(): Promise { + const version = await runWsl(["--version"]).catch((error) => ({ + code: 1, + signal: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) + + if (version.code !== 0) { + return { + available: false, + version: null, + status: null, + error: summarize(version.stderr || version.stdout) || "WSL is unavailable", + } + } + + const status = await runWsl(["--status"]).catch(() => undefined) + return { + available: true, + version: firstLine(version.stdout), + status: status?.code === 0 ? summarize(status.stdout) : null, + error: null, + } +} + +export async function listInstalledWslDistros() { + const result = await runWsl(["--list", "--verbose"]) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || "Failed to list installed WSL distros") + } + return parseInstalledDistros(result.stdout) +} + +export async function listOnlineWslDistros() { + const result = await runWsl(["--list", "--online"]) + if (result.code !== 0) { + throw new Error(summarize(result.stderr || result.stdout) || "Failed to list online WSL distros") + } + return parseOnlineDistros(result.stdout) +} + +export async function installWslRuntime(opts?: RunWslOptions) { + return runWsl(["--install", "--no-distribution"], opts) +} + +export async function installWslDistro(name: string, opts?: RunWslOptions) { + return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts) +} + +export async function probeWslDistro(name: string): Promise { + const executable = await runWslInDistro(["/bin/true"], name).catch((error) => ({ + code: 1, + signal: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + })) + if (executable.code !== 0) { + return { + name, + canExecute: false, + hasBash: false, + hasCurl: false, + username: null, + isRoot: null, + error: summarize(executable.stderr || executable.stdout) || "Cannot execute commands in distro", + } + } + + const [bash, curl, user] = await Promise.all([ + runWslSh("command -v bash >/dev/null && printf yes || printf no", name), + runWslSh("command -v curl >/dev/null && printf yes || printf no", name), + runWslSh("id -un 2>/dev/null || true", name), + ]) + + const username = summarize(user.stdout) + return { + name, + canExecute: true, + hasBash: bash.code === 0 && summarize(bash.stdout) === "yes", + hasCurl: curl.code === 0 && summarize(curl.stdout) === "yes", + username: username || null, + isRoot: username ? username === "root" : null, + error: null, + } +} + +export async function resolveWslCommand(command: string, distro: string) { + const result = await runWslSh(`command -v ${shellEscape(command)} 2>/dev/null || true`, distro) + 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) + return firstLine(result.stdout) +} + +export async function upgradeWslOpencode(target: string, distro: string, opts?: RunWslOptions) { + return runWslBash(`opencode upgrade ${shellEscape(target)}`, distro, opts) +} + +function parseInstalledDistros(output: string) { + return output.split(/\r?\n/g).flatMap((line) => { + const trimmed = line.trim() + if (!trimmed) return [] + const match = line.match(/^\s*(\*)?\s*(.*?)\s{2,}(\S+)\s+(\d+)\s*$/) + if (!match) return [] + const [, marker, name, state, version] = match + if (!name || /^name$/i.test(name)) return [] + return [ + { + name: name.trim(), + state: state || null, + version: Number.isNaN(Number.parseInt(version, 10)) ? null : Number.parseInt(version, 10), + isDefault: marker === "*", + } satisfies WslInstalledDistro, + ] + }) +} + +function parseOnlineDistros(output: string) { + return output.split(/\r?\n/g).flatMap((line) => { + const trimmed = line.trim() + if (!trimmed) return [] + const match = trimmed.match(/^([A-Za-z0-9._-]+)\s{2,}(.+)$/) + if (!match) return [] + const [, name, label] = match + if (/^name$/i.test(name)) return [] + return [{ name, label: label.trim() } satisfies WslOnlineDistro] + }) +} + +function firstLine(value: string) { + return ( + value + .split(/\r?\n/g) + .map((line) => line.trim()) + .find(Boolean) ?? null + ) +} + +function summarize(value: string) { + return value + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter(Boolean) + .join("\n") +} + +function shellEscape(value: string) { + return `'${value.replace(/'/g, `'"'"'`)}'` +}