Skip to content

Commit 506dd75

Browse files
authored
electron: port mergeShellEnv logic from tauri (#20192)
1 parent c8ecd64 commit 506dd75

3 files changed

Lines changed: 141 additions & 7 deletions

File tree

packages/desktop-electron/src/main/cli.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { app } from "electron"
99
import treeKill from "tree-kill"
1010

1111
import { WSL_ENABLED_KEY } from "./constants"
12+
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
1213
import { store } from "./store"
1314

1415
const CLI_INSTALL_DIR = ".opencode/bin"
@@ -135,16 +136,18 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
135136
const base = Object.fromEntries(
136137
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
137138
)
138-
const envs = {
139+
const env = {
139140
...base,
140141
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
141142
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
142143
OPENCODE_CLIENT: "desktop",
143144
XDG_STATE_HOME: app.getPath("userData"),
144145
...extraEnv,
145146
}
147+
const shell = process.platform === "win32" ? null : getUserShell()
148+
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
146149

147-
const { cmd, cmdArgs } = buildCommand(args, envs)
150+
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
148151
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
149152
const child = spawn(cmd, cmdArgs, {
150153
env: envs,
@@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
210213
return false
211214
}
212215

213-
function buildCommand(args: string, env: Record<string, string>) {
216+
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
214217
if (process.platform === "win32" && isWslEnabled()) {
215218
console.log(`[cli] Using WSL mode`)
216219
const version = app.getVersion()
@@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
233236
}
234237

235238
const sidecar = getSidecarPath()
236-
const shell = process.env.SHELL || "/bin/sh"
237-
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
238-
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
239-
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
239+
const user = shell || getUserShell()
240+
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
241+
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
242+
return { cmd: user, cmdArgs: ["-l", "-c", line] }
240243
}
241244

242245
function envPrefix(env: Record<string, string>) {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, test } from "bun:test"
2+
3+
import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
4+
5+
describe("shell env", () => {
6+
test("parseShellEnv supports null-delimited pairs", () => {
7+
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
8+
9+
expect(env.PATH).toBe("/usr/bin:/bin")
10+
expect(env.FOO).toBe("bar=baz")
11+
})
12+
13+
test("parseShellEnv ignores invalid entries", () => {
14+
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
15+
16+
expect(Object.keys(env).length).toBe(1)
17+
expect(env.OK).toBe("1")
18+
})
19+
20+
test("mergeShellEnv keeps explicit overrides", () => {
21+
const env = mergeShellEnv(
22+
{
23+
PATH: "/shell/path",
24+
HOME: "/tmp/home",
25+
},
26+
{
27+
PATH: "/desktop/path",
28+
OPENCODE_CLIENT: "desktop",
29+
},
30+
)
31+
32+
expect(env.PATH).toBe("/desktop/path")
33+
expect(env.HOME).toBe("/tmp/home")
34+
expect(env.OPENCODE_CLIENT).toBe("desktop")
35+
})
36+
37+
test("isNushell handles path and binary name", () => {
38+
expect(isNushell("nu")).toBe(true)
39+
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
40+
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
41+
expect(isNushell("/bin/zsh")).toBe(false)
42+
})
43+
})
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { spawnSync } from "node:child_process"
2+
import { basename } from "node:path"
3+
4+
const SHELL_ENV_TIMEOUT = 5_000
5+
6+
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
7+
8+
export function getUserShell() {
9+
return process.env.SHELL || "/bin/sh"
10+
}
11+
12+
export function parseShellEnv(out: Buffer) {
13+
const env: Record<string, string> = {}
14+
for (const line of out.toString("utf8").split("\0")) {
15+
if (!line) continue
16+
const ix = line.indexOf("=")
17+
if (ix <= 0) continue
18+
env[line.slice(0, ix)] = line.slice(ix + 1)
19+
}
20+
return env
21+
}
22+
23+
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
24+
const out = spawnSync(shell, [mode, "-c", "env -0"], {
25+
stdio: ["ignore", "pipe", "ignore"],
26+
timeout: SHELL_ENV_TIMEOUT,
27+
windowsHide: true,
28+
})
29+
30+
const err = out.error as NodeJS.ErrnoException | undefined
31+
if (err) {
32+
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
33+
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
34+
return { type: "Unavailable" }
35+
}
36+
37+
if (out.status !== 0) {
38+
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
39+
return { type: "Unavailable" }
40+
}
41+
42+
const env = parseShellEnv(out.stdout)
43+
if (Object.keys(env).length === 0) {
44+
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
45+
return { type: "Unavailable" }
46+
}
47+
48+
return { type: "Loaded", value: env }
49+
}
50+
51+
export function isNushell(shell: string) {
52+
const name = basename(shell).toLowerCase()
53+
const raw = shell.toLowerCase()
54+
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
55+
}
56+
57+
export function loadShellEnv(shell: string) {
58+
if (isNushell(shell)) {
59+
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
60+
return null
61+
}
62+
63+
const interactive = probeShellEnv(shell, "-il")
64+
if (interactive.type === "Loaded") {
65+
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
66+
return interactive.value
67+
}
68+
if (interactive.type === "Timeout") {
69+
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
70+
return null
71+
}
72+
73+
const login = probeShellEnv(shell, "-l")
74+
if (login.type === "Loaded") {
75+
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
76+
return login.value
77+
}
78+
79+
console.warn(`[cli] Falling back to app environment: ${shell}`)
80+
return null
81+
}
82+
83+
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
84+
return {
85+
...(shell || {}),
86+
...env,
87+
}
88+
}

0 commit comments

Comments
 (0)