From 506dd758187c93bae028fbe7bbfd6ed75772ee1b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 1 Apr 2026 15:01:44 +0800 Subject: [PATCH] electron: port mergeShellEnv logic from tauri (#20192) --- packages/desktop-electron/src/main/cli.ts | 17 ++-- .../src/main/shell-env.test.ts | 43 +++++++++ .../desktop-electron/src/main/shell-env.ts | 88 +++++++++++++++++++ 3 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 packages/desktop-electron/src/main/shell-env.test.ts create mode 100644 packages/desktop-electron/src/main/shell-env.ts diff --git a/packages/desktop-electron/src/main/cli.ts b/packages/desktop-electron/src/main/cli.ts index f2d918bd21..ebaf89fda9 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) { 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) { 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) { +function buildCommand(args: string, env: Record, 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) { } 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) { 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 0000000000..cfe88277ea --- /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 0000000000..3000848212 --- /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 } | { type: "Timeout" } | { type: "Unavailable" } + +export function getUserShell() { + return process.env.SHELL || "/bin/sh" +} + +export function parseShellEnv(out: Buffer) { + const env: Record = {} + 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 | null, env: Record) { + return { + ...(shell || {}), + ...env, + } +}