feat: add Electron WSL command helpers

This commit is contained in:
LukeParkerDev
2026-04-16 14:24:44 +10:00
parent 1a215700c8
commit 4212931808
2 changed files with 270 additions and 7 deletions
+3 -7
View File
@@ -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`]
+267
View File
@@ -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<WslCommandResult>((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<WslRuntimeProbe> {
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<WslDistroProbe> {
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, `'"'"'`)}'`
}