diff options
| author | Brendan Allan <[email protected]> | 2026-03-04 15:12:34 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-04 15:12:34 +0800 |
| commit | 5cf235fa6cf7b4c890c68f8ff68a96fcae992abf (patch) | |
| tree | 25fdfd8ce95ad048fb097822995dcf060e8d6d8b /packages/desktop-electron/src/main/cli.ts | |
| parent | e4f0825c56300286ec0aa82b1006e4006a17e1e1 (diff) | |
| download | opencode-5cf235fa6cf7b4c890c68f8ff68a96fcae992abf.tar.gz opencode-5cf235fa6cf7b4c890c68f8ff68a96fcae992abf.zip | |
desktop: add electron version (#15663)
Diffstat (limited to 'packages/desktop-electron/src/main/cli.ts')
| -rw-r--r-- | packages/desktop-electron/src/main/cli.ts | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts new file mode 100644 index 000000000..e338d3913 --- /dev/null +++ b/packages/desktop-electron/src/main/cli.ts @@ -0,0 +1,279 @@ +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<Config | null> { + const { events } = spawnCommand("debug config", {}) + let output = "" + + await new Promise<void>((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<string> { + 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<string>((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"]).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<string, string>) { + 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: true, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + }) + console.log(`[cli] Spawned process with PID: ${child.pid}`) + + const events = new EventEmitter() + const exit = new Promise<TerminatedPayload>((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<string, string>) { + 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<string, string>) { + 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 +} |
