mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 20:50:01 -04:00
Compare commits
3 Commits
beta
...
desktop-wsl-v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 07abdeef1f | |||
| af127a643b | |||
| 77aa1cfede |
@@ -22,6 +22,7 @@ await rm(outdir, { recursive: true, force: true })
|
||||
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const skipWebUi = process.argv.includes("--skip-web-ui")
|
||||
const solidPlugin = createSolidTransformPlugin()
|
||||
@@ -46,13 +47,16 @@ const allTargets: {
|
||||
{ os: "win32", arch: "x64", avx2: false },
|
||||
]
|
||||
|
||||
const targets = singleFlag
|
||||
? allTargets.filter((item) => {
|
||||
if (item.os !== process.platform || item.arch !== process.arch) return false
|
||||
if (item.avx2 === false) return baselineFlag
|
||||
return item.abi === undefined
|
||||
})
|
||||
: allTargets
|
||||
const targets = requestedTarget
|
||||
? allTargets.filter((item) => targetName(item) === requestedTarget)
|
||||
: singleFlag
|
||||
? allTargets.filter((item) => {
|
||||
if (item.os !== process.platform || item.arch !== process.arch) return false
|
||||
if (item.avx2 === false) return baselineFlag
|
||||
return item.abi === undefined
|
||||
})
|
||||
: allTargets
|
||||
if (!targets.length) throw new Error(`Unknown build target: ${requestedTarget}`)
|
||||
|
||||
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
const appArchive = await buildAppArchive(Script.channel, { skipBuild: skipWebUi })
|
||||
@@ -81,15 +85,7 @@ for (const item of targets) {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const target = [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
item.arch,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
item.abi,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
const target = targetName(item)
|
||||
const name = target.replace(binary, "cli")
|
||||
console.log(`building ${name}`)
|
||||
const result = await Bun.build({
|
||||
@@ -143,3 +139,15 @@ for (const item of targets) {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function targetName(item: (typeof allTargets)[number]) {
|
||||
return [
|
||||
binary,
|
||||
item.os === "win32" ? "windows" : item.os,
|
||||
item.arch,
|
||||
item.avx2 === false ? "baseline" : undefined,
|
||||
item.abi,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("-")
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { $ } from "bun"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { buildCliToResources, downloadCliToResources, windowsify } from "./utils"
|
||||
import { downloadCliToResources, windowsify } from "./utils"
|
||||
|
||||
type ServerSource = { type: "build" } | { type: "download"; version: string }
|
||||
type DevOptions = { server: ServerSource; electron: string[] }
|
||||
@@ -38,18 +37,12 @@ function selectOptions(): DevOptions {
|
||||
}
|
||||
|
||||
async function prepareServer(source: ServerSource) {
|
||||
const destination = windowsify("resources/opencode-cli-dev")
|
||||
if (source.type === "download") return downloadCliToResources(source.version, destination)
|
||||
return buildCliToResources(destination, developmentStateHome())
|
||||
}
|
||||
|
||||
function developmentStateHome() {
|
||||
const appData = (() => {
|
||||
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
|
||||
if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")
|
||||
return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
|
||||
})()
|
||||
return join(appData, "ai.opencode.desktop.dev")
|
||||
if (source.type === "download")
|
||||
return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev"))
|
||||
process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli")
|
||||
if (process.platform !== "win32") return
|
||||
process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD = join(import.meta.dirname, "../../cli/script/build.ts")
|
||||
process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT = join(import.meta.dirname, "../resources/opencode-cli-wsl")
|
||||
}
|
||||
|
||||
async function startDesktop(args: string[]) {
|
||||
|
||||
@@ -86,34 +86,6 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
|
||||
console.log(`Copied ${cli.package}@${version} to ${dest}`)
|
||||
}
|
||||
|
||||
export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
|
||||
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
|
||||
try {
|
||||
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env(
|
||||
{
|
||||
...process.env,
|
||||
OPENCODE_VERSION: process.env.OPENCODE_VERSION,
|
||||
},
|
||||
)
|
||||
if (stateHome && (await Bun.file(dest).exists())) {
|
||||
const child = Bun.spawn([dest, "service", "stop"], {
|
||||
env: { ...process.env, XDG_STATE_HOME: stateHome },
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
const exitCode = await child.exited
|
||||
if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`)
|
||||
}
|
||||
await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
await prepareCli(dest)
|
||||
|
||||
console.log(`Built local CLI at ${dest}`)
|
||||
}
|
||||
|
||||
async function prepareCli(dest: string) {
|
||||
if (process.platform !== "win32") await chmod(dest, 0o755)
|
||||
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { dirname, join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { promisify } from "node:util"
|
||||
import { app } from "electron"
|
||||
import { parseCliVersion } from "./cli-version"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
@@ -17,36 +18,56 @@ type Logger = {
|
||||
|
||||
export async function startBackgroundCli(logger: Logger) {
|
||||
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
|
||||
const bundled = app.isPackaged
|
||||
? join(process.resourcesPath, executableName())
|
||||
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
|
||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseVersion(await run(bundled, ["--version"], logger))
|
||||
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
|
||||
const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV
|
||||
const cli = development
|
||||
? {
|
||||
version: "local",
|
||||
command: ["bun", "run", "--cwd", development, "dev", "--"],
|
||||
binary: undefined,
|
||||
}
|
||||
: await resolveBundledCli(isolated, logger)
|
||||
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
|
||||
const service = await Service.ensure({
|
||||
file:
|
||||
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
|
||||
? join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version,
|
||||
command: [binary, "serve", "--service"],
|
||||
version: cli.version,
|
||||
command: [...cli.command, "serve", "--service"],
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
})
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
logger.log("v2 CLI background service ready", {
|
||||
username: service.auth.username,
|
||||
version,
|
||||
version: cli.version,
|
||||
...endpoint(service.url),
|
||||
})
|
||||
if (isolated) await cleanCliStages(binary, logger)
|
||||
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
|
||||
return {
|
||||
url: service.url,
|
||||
username: service.auth.username,
|
||||
password: service.auth.password,
|
||||
version: cli.version,
|
||||
wslBuild:
|
||||
app.isPackaged || !process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD || !process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT
|
||||
? undefined
|
||||
: {
|
||||
script: process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD,
|
||||
output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveBundledCli(isolated: boolean, logger: Logger) {
|
||||
const bundled = app.isPackaged
|
||||
? join(process.resourcesPath, executableName())
|
||||
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
|
||||
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
|
||||
const version = parseCliVersion(await run(bundled, ["--version"], logger))
|
||||
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
|
||||
return { version, binary, command: [binary] }
|
||||
}
|
||||
|
||||
async function cleanCliStages(binary: string, logger: Logger) {
|
||||
const current = dirname(binary)
|
||||
const root = dirname(current)
|
||||
@@ -103,13 +124,6 @@ async function run(binary: string, args: string[], logger: Logger) {
|
||||
)
|
||||
}
|
||||
|
||||
function parseVersion(output: string) {
|
||||
const marker = output.lastIndexOf(" v")
|
||||
const version = marker === -1 ? output : output.slice(marker + 2)
|
||||
if (!version) throw new Error("V2 CLI did not provide a version")
|
||||
return version
|
||||
}
|
||||
|
||||
function endpoint(url: string | undefined) {
|
||||
if (!url || !URL.canParse(url)) return {}
|
||||
const parsed = new URL(url)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function parseCliVersion(output: string) {
|
||||
const marker = output.lastIndexOf(" v")
|
||||
const version = marker === -1 ? output : output.slice(marker + 2)
|
||||
if (!version) throw new Error("V2 CLI did not provide a version")
|
||||
return version
|
||||
}
|
||||
@@ -36,9 +36,7 @@ import {
|
||||
setDockIcon,
|
||||
restoreMainWindows,
|
||||
} from "./windows"
|
||||
import { createWslServersController } from "./wsl/servers"
|
||||
import { registerWslIpcHandlers } from "./wsl/ipc"
|
||||
import { spawnWslSidecar } from "./wsl/sidecar"
|
||||
import { migrate } from "./migrate"
|
||||
import { cleanupStoreFiles } from "./store-cleanup"
|
||||
import { startBackgroundCli } from "./background-cli"
|
||||
@@ -134,25 +132,10 @@ const main = Effect.gen(function* () {
|
||||
logger = initLogging()
|
||||
initCrashReporter()
|
||||
|
||||
const wslServers = createWslServersController(
|
||||
VERSION,
|
||||
async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
||||
})
|
||||
},
|
||||
{
|
||||
logger: {
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
},
|
||||
)
|
||||
const stopSidecars = async () => wslServers.stopAll()
|
||||
let stopWslServers = async () => {}
|
||||
const relaunch = () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars().finally(() => {
|
||||
void stopWslServers().finally(() => {
|
||||
app.relaunch()
|
||||
app.quit()
|
||||
})
|
||||
@@ -205,12 +188,12 @@ const main = Effect.gen(function* () {
|
||||
|
||||
app.on("before-quit", () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars()
|
||||
void stopWslServers()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars()
|
||||
void stopWslServers()
|
||||
})
|
||||
|
||||
app.on("child-process-gone", (_event, details) => {
|
||||
@@ -228,7 +211,7 @@ const main = Effect.gen(function* () {
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
setAppQuitting()
|
||||
void stopSidecars().finally(() => app.quit())
|
||||
void stopWslServers().finally(() => app.quit())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -253,7 +236,7 @@ const main = Effect.gen(function* () {
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
const updater = setupAutoUpdater(stopSidecars)
|
||||
const updater = setupAutoUpdater(() => stopWslServers())
|
||||
const menuDeps = {
|
||||
trigger: (id: string) => {
|
||||
const win = getLastFocusedWindow()
|
||||
@@ -293,7 +276,6 @@ const main = Effect.gen(function* () {
|
||||
},
|
||||
})
|
||||
registerUpdaterIpc(updater)
|
||||
registerWslIpcHandlers(wslServers)
|
||||
void updater.start()
|
||||
const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000)
|
||||
updateTimer.unref()
|
||||
@@ -311,16 +293,14 @@ const main = Effect.gen(function* () {
|
||||
useEnvProxy()
|
||||
|
||||
logger.log("starting v2 background service")
|
||||
const sidecar = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url: sidecar.url,
|
||||
username: sidecar.username,
|
||||
password: sidecar.password,
|
||||
})
|
||||
const background = yield* Effect.promise(() => startBackgroundCli(logger))
|
||||
stopWslServers = yield* Effect.promise(() => startWslServers(background))
|
||||
|
||||
if (process.platform === "win32") {
|
||||
void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error))
|
||||
}
|
||||
yield* Deferred.succeed(serverReady, {
|
||||
url: background.url,
|
||||
username: background.username,
|
||||
password: background.password,
|
||||
})
|
||||
|
||||
logger.log("loading task finished")
|
||||
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
|
||||
@@ -340,4 +320,41 @@ const main = Effect.gen(function* () {
|
||||
if (windows.length) createMenu(menuDeps)
|
||||
})
|
||||
|
||||
async function startWslServers(cli: { version: string; wslBuild?: { script: string; output: string } }) {
|
||||
if (process.platform !== "win32") {
|
||||
registerWslIpcHandlers()
|
||||
return async () => {}
|
||||
}
|
||||
|
||||
const { createWslServersController } = await import("./wsl/servers")
|
||||
const { spawnWslSidecar } = await import("./wsl/sidecar")
|
||||
const local = cli.wslBuild
|
||||
const controller = createWslServersController({
|
||||
cli: { version: cli.version },
|
||||
installCli: local
|
||||
? async (distro) => {
|
||||
const { buildLocalWslCli } = await import("./wsl/local")
|
||||
const { installWslCli } = await import("./wsl/runtime")
|
||||
await installWslCli(distro, {
|
||||
version: cli.version,
|
||||
binary: await buildLocalWslCli({ ...local, version: cli.version }),
|
||||
})
|
||||
}
|
||||
: undefined,
|
||||
spawnSidecar: async (distro) => {
|
||||
logger.log("spawning wsl sidecar", { distro })
|
||||
return spawnWslSidecar(distro, {
|
||||
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
|
||||
})
|
||||
},
|
||||
logger: {
|
||||
log: (message, meta) => logger.log(message, meta),
|
||||
error: (message, meta) => logger.error(message, meta),
|
||||
},
|
||||
})
|
||||
registerWslIpcHandlers(controller)
|
||||
controller.startConfiguredServers()
|
||||
return async () => controller.stopServers()
|
||||
}
|
||||
|
||||
Effect.runFork(main)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { app, ipcMain } from "electron"
|
||||
import type { IpcMainInvokeEvent } from "electron"
|
||||
import type { WslServersController } from "./servers"
|
||||
import { requireWslIpcString, requireWslIpcStrings } from "./policy"
|
||||
import type { WslServersState } from "../../preload/types"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export function registerWslIpcHandlers(controller: WslServersController) {
|
||||
if (process.platform !== "win32") {
|
||||
export function registerWslIpcHandlers(controller?: WslServersController) {
|
||||
if (!controller) {
|
||||
registerUnavailableWslIpcHandlers()
|
||||
return
|
||||
}
|
||||
@@ -67,6 +66,18 @@ export function registerWslIpcHandlers(controller: WslServersController) {
|
||||
)
|
||||
}
|
||||
|
||||
function requireWslIpcString(name: string, value: unknown) {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
function requireWslIpcStrings(name: string, value: unknown) {
|
||||
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
|
||||
const values = value.map((item) => requireWslIpcString(name, item))
|
||||
if (values.length) return values
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
function registerUnavailableWslIpcHandlers() {
|
||||
const unavailable = () => {
|
||||
throw new Error(nativeT("desktop.wsl.error.windowsOnly"))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import { promisify } from "node:util"
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export async function buildLocalWslCli(input: { version: string; script: string; output: string }) {
|
||||
const directory = await mkdtemp(join(tmpdir(), "opencode-wsl-cli-"))
|
||||
const root = join(dirname(input.script), "../../..")
|
||||
const packageManager = (JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { packageManager: string })
|
||||
.packageManager
|
||||
const target = `linux-${process.arch}`
|
||||
try {
|
||||
await execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
await execFileAsync(
|
||||
"bunx",
|
||||
[
|
||||
packageManager,
|
||||
input.script,
|
||||
`--target=opencode2-${target}`,
|
||||
"--skip-install",
|
||||
"--skip-web-ui",
|
||||
`--outdir=${directory}`,
|
||||
],
|
||||
{ cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true },
|
||||
)
|
||||
await copyFile(join(directory, `cli-${target}`, "bin", "opencode2"), input.output)
|
||||
return input.output
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { WslDistroProbe, WslOpencodeCheck, WslServerItem } from "../../preload/types"
|
||||
|
||||
export function wslServerIdToRestart(servers: WslServerItem[], distro: string) {
|
||||
return servers.find((item) => item.config.distro === distro)?.config.id
|
||||
}
|
||||
|
||||
export function clearWslDistroState(
|
||||
distroProbes: Record<string, WslDistroProbe>,
|
||||
opencodeChecks: Record<string, WslOpencodeCheck>,
|
||||
distro: string,
|
||||
) {
|
||||
const nextDistroProbes = { ...distroProbes }
|
||||
const nextOpencodeChecks = { ...opencodeChecks }
|
||||
delete nextDistroProbes[distro]
|
||||
delete nextOpencodeChecks[distro]
|
||||
return { distroProbes: nextDistroProbes, opencodeChecks: nextOpencodeChecks }
|
||||
}
|
||||
|
||||
export function wslTerminalArgs(distro?: string | null) {
|
||||
return ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])]
|
||||
}
|
||||
|
||||
export function requireWslIpcString(name: string, value: unknown) {
|
||||
if (typeof value === "string" && value.length > 0) return value
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
|
||||
export function requireWslIpcStrings(name: string, value: unknown) {
|
||||
if (!Array.isArray(value)) throw new Error(`Invalid ${name}`)
|
||||
const values = value.map((item) => requireWslIpcString(name, item))
|
||||
if (values.length > 0) return values
|
||||
throw new Error(`Invalid ${name}`)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types"
|
||||
import { wslTerminalArgs } from "./policy"
|
||||
import { parseCliVersion } from "../cli-version"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export type WslCommandLine = {
|
||||
@@ -31,6 +31,11 @@ export type RunWslOptions = {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type WslCliBuild = {
|
||||
version: string
|
||||
binary?: string
|
||||
}
|
||||
|
||||
const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
@@ -252,28 +257,34 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) {
|
||||
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
|
||||
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
|
||||
].join("; ")
|
||||
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
||||
const result = await runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
||||
requireSuccess(result, nativeT("desktop.wsl.error.installWsl"))
|
||||
}
|
||||
|
||||
export async function installWslDistro(name: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
export async function installWslDistro(distro: string, opts?: RunWslOptions) {
|
||||
const result = await runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
["--install", "-d", name, "--web-download", "--no-launch"],
|
||||
["--install", "-d", distro, "--web-download", "--no-launch"],
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
requireSuccess(result, nativeT("desktop.wsl.error.installDistro", { distro }))
|
||||
}
|
||||
|
||||
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
|
||||
return runInteractiveCommand(
|
||||
export async function installWslCli(distro: string, cli: WslCliBuild, opts?: RunWslOptions) {
|
||||
const result = await runInteractiveCommand(
|
||||
resolveSystem32Command("wsl.exe"),
|
||||
wslArgs(
|
||||
["bash", "-lc", `curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`],
|
||||
distro,
|
||||
),
|
||||
wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro),
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
requireSuccess(result, nativeT("desktop.wsl.error.installOpencode"))
|
||||
}
|
||||
|
||||
function wslCliInstallCommand(cli: WslCliBuild) {
|
||||
const installer = "curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s --"
|
||||
if (!cli.binary) return `${installer} --version ${shellEscape(cli.version)}`
|
||||
return `binary=$(wslpath -a ${shellEscape(cli.binary)}) && ${installer} --binary "$binary"`
|
||||
}
|
||||
|
||||
export async function probeWslDistro(name: string, opts?: RunWslOptions): Promise<WslDistroProbe> {
|
||||
@@ -307,11 +318,11 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
|
||||
export async function resolveWslCli(distro: string, opts?: RunWslOptions) {
|
||||
return firstLine(
|
||||
(
|
||||
await runWslSh(
|
||||
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',
|
||||
'if [ -x "$HOME/.opencode/bin/opencode2" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode2"; fi',
|
||||
distro,
|
||||
opts,
|
||||
)
|
||||
@@ -319,14 +330,15 @@ export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
|
||||
)
|
||||
}
|
||||
|
||||
export async function readWslCommandVersion(command: string, distro: string, opts?: RunWslOptions) {
|
||||
export async function readWslCliVersion(command: string, distro: string, opts?: RunWslOptions) {
|
||||
const result = await runWslSh(`${shellEscape(command)} --version 2>/dev/null || true`, distro, opts)
|
||||
return firstLine(result.stdout)
|
||||
const output = firstLine(result.stdout)
|
||||
return output ? parseCliVersion(output) : null
|
||||
}
|
||||
|
||||
export function openWslTerminal(distro?: string | null) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("cmd.exe", wslTerminalArgs(distro), {
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
@@ -386,6 +398,11 @@ export function summarize(value: string) {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function requireSuccess(result: WslCommandResult, fallback: string) {
|
||||
if (result.code === 0) return
|
||||
throw new Error(summarize(result.stderr || result.stdout) || fallback)
|
||||
}
|
||||
|
||||
export function shellEscape(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`
|
||||
}
|
||||
|
||||
@@ -1,153 +1,42 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
clearWslDistroState,
|
||||
requireWslIpcString,
|
||||
requireWslIpcStrings,
|
||||
wslServerIdToRestart,
|
||||
wslTerminalArgs,
|
||||
} from "./policy"
|
||||
import {
|
||||
expectOpencodeVersion,
|
||||
pendingRestartAfterWslInstall,
|
||||
pollWslHealth,
|
||||
wslServerIdsToStartOnInitialize,
|
||||
} from "./startup"
|
||||
import { createWslServersController, type WslServerConfig } from "./servers"
|
||||
import type { WslServerConfig } from "../../preload/types"
|
||||
import { createWslServersController } from "./servers"
|
||||
|
||||
type ControllerOptions = Parameters<typeof createWslServersController>[0]
|
||||
|
||||
let persistedServers: WslServerConfig[] = []
|
||||
let releaseOpencodeResolve: (() => void) | undefined
|
||||
|
||||
test("starts every configured WSL server on initialization", () => {
|
||||
expect(
|
||||
wslServerIdsToStartOnInitialize([
|
||||
{ id: "wsl:Debian", distro: "Debian" },
|
||||
{ id: "wsl:Ubuntu-24.04", distro: "Ubuntu-24.04" },
|
||||
]),
|
||||
).toEqual(["wsl:Debian", "wsl:Ubuntu-24.04"])
|
||||
})
|
||||
|
||||
test("rejects an update that did not install the desktop version", () => {
|
||||
expect(() => expectOpencodeVersion("1.16.2", "1.16.2")).not.toThrow()
|
||||
expect(() => expectOpencodeVersion("1.14.35", "1.16.2")).toThrow(
|
||||
"OpenCode update finished but Debian still reports 1.14.35; expected 1.16.2",
|
||||
)
|
||||
})
|
||||
|
||||
test("restarts an existing distro server after updating OpenCode", () => {
|
||||
expect(
|
||||
wslServerIdToRestart(
|
||||
[
|
||||
{
|
||||
config: { id: "wsl:Debian", distro: "Debian" },
|
||||
runtime: { kind: "ready", url: "", username: null, password: null },
|
||||
},
|
||||
],
|
||||
"Debian",
|
||||
),
|
||||
).toBe("wsl:Debian")
|
||||
expect(wslServerIdToRestart([], "Debian")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("clears cached distro probes when removing a WSL server", () => {
|
||||
expect(
|
||||
clearWslDistroState(
|
||||
{ Debian: { name: "Debian", canExecute: true, hasBash: true, hasCurl: true, error: null } },
|
||||
{
|
||||
Debian: {
|
||||
distro: "Debian",
|
||||
resolvedPath: "/home/luke/.opencode/bin/opencode",
|
||||
version: "1.16.2",
|
||||
expectedVersion: "1.16.2",
|
||||
matchesDesktop: true,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
"Debian",
|
||||
),
|
||||
).toEqual({ distroProbes: {}, opencodeChecks: {} })
|
||||
})
|
||||
|
||||
test("opens terminals for distro names containing spaces", () => {
|
||||
expect(wslTerminalArgs("Ubuntu Preview")).toEqual(["/c", "start", "", "wsl", "-d", "Ubuntu Preview"])
|
||||
})
|
||||
|
||||
test("stops health polling when sidecar startup settles", async () => {
|
||||
const abort = new AbortController()
|
||||
let checks = 0
|
||||
const polling = pollWslHealth(
|
||||
async () => {
|
||||
checks++
|
||||
return false
|
||||
},
|
||||
abort.signal,
|
||||
1,
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
abort.abort()
|
||||
await polling
|
||||
const settled = checks
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
expect(checks).toBe(settled)
|
||||
})
|
||||
|
||||
test("validates WSL IPC identifiers at the module boundary", () => {
|
||||
expect(requireWslIpcString("distro", "Debian")).toBe("Debian")
|
||||
expect(requireWslIpcStrings("distro", ["Debian", "Ubuntu"])).toEqual(["Debian", "Ubuntu"])
|
||||
expect(() => requireWslIpcString("distro", "")).toThrow("Invalid distro")
|
||||
expect(() => requireWslIpcString("server id", undefined)).toThrow("Invalid server id")
|
||||
expect(() => requireWslIpcStrings("distro", [])).toThrow("Invalid distro")
|
||||
})
|
||||
|
||||
test("derives a required Windows restart from the post-install runtime probe", () => {
|
||||
expect(pendingRestartAfterWslInstall({ available: false, version: null, error: "WSL unavailable" })).toBe(true)
|
||||
expect(pendingRestartAfterWslInstall({ available: true, version: "WSL version: 2.6.1", error: null })).toBe(false)
|
||||
})
|
||||
|
||||
test("ignores stale background OpenCode checks after removing a WSL server", async () => {
|
||||
test("installs and verifies the bundled CLI version", async () => {
|
||||
persistedServers = []
|
||||
releaseOpencodeResolve = undefined
|
||||
const installs: string[][] = []
|
||||
const controller = createWslServersController(
|
||||
"1.16.2",
|
||||
async () => ({
|
||||
listener: {
|
||||
stop: () => undefined,
|
||||
onExit: () => undefined,
|
||||
testControllerOptions({
|
||||
installCli: async (distro, cli) => {
|
||||
installs.push([distro, cli.version])
|
||||
},
|
||||
url: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
|
||||
}),
|
||||
testControllerOptions(),
|
||||
)
|
||||
|
||||
await controller.addServer("Debian")
|
||||
await waitFor(() => !!releaseOpencodeResolve)
|
||||
await controller.removeServer("wsl:Debian")
|
||||
releaseOpencodeResolve?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await controller.installOpencode("Debian")
|
||||
|
||||
expect(controller.getState().servers).toEqual([])
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
expect(installs).toEqual([["Debian", "0.0.0-next-16365"]])
|
||||
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores stale startup OpenCode checks after removing a WSL server", async () => {
|
||||
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
|
||||
releaseOpencodeResolve = undefined
|
||||
test("rejects a WSL CLI version that differs from the bundled version", async () => {
|
||||
persistedServers = []
|
||||
const controller = createWslServersController(
|
||||
"1.16.2",
|
||||
async () => new Promise<never>(() => undefined),
|
||||
testControllerOptions(),
|
||||
testControllerOptions({
|
||||
installCli: async () => undefined,
|
||||
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
|
||||
readCliVersion: async () => "0.0.0-next-older",
|
||||
}),
|
||||
)
|
||||
|
||||
await controller.initialize()
|
||||
await waitFor(() => !!releaseOpencodeResolve)
|
||||
await controller.removeServer("wsl:Debian")
|
||||
releaseOpencodeResolve?.()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(controller.getState().servers).toEqual([])
|
||||
expect(controller.getState().opencodeChecks).toEqual({})
|
||||
await expect(controller.installOpencode("Debian")).rejects.toThrow(
|
||||
"OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-16365",
|
||||
)
|
||||
})
|
||||
|
||||
test("probes addable distros in parallel before checking OpenCode", async () => {
|
||||
@@ -155,18 +44,20 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
|
||||
const started: string[] = []
|
||||
const release = new Map<string, () => void>()
|
||||
const opencode: string[] = []
|
||||
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
|
||||
...testControllerOptions(),
|
||||
probeDistro: async (distro) => {
|
||||
started.push(distro)
|
||||
await new Promise<void>((resolve) => release.set(distro, resolve))
|
||||
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
|
||||
},
|
||||
resolveOpencode: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
})
|
||||
const controller = createWslServersController(
|
||||
testControllerOptions({
|
||||
spawnSidecar: pendingSidecar,
|
||||
probeDistro: async (distro) => {
|
||||
started.push(distro)
|
||||
await new Promise<void>((resolve) => release.set(distro, resolve))
|
||||
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
|
||||
},
|
||||
resolveCli: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode2"
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const task = controller.probeAddable(["Debian", "Ubuntu"])
|
||||
await waitFor(() => started.length === 2)
|
||||
@@ -184,20 +75,22 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
|
||||
test("does not check OpenCode in addable distros that cannot execute commands", async () => {
|
||||
persistedServers = []
|
||||
const opencode: string[] = []
|
||||
const controller = createWslServersController("1.16.2", async () => new Promise<never>(() => undefined), {
|
||||
...testControllerOptions(),
|
||||
probeDistro: async (distro) => ({
|
||||
name: distro,
|
||||
canExecute: distro === "Debian",
|
||||
hasBash: distro === "Debian",
|
||||
hasCurl: distro === "Debian",
|
||||
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
|
||||
const controller = createWslServersController(
|
||||
testControllerOptions({
|
||||
spawnSidecar: pendingSidecar,
|
||||
probeDistro: async (distro) => ({
|
||||
name: distro,
|
||||
canExecute: distro === "Debian",
|
||||
hasBash: distro === "Debian",
|
||||
hasCurl: distro === "Debian",
|
||||
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
|
||||
}),
|
||||
resolveCli: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode2"
|
||||
},
|
||||
}),
|
||||
resolveOpencode: async (distro) => {
|
||||
opencode.push(distro)
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
await controller.probeAddable(["Debian", "Ubuntu"])
|
||||
|
||||
@@ -214,18 +107,24 @@ async function waitFor(check: () => boolean) {
|
||||
throw new Error("Timed out waiting for condition")
|
||||
}
|
||||
|
||||
function testControllerOptions() {
|
||||
function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions {
|
||||
return {
|
||||
cli: { version: "0.0.0-next-16365" },
|
||||
spawnSidecar: async () => ({
|
||||
stop: () => undefined,
|
||||
onExit: () => undefined,
|
||||
url: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
}),
|
||||
readServers: () => persistedServers,
|
||||
writeServers: (servers: WslServerConfig[]) => {
|
||||
persistedServers = servers
|
||||
},
|
||||
readCommandVersion: async () => "1.16.2",
|
||||
resolveOpencode: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseOpencodeResolve = resolve
|
||||
})
|
||||
return "/home/me/.opencode/bin/opencode"
|
||||
},
|
||||
readCliVersion: async () => "0.0.0-next-16365",
|
||||
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const pendingSidecar = async () => new Promise<never>(() => undefined)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type {
|
||||
WslDistroProbe,
|
||||
WslInstalledDistro,
|
||||
WslJob,
|
||||
WslOnlineDistro,
|
||||
WslOpencodeCheck,
|
||||
WslRuntimeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
@@ -13,25 +10,24 @@ import type {
|
||||
} from "../../preload/types"
|
||||
import { WSL_SERVERS_KEY } from "../store-keys"
|
||||
import { getStore } from "../store"
|
||||
import { expectOpencodeVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup"
|
||||
import { clearWslDistroState, wslServerIdToRestart } from "./policy"
|
||||
import { nativeT } from "../native-translations"
|
||||
import {
|
||||
installWslCli,
|
||||
installWslDistro,
|
||||
installWslOpencode,
|
||||
installWslRuntimeElevated,
|
||||
listInstalledWslDistros,
|
||||
listOnlineWslDistros,
|
||||
openWslTerminal,
|
||||
probeWslDistro,
|
||||
probeWslRuntime,
|
||||
readWslCommandVersion,
|
||||
resolveWslOpencode,
|
||||
summarize,
|
||||
readWslCliVersion,
|
||||
resolveWslCli,
|
||||
type WslCliBuild,
|
||||
} from "./runtime"
|
||||
|
||||
type RunningSidecar = {
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
stop: () => void
|
||||
onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
@@ -45,12 +41,15 @@ type ControllerLogger = {
|
||||
}
|
||||
|
||||
type WslServersControllerOptions = {
|
||||
cli: WslCliBuild
|
||||
spawnSidecar: SpawnSidecar
|
||||
logger?: ControllerLogger
|
||||
readServers?: () => WslServerConfig[]
|
||||
writeServers?: (servers: WslServerConfig[]) => void
|
||||
installCli?: typeof installWslCli
|
||||
probeDistro?: typeof probeWslDistro
|
||||
resolveOpencode?: typeof resolveWslOpencode
|
||||
readCommandVersion?: typeof readWslCommandVersion
|
||||
resolveCli?: typeof resolveWslCli
|
||||
readCliVersion?: typeof readWslCliVersion
|
||||
}
|
||||
|
||||
export type WslServersController = ReturnType<typeof createWslServersController>
|
||||
@@ -59,20 +58,13 @@ export function wslServerIdForDistro(distro: string) {
|
||||
return `wsl:${distro}`
|
||||
}
|
||||
|
||||
export function createWslServersController(
|
||||
appVersion: string,
|
||||
spawnSidecar: SpawnSidecar,
|
||||
options?: WslServersControllerOptions,
|
||||
) {
|
||||
export function createWslServersController(options: WslServersControllerOptions) {
|
||||
let state: WslServersState = initialState()
|
||||
const listeners = new Set<(event: WslServersEvent) => void>()
|
||||
const sidecars = new Map<string, RunningSidecar>()
|
||||
const startAttempts = new Map<string, number>()
|
||||
let jobAbort: AbortController | undefined
|
||||
const logger = options?.logger
|
||||
const readServers = options?.readServers ?? readPersistedServers
|
||||
const writeServers = options?.writeServers ?? writePersistedServers
|
||||
const probeDistro = options?.probeDistro ?? probeWslDistro
|
||||
const readServers = options.readServers ?? readPersistedServers
|
||||
const writeServers = options.writeServers ?? writePersistedServers
|
||||
const probeDistro = options.probeDistro ?? probeWslDistro
|
||||
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener({ type: "state", state })
|
||||
@@ -83,29 +75,11 @@ export function createWslServersController(
|
||||
emit()
|
||||
}
|
||||
|
||||
const persistServers = (servers: WslServerConfig[]) => {
|
||||
writeServers(servers)
|
||||
}
|
||||
|
||||
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
|
||||
const next = state.servers.map((item) => (item.config.id === id ? update(item) : item))
|
||||
setState({ servers: next })
|
||||
}
|
||||
|
||||
const beginJob = (job: WslJob): AbortController => {
|
||||
jobAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
jobAbort = abort
|
||||
setState({ job })
|
||||
return abort
|
||||
}
|
||||
|
||||
const endJob = (abort: AbortController) => {
|
||||
if (jobAbort !== abort) return
|
||||
jobAbort = undefined
|
||||
setState({ job: null })
|
||||
}
|
||||
|
||||
const refreshFromStore = () => {
|
||||
const persisted = readServers()
|
||||
const items: WslServerItem[] = persisted.map((config) => {
|
||||
@@ -122,7 +96,7 @@ export function createWslServersController(
|
||||
updateServer(id, (item) => ({ ...item, runtime }))
|
||||
}
|
||||
|
||||
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
|
||||
const setCliCheck = (distro: string, check: WslOpencodeCheck) => {
|
||||
setState({
|
||||
opencodeChecks: {
|
||||
...state.opencodeChecks,
|
||||
@@ -131,24 +105,24 @@ export function createWslServersController(
|
||||
})
|
||||
}
|
||||
|
||||
const checkOpencode = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
const resolved = await (options?.resolveOpencode ?? resolveWslOpencode)(distro, opts)
|
||||
const version = resolved
|
||||
? await (options?.readCommandVersion ?? readWslCommandVersion)(resolved, distro, opts)
|
||||
: null
|
||||
return opencodeCheck(distro, resolved, version, appVersion)
|
||||
const inspectCli = async (distro: string) => {
|
||||
const resolved = await (options.resolveCli ?? resolveWslCli)(distro)
|
||||
const version = resolved ? await (options.readCliVersion ?? readWslCliVersion)(resolved, distro) : null
|
||||
return cliCheck(distro, resolved, version, options.cli.version)
|
||||
}
|
||||
|
||||
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
|
||||
setOpencodeCheck(distro, await checkOpencode(distro, opts))
|
||||
const refreshCliCheck = async (distro: string) => {
|
||||
const check = await inspectCli(distro)
|
||||
setCliCheck(distro, check)
|
||||
return check
|
||||
}
|
||||
|
||||
const probeAddableDistros = async (distros: string[], opts?: { signal?: AbortSignal }) => {
|
||||
const probeAddableDistros = async (distros: string[]) => {
|
||||
const unique = [...new Set(distros)]
|
||||
const distroProbes = await Promise.all(
|
||||
unique
|
||||
.filter((distro) => !state.distroProbes[distro])
|
||||
.map(async (distro) => [distro, await probeDistro(distro, opts)] as const),
|
||||
.map(async (distro) => [distro, await probeDistro(distro)] as const),
|
||||
)
|
||||
if (distroProbes.length) {
|
||||
setState({ distroProbes: { ...state.distroProbes, ...Object.fromEntries(distroProbes) } })
|
||||
@@ -158,86 +132,37 @@ export function createWslServersController(
|
||||
unique
|
||||
.filter((distro) => distroProbeReady(state.distroProbes[distro]))
|
||||
.filter((distro) => !state.opencodeChecks[distro])
|
||||
.map(async (distro) => [distro, await checkOpencode(distro, opts)] as const),
|
||||
.map(async (distro) => [distro, await inspectCli(distro)] as const),
|
||||
)
|
||||
if (opencodeChecks.length) {
|
||||
setState({ opencodeChecks: { ...state.opencodeChecks, ...Object.fromEntries(opencodeChecks) } })
|
||||
}
|
||||
}
|
||||
|
||||
const hasServer = (id: string, distro: string) => {
|
||||
return state.servers.some((item) => item.config.id === id && item.config.distro === distro)
|
||||
const refreshCliCheckSafely = (id: string, distro: string) => {
|
||||
return refreshCliCheck(distro).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
options.logger?.error("wsl CLI check failed", { id, distro, message })
|
||||
})
|
||||
}
|
||||
|
||||
const refreshOpencodeCheckBackground = (id: string, distro: string) => {
|
||||
void checkOpencode(distro)
|
||||
.then((check) => {
|
||||
if (!hasServer(id, distro)) return
|
||||
setOpencodeCheck(distro, check)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", { id, distro, message })
|
||||
})
|
||||
const refreshCliChecks = async () => {
|
||||
await Promise.all(state.servers.map((item) => refreshCliCheckSafely(item.config.id, item.config.distro)))
|
||||
}
|
||||
|
||||
const refreshOpencodeChecks = async () => {
|
||||
await Promise.all(
|
||||
state.servers.map((item) =>
|
||||
checkOpencode(item.config.distro)
|
||||
.then((check) => {
|
||||
if (!hasServer(item.config.id, item.config.distro)) return
|
||||
setOpencodeCheck(item.config.distro, check)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
logger?.error("wsl opencode check failed", {
|
||||
id: item.config.id,
|
||||
distro: item.config.distro,
|
||||
message,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
|
||||
const [installed, online] = await Promise.all([listInstalledWslDistros(opts), listOnlineWslDistros(opts)])
|
||||
const refreshDistroLists = async () => {
|
||||
const [installed, online] = await Promise.all([listInstalledWslDistros(), listOnlineWslDistros()])
|
||||
return { installed, online }
|
||||
}
|
||||
|
||||
const nextStartAttempt = (id: string) => {
|
||||
const next = (startAttempts.get(id) ?? 0) + 1
|
||||
startAttempts.set(id, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const invalidateStartAttempt = (id: string) => {
|
||||
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const isCurrentStartAttempt = (id: string, attempt: number) => {
|
||||
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
|
||||
}
|
||||
|
||||
const startServer = async (id: string) => {
|
||||
const item = state.servers.find((x) => x.config.id === id)
|
||||
if (!item) return
|
||||
const attempt = nextStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
stopServer(id)
|
||||
setRuntime(id, { kind: "starting" })
|
||||
logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
|
||||
options.logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
|
||||
try {
|
||||
const sidecar = await spawnSidecar(item.config.distro)
|
||||
if (!isCurrentStartAttempt(id, attempt)) {
|
||||
try {
|
||||
sidecar.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors for stale sidecars
|
||||
}
|
||||
return
|
||||
}
|
||||
const sidecar = await options.spawnSidecar(item.config.distro)
|
||||
sidecars.set(id, sidecar)
|
||||
setRuntime(id, {
|
||||
kind: "ready",
|
||||
@@ -245,51 +170,35 @@ export function createWslServersController(
|
||||
username: sidecar.username,
|
||||
password: sidecar.password,
|
||||
})
|
||||
sidecar.listener.onExit((code, signal) => {
|
||||
sidecar.onExit((code, signal) => {
|
||||
if (sidecars.get(id) !== sidecar) return
|
||||
sidecars.delete(id)
|
||||
const message = startupFailure(code, signal)
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
||||
options.logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
||||
})
|
||||
refreshOpencodeCheckBackground(id, item.config.distro)
|
||||
logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
||||
void refreshCliCheckSafely(id, item.config.distro)
|
||||
options.logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
// Without this, an Ubuntu-style silent failure leaves no trace in
|
||||
// main.log — the controller captures the message in its state but
|
||||
// nothing surfaces unless the user opens the WSL servers dialog.
|
||||
logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
|
||||
options.logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
|
||||
}
|
||||
}
|
||||
|
||||
const stopServerInternal = async (id: string) => {
|
||||
const stopServer = (id: string) => {
|
||||
const existing = sidecars.get(id)
|
||||
if (!existing) return
|
||||
sidecars.delete(id)
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors
|
||||
}
|
||||
existing.stop()
|
||||
}
|
||||
|
||||
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
|
||||
const abort = beginJob(job)
|
||||
const runJob = async <T>(job: WslJob, runner: () => Promise<T>) => {
|
||||
setState({ job })
|
||||
try {
|
||||
const value = await runner(abort)
|
||||
endJob(abort)
|
||||
return value
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
endJob(abort)
|
||||
return undefined
|
||||
}
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
endJob(abort)
|
||||
throw err
|
||||
return await runner()
|
||||
} finally {
|
||||
setState({ job: null })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,15 +211,15 @@ export function createWslServersController(
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
|
||||
async initialize() {
|
||||
startConfiguredServers() {
|
||||
refreshFromStore()
|
||||
void refreshOpencodeChecks()
|
||||
for (const id of wslServerIdsToStartOnInitialize(state.servers.map((item) => item.config))) void startServer(id)
|
||||
void refreshCliChecks()
|
||||
state.servers.forEach((item) => void startServer(item.config.id))
|
||||
},
|
||||
|
||||
async probeRuntime() {
|
||||
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
|
||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
||||
await runJob({ kind: "runtime", startedAt: Date.now() }, async () => {
|
||||
const runtime = await probeWslRuntime()
|
||||
setState({
|
||||
runtime,
|
||||
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
|
||||
@@ -319,62 +228,47 @@ export function createWslServersController(
|
||||
},
|
||||
|
||||
async refreshDistros() {
|
||||
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
|
||||
setState(await refreshDistroLists({ signal: abort.signal }))
|
||||
await runJob({ kind: "distros", startedAt: Date.now() }, async () => {
|
||||
setState(await refreshDistroLists())
|
||||
})
|
||||
},
|
||||
|
||||
async installWsl() {
|
||||
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslRuntimeElevated({ signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
const message = summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installWsl")
|
||||
throw new Error(message)
|
||||
}
|
||||
const runtime = await probeWslRuntime({ signal: abort.signal })
|
||||
setState({ runtime, pendingRestart: pendingRestartAfterWslInstall(runtime) })
|
||||
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async () => {
|
||||
await installWslRuntimeElevated()
|
||||
const runtime = await probeWslRuntime()
|
||||
setState({ runtime, pendingRestart: !runtime.available })
|
||||
})
|
||||
},
|
||||
|
||||
async installDistro(name: string) {
|
||||
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslDistro(name, { signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
const message =
|
||||
summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installDistro", { distro: name })
|
||||
throw new Error(message)
|
||||
}
|
||||
const distros = await refreshDistroLists({ signal: abort.signal })
|
||||
const probe = await probeDistro(name, { signal: abort.signal })
|
||||
async installDistro(distro: string) {
|
||||
await runJob({ kind: "install-distro", distro, startedAt: Date.now() }, async () => {
|
||||
await installWslDistro(distro)
|
||||
const distros = await refreshDistroLists()
|
||||
const probe = await probeDistro(distro)
|
||||
setState({
|
||||
...distros,
|
||||
distroProbes: { ...state.distroProbes, [name]: probe },
|
||||
distroProbes: { ...state.distroProbes, [distro]: probe },
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async probeAddable(distros: string[]) {
|
||||
if (!distros.length) return
|
||||
await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, async (abort) => {
|
||||
await probeAddableDistros(distros, { signal: abort.signal })
|
||||
})
|
||||
await runJob({ kind: "probe-addable", distros, startedAt: Date.now() }, () => probeAddableDistros(distros))
|
||||
},
|
||||
|
||||
async installOpencode(name: string) {
|
||||
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
const result = await installWslOpencode(appVersion, name, { signal: abort.signal })
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode"))
|
||||
}
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal })
|
||||
expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name)
|
||||
const id = wslServerIdToRestart(state.servers, name)
|
||||
async installOpencode(distro: string) {
|
||||
await runJob({ kind: "install-opencode", distro, startedAt: Date.now() }, async () => {
|
||||
await (options.installCli ?? installWslCli)(distro, options.cli)
|
||||
requireMatchingCli(await refreshCliCheck(distro), options.cli.version)
|
||||
const id = state.servers.find((item) => item.config.distro === distro)?.config.id
|
||||
if (id) await startServer(id)
|
||||
})
|
||||
},
|
||||
|
||||
async openTerminal(name: string) {
|
||||
await openWslTerminal(name)
|
||||
async openTerminal(distro: string) {
|
||||
await openWslTerminal(distro)
|
||||
},
|
||||
|
||||
async addServer(distro: string): Promise<WslServerConfig> {
|
||||
@@ -386,7 +280,7 @@ export function createWslServersController(
|
||||
id,
|
||||
distro,
|
||||
}
|
||||
persistServers([...readServers(), config])
|
||||
writeServers([...readServers(), config])
|
||||
setState({
|
||||
servers: [...state.servers, { config, runtime: { kind: "starting" } }],
|
||||
})
|
||||
@@ -396,27 +290,19 @@ export function createWslServersController(
|
||||
|
||||
async removeServer(id: string) {
|
||||
const distro = state.servers.find((item) => item.config.id === id)?.config.distro
|
||||
invalidateStartAttempt(id)
|
||||
await stopServerInternal(id)
|
||||
stopServer(id)
|
||||
const remaining = readServers().filter((item) => item.id !== id)
|
||||
persistServers(remaining)
|
||||
writeServers(remaining)
|
||||
setState({
|
||||
servers: state.servers.filter((item) => item.config.id !== id),
|
||||
...(distro ? clearWslDistroState(state.distroProbes, state.opencodeChecks, distro) : {}),
|
||||
...(distro ? removeDistroState(state, distro) : {}),
|
||||
})
|
||||
},
|
||||
|
||||
startServer,
|
||||
|
||||
stopAll() {
|
||||
for (const item of state.servers) invalidateStartAttempt(item.config.id)
|
||||
for (const existing of sidecars.values()) {
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
stopServers() {
|
||||
sidecars.forEach((sidecar) => sidecar.stop())
|
||||
sidecars.clear()
|
||||
},
|
||||
}
|
||||
@@ -464,7 +350,7 @@ function normalizePersistedServer(value: unknown): WslServerConfig[] {
|
||||
]
|
||||
}
|
||||
|
||||
function opencodeCheck(
|
||||
function cliCheck(
|
||||
distro: string,
|
||||
resolvedPath: string | null,
|
||||
version: string | null,
|
||||
@@ -500,6 +386,25 @@ function opencodeCheck(
|
||||
}
|
||||
}
|
||||
|
||||
function requireMatchingCli(check: WslOpencodeCheck, expected: string) {
|
||||
if (check.version === expected) return
|
||||
throw new Error(
|
||||
nativeT("desktop.wsl.error.updateVersion", {
|
||||
distro: check.distro,
|
||||
installed: check.version ?? nativeT("desktop.wsl.error.noVersion"),
|
||||
expected,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function removeDistroState(state: WslServersState, distro: string) {
|
||||
const distroProbes = { ...state.distroProbes }
|
||||
const opencodeChecks = { ...state.opencodeChecks }
|
||||
delete distroProbes[distro]
|
||||
delete opencodeChecks[distro]
|
||||
return { distroProbes, opencodeChecks }
|
||||
}
|
||||
|
||||
function distroProbeReady(probe: WslDistroProbe | undefined) {
|
||||
return !!probe?.canExecute && probe.hasBash && probe.hasCurl
|
||||
}
|
||||
@@ -507,17 +412,3 @@ function distroProbeReady(probe: WslDistroProbe | undefined) {
|
||||
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
|
||||
return nativeT("desktop.wsl.error.serverExited", { code: code ?? "null", signal: signal ?? "null" })
|
||||
}
|
||||
|
||||
// Re-export types used by callers
|
||||
export type {
|
||||
WslInstalledDistro,
|
||||
WslOnlineDistro,
|
||||
WslRuntimeCheck,
|
||||
WslDistroProbe,
|
||||
WslOpencodeCheck,
|
||||
WslServerConfig,
|
||||
WslServerItem,
|
||||
WslServerRuntime,
|
||||
WslServersEvent,
|
||||
WslServersState,
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:net"
|
||||
import { app } from "electron"
|
||||
import { checkHealth } from "../server"
|
||||
import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime"
|
||||
import { pollWslHealth } from "./startup"
|
||||
import { type WslCommandLine, resolveWslCli, shellEscape, wslArgs } from "./runtime"
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export type WslSidecar = {
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
stop: () => void
|
||||
onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
@@ -18,7 +18,7 @@ export async function spawnWslSidecar(
|
||||
distro: string,
|
||||
opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {},
|
||||
): Promise<WslSidecar> {
|
||||
const opencode = await resolveWslOpencode(distro)
|
||||
const opencode = await resolveWslCli(distro)
|
||||
if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro }))
|
||||
|
||||
const port = await allocatePort()
|
||||
@@ -80,16 +80,21 @@ export async function spawnWslSidecar(
|
||||
startup.abort()
|
||||
})
|
||||
return {
|
||||
listener: {
|
||||
stop: () => child.kill(),
|
||||
onExit: (cb) => child.once("exit", cb),
|
||||
},
|
||||
stop: () => child.kill(),
|
||||
onExit: (cb) => child.once("exit", cb),
|
||||
url,
|
||||
username,
|
||||
password,
|
||||
}
|
||||
}
|
||||
|
||||
async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal) {
|
||||
while (!signal.aborted) {
|
||||
if (await check()) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
function allocatePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { nativeT } from "../native-translations"
|
||||
|
||||
export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) {
|
||||
return servers.map((server) => server.id)
|
||||
}
|
||||
|
||||
export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") {
|
||||
if (installed === expected) return
|
||||
throw new Error(
|
||||
nativeT("desktop.wsl.error.updateVersion", {
|
||||
distro,
|
||||
installed: installed ?? nativeT("desktop.wsl.error.noVersion"),
|
||||
expected,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const pendingRestartAfterWslInstall = (runtime: { available: boolean }) => !runtime.available
|
||||
|
||||
export async function pollWslHealth(check: () => Promise<boolean>, signal: AbortSignal, interval = 100) {
|
||||
while (!signal.aborted) {
|
||||
if (await check()) return
|
||||
await abortableDelay(interval, signal)
|
||||
}
|
||||
}
|
||||
|
||||
function abortableDelay(duration: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const done = () => {
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
const timeout = setTimeout(done, duration)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -2089,7 +2089,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
|
||||
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
|
||||
const theme = useTheme("elevated")
|
||||
const next = createMemo(() => props.prompts[0]?.text)
|
||||
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
|
||||
|
||||
return (
|
||||
<box
|
||||
|
||||
Reference in New Issue
Block a user