summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop-electron/src/main/cli.ts
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-03-04 15:12:34 +0800
committerGitHub <[email protected]>2026-03-04 15:12:34 +0800
commit5cf235fa6cf7b4c890c68f8ff68a96fcae992abf (patch)
tree25fdfd8ce95ad048fb097822995dcf060e8d6d8b /packages/desktop-electron/src/main/cli.ts
parente4f0825c56300286ec0aa82b1006e4006a17e1e1 (diff)
downloadopencode-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.ts279
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
+}