Răsfoiți Sursa

electron: port mergeShellEnv logic from tauri (#20192)

Brendan Allan 2 săptămâni în urmă
părinte
comite
506dd75818

+ 10 - 7
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>) {

+ 43 - 0
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)
+  })
+})

+ 88 - 0
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,
+  }
+}