summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrendan Allan <[email protected]>2026-04-01 15:01:44 +0800
committerGitHub <[email protected]>2026-04-01 07:01:44 +0000
commit506dd758187c93bae028fbe7bbfd6ed75772ee1b (patch)
treebaae9537e0e123d70a639adbbe3cf34b76d41bd0
parentc8ecd640220331ce7695d72ea8c618dd8909eab1 (diff)
downloadopencode-506dd758187c93bae028fbe7bbfd6ed75772ee1b.tar.gz
opencode-506dd758187c93bae028fbe7bbfd6ed75772ee1b.zip
electron: port mergeShellEnv logic from tauri (#20192)
-rw-r--r--packages/desktop-electron/src/main/cli.ts17
-rw-r--r--packages/desktop-electron/src/main/shell-env.test.ts43
-rw-r--r--packages/desktop-electron/src/main/shell-env.ts88
3 files changed, 141 insertions, 7 deletions
diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts
index f2d918bd2..ebaf89fda 100644
--- a/packages/desktop-electron/src/main/cli.ts
+++ b/packages/desktop-electron/src/main/cli.ts
@@ -9,6 +9,7 @@ import { app } from "electron"
import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants"
+import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin"
@@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
)
- const envs = {
+ const env = {
...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
@@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
XDG_STATE_HOME: app.getPath("userData"),
...extraEnv,
}
+ const shell = process.platform === "win32" ? null : getUserShell()
+ const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
- const { cmd, cmdArgs } = buildCommand(args, envs)
+ const { cmd, cmdArgs } = buildCommand(args, envs, shell)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
@@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
return false
}
-function buildCommand(args: string, env: Record<string, string>) {
+function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`)
const version = app.getVersion()
@@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
}
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] }
+ const user = shell || getUserShell()
+ const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
+ console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
+ return { cmd: user, cmdArgs: ["-l", "-c", line] }
}
function envPrefix(env: Record<string, string>) {
diff --git a/packages/desktop-electron/src/main/shell-env.test.ts b/packages/desktop-electron/src/main/shell-env.test.ts
new file mode 100644
index 000000000..cfe88277e
--- /dev/null
+++ b/packages/desktop-electron/src/main/shell-env.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+
+import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
+
+describe("shell env", () => {
+ test("parseShellEnv supports null-delimited pairs", () => {
+ const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
+
+ expect(env.PATH).toBe("/usr/bin:/bin")
+ expect(env.FOO).toBe("bar=baz")
+ })
+
+ test("parseShellEnv ignores invalid entries", () => {
+ const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
+
+ expect(Object.keys(env).length).toBe(1)
+ expect(env.OK).toBe("1")
+ })
+
+ test("mergeShellEnv keeps explicit overrides", () => {
+ const env = mergeShellEnv(
+ {
+ PATH: "/shell/path",
+ HOME: "/tmp/home",
+ },
+ {
+ PATH: "/desktop/path",
+ OPENCODE_CLIENT: "desktop",
+ },
+ )
+
+ expect(env.PATH).toBe("/desktop/path")
+ expect(env.HOME).toBe("/tmp/home")
+ expect(env.OPENCODE_CLIENT).toBe("desktop")
+ })
+
+ test("isNushell handles path and binary name", () => {
+ expect(isNushell("nu")).toBe(true)
+ expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
+ expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
+ expect(isNushell("/bin/zsh")).toBe(false)
+ })
+})
diff --git a/packages/desktop-electron/src/main/shell-env.ts b/packages/desktop-electron/src/main/shell-env.ts
new file mode 100644
index 000000000..300084821
--- /dev/null
+++ b/packages/desktop-electron/src/main/shell-env.ts
@@ -0,0 +1,88 @@
+import { spawnSync } from "node:child_process"
+import { basename } from "node:path"
+
+const SHELL_ENV_TIMEOUT = 5_000
+
+type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
+
+export function getUserShell() {
+ return process.env.SHELL || "/bin/sh"
+}
+
+export function parseShellEnv(out: Buffer) {
+ const env: Record<string, string> = {}
+ for (const line of out.toString("utf8").split("\0")) {
+ if (!line) continue
+ const ix = line.indexOf("=")
+ if (ix <= 0) continue
+ env[line.slice(0, ix)] = line.slice(ix + 1)
+ }
+ return env
+}
+
+function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
+ const out = spawnSync(shell, [mode, "-c", "env -0"], {
+ stdio: ["ignore", "pipe", "ignore"],
+ timeout: SHELL_ENV_TIMEOUT,
+ windowsHide: true,
+ })
+
+ const err = out.error as NodeJS.ErrnoException | undefined
+ if (err) {
+ if (err.code === "ETIMEDOUT") return { type: "Timeout" }
+ console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
+ return { type: "Unavailable" }
+ }
+
+ if (out.status !== 0) {
+ console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
+ return { type: "Unavailable" }
+ }
+
+ const env = parseShellEnv(out.stdout)
+ if (Object.keys(env).length === 0) {
+ console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
+ return { type: "Unavailable" }
+ }
+
+ return { type: "Loaded", value: env }
+}
+
+export function isNushell(shell: string) {
+ const name = basename(shell).toLowerCase()
+ const raw = shell.toLowerCase()
+ return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
+}
+
+export function loadShellEnv(shell: string) {
+ if (isNushell(shell)) {
+ console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
+ return null
+ }
+
+ const interactive = probeShellEnv(shell, "-il")
+ if (interactive.type === "Loaded") {
+ console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
+ return interactive.value
+ }
+ if (interactive.type === "Timeout") {
+ console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
+ return null
+ }
+
+ const login = probeShellEnv(shell, "-l")
+ if (login.type === "Loaded") {
+ console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
+ return login.value
+ }
+
+ console.warn(`[cli] Falling back to app environment: ${shell}`)
+ return null
+}
+
+export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
+ return {
+ ...(shell || {}),
+ ...env,
+ }
+}