import { execFileSync, spawn } from "node:child_process" import { EventEmitter } from "node:events" import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { dirname, join } from "node:path" import readline from "node:readline" import { fileURLToPath } from "node:url" import { app } from "electron" import treeKill from "tree-kill" import { WSL_ENABLED_KEY } from "./constants" import { store } from "./store" const CLI_INSTALL_DIR = ".opencode/bin" const CLI_BINARY_NAME = "opencode" export type ServerConfig = { hostname?: string port?: number } export type Config = { server?: ServerConfig } export type TerminatedPayload = { code: number | null; signal: number | null } export type CommandEvent = | { type: "stdout"; value: string } | { type: "stderr"; value: string } | { type: "error"; value: string } | { type: "terminated"; value: TerminatedPayload } | { type: "sqlite"; value: SqliteMigrationProgress } export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } export type CommandChild = { kill: () => void } const root = dirname(fileURLToPath(import.meta.url)) export function getSidecarPath() { const suffix = process.platform === "win32" ? ".exe" : "" const path = app.isPackaged ? join(process.resourcesPath, `opencode-cli${suffix}`) : join(root, "../../resources", `opencode-cli${suffix}`) console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`) return path } export async function getConfig(): Promise { const { events } = spawnCommand("debug config", {}) let output = "" await new Promise((resolve) => { events.on("stdout", (line: string) => { output += line }) events.on("stderr", (line: string) => { output += line }) events.on("terminated", () => resolve()) events.on("error", () => resolve()) }) try { return JSON.parse(output) as Config } catch { return null } } export async function installCli(): Promise { if (process.platform === "win32") { throw new Error("CLI installation is only supported on macOS & Linux") } const sidecar = getSidecarPath() const scriptPath = join(app.getAppPath(), "install") const script = readFileSync(scriptPath, "utf8") const tempScript = join(tmpdir(), "opencode-install.sh") writeFileSync(tempScript, script, "utf8") chmodSync(tempScript, 0o755) const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" }) return await new Promise((resolve, reject) => { cmd.on("exit", (code: number | null) => { try { unlinkSync(tempScript) } catch {} if (code === 0) { const installPath = getCliInstallPath() if (installPath) return resolve(installPath) return reject(new Error("Could not determine install path")) } reject(new Error("Install script failed")) }) }) } export function syncCli() { if (!app.isPackaged) return const installPath = getCliInstallPath() if (!installPath) return let version = "" try { version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim() } catch { return } const cli = parseVersion(version) const appVersion = parseVersion(app.getVersion()) if (!cli || !appVersion) return if (compareVersions(cli, appVersion) >= 0) return void installCli().catch(() => undefined) } export function serve(hostname: string, port: number, password: string) { const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}` const env = { OPENCODE_SERVER_USERNAME: "opencode", OPENCODE_SERVER_PASSWORD: password, } return spawnCommand(args, env) } export function spawnCommand(args: string, extraEnv: Record) { console.log(`[cli] Spawning command with args: ${args}`) const base = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), ) const envs = { ...base, OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_CLIENT: "desktop", XDG_STATE_HOME: app.getPath("userData"), ...extraEnv, } const { cmd, cmdArgs } = buildCommand(args, envs) console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`) const child = spawn(cmd, cmdArgs, { env: envs, detached: process.platform !== "win32", windowsHide: true, stdio: ["ignore", "pipe", "pipe"], }) console.log(`[cli] Spawned process with PID: ${child.pid}`) const events = new EventEmitter() const exit = new Promise((resolve) => { child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => { console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`) resolve({ code: code ?? null, signal: null }) }) child.on("error", (error: Error) => { console.error(`[cli] Process error: ${error.message}`) events.emit("error", error.message) }) }) const stdout = child.stdout const stderr = child.stderr if (stdout) { readline.createInterface({ input: stdout }).on("line", (line: string) => { if (handleSqliteProgress(events, line)) return events.emit("stdout", `${line}\n`) }) } if (stderr) { readline.createInterface({ input: stderr }).on("line", (line: string) => { if (handleSqliteProgress(events, line)) return events.emit("stderr", `${line}\n`) }) } exit.then((payload) => { events.emit("terminated", payload) }) const kill = () => { if (!child.pid) return treeKill(child.pid) } return { events, child: { kill }, exit } } function handleSqliteProgress(events: EventEmitter, line: string) { const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null if (!stripped) return false if (stripped === "done") { events.emit("sqlite", { type: "Done" }) return true } const value = Number.parseInt(stripped, 10) if (!Number.isNaN(value)) { events.emit("sqlite", { type: "InProgress", value }) return true } return false } function buildCommand(args: string, env: Record) { if (process.platform === "win32" && isWslEnabled()) { console.log(`[cli] Using WSL mode`) const version = app.getVersion() const script = [ "set -e", 'BIN="$HOME/.opencode/bin/opencode"', 'if [ ! -x "$BIN" ]; then', ` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`, "fi", `${envPrefix(env)} exec "$BIN" ${args}`, ].join("\n") return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] } } if (process.platform === "win32") { const sidecar = getSidecarPath() console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`) return { cmd: sidecar, cmdArgs: args.split(" ") } } const sidecar = getSidecarPath() const shell = process.env.SHELL || "/bin/sh" const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}` console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`) return { cmd: shell, cmdArgs: ["-l", "-c", line] } } function envPrefix(env: Record) { const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`) return entries.join(" ") } function shellEscape(input: string) { if (!input) return "''" return `'${input.replace(/'/g, `'"'"'`)}'` } function getCliInstallPath() { const home = process.env.HOME if (!home) return null return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME) } function isWslEnabled() { return store.get(WSL_ENABLED_KEY) === true } function parseVersion(value: string) { const parts = value .replace(/^v/, "") .split(".") .map((part) => Number.parseInt(part, 10)) if (parts.some((part) => Number.isNaN(part))) return null return parts } function compareVersions(a: number[], b: number[]) { const len = Math.max(a.length, b.length) for (let i = 0; i < len; i += 1) { const left = a[i] ?? 0 const right = b[i] ?? 0 if (left > right) return 1 if (left < right) return -1 } return 0 }