diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2c84f0ae05a..49ad97e0c15 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -114,6 +114,7 @@ "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "semver": "^7.7.3", + "shell-env": "^4.0.1", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", "strip-ansi": "^7.1.2", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a9d1e6c5687..7517c71e537 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -13,6 +13,7 @@ import { initAppState } from "./lib/app-state"; import { authService, parseAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; import { localDb } from "./lib/local-db"; +import { ensureShellEnvVars } from "./lib/shell-env"; import { terminalManager } from "./lib/terminal"; import { MainWindow } from "./windows/main"; @@ -207,6 +208,10 @@ if (!gotTheLock) { await initAppState(); await authService.initialize(); + // Resolve shell environment before setting up agent hooks + // This ensures ZDOTDIR and PATH are available for terminal initialization + await ensureShellEnvVars(); + try { setupAgentHooks(); } catch (error) { diff --git a/apps/desktop/src/main/lib/shell-env.test.ts b/apps/desktop/src/main/lib/shell-env.test.ts new file mode 100644 index 00000000000..2c9100c35fb --- /dev/null +++ b/apps/desktop/src/main/lib/shell-env.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mergePathFromShell } from "./shell-env"; + +describe("shell-env", () => { + describe("mergePathFromShell", () => { + let originalPath: string | undefined; + + beforeEach(() => { + originalPath = process.env.PATH; + }); + + afterEach(() => { + process.env.PATH = originalPath; + }); + + it("should prepend new paths from shell", () => { + process.env.PATH = "/usr/bin:/bin"; + const result = mergePathFromShell("/opt/homebrew/bin:/usr/bin:/bin"); + + expect(result).toBe(true); + expect(process.env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/bin"); + }); + + it("should return false when no new paths to add", () => { + process.env.PATH = "/usr/bin:/bin:/opt/homebrew/bin"; + const result = mergePathFromShell("/usr/bin:/bin"); + + expect(result).toBe(false); + expect(process.env.PATH).toBe("/usr/bin:/bin:/opt/homebrew/bin"); + }); + + it("should preserve existing paths", () => { + process.env.PATH = "/electron/path:/usr/bin"; + const result = mergePathFromShell("/new/path:/usr/bin"); + + expect(result).toBe(true); + expect(process.env.PATH).toBe("/new/path:/electron/path:/usr/bin"); + }); + + it("should handle empty current PATH", () => { + process.env.PATH = ""; + const result = mergePathFromShell("/usr/bin:/bin"); + + expect(result).toBe(true); + expect(process.env.PATH).toBe("/usr/bin:/bin"); + }); + + it("should handle empty shell PATH", () => { + process.env.PATH = "/usr/bin"; + const result = mergePathFromShell(""); + + expect(result).toBe(false); + expect(process.env.PATH).toBe("/usr/bin"); + }); + + it("should deduplicate paths", () => { + process.env.PATH = "/usr/bin:/bin"; + const result = mergePathFromShell("/new/path:/usr/bin:/another:/bin"); + + expect(result).toBe(true); + expect(process.env.PATH).toBe("/new/path:/another:/usr/bin:/bin"); + }); + + it("should filter empty path segments", () => { + process.env.PATH = "/usr/bin::/bin"; + const result = mergePathFromShell("/new/path:::/usr/bin"); + + expect(result).toBe(true); + expect(process.env.PATH).toBe("/new/path:/usr/bin::/bin"); + }); + + it("should maintain shell path order for new entries", () => { + process.env.PATH = "/existing"; + const result = mergePathFromShell("/first:/second:/third"); + + expect(result).toBe(true); + expect(process.env.PATH).toBe("/first:/second:/third:/existing"); + }); + }); +}); diff --git a/apps/desktop/src/main/lib/shell-env.ts b/apps/desktop/src/main/lib/shell-env.ts new file mode 100644 index 00000000000..707b5d97bda --- /dev/null +++ b/apps/desktop/src/main/lib/shell-env.ts @@ -0,0 +1,65 @@ +import { shellEnv } from "shell-env"; + +const SHELL_ENV_TIMEOUT_MS = 5000; + +function isLaunchedFromTerminal(): boolean { + return Boolean(process.stdout.isTTY || process.env.TERM_PROGRAM); +} + +export function mergePathFromShell(shellPath: string): boolean { + const currentPath = process.env.PATH || ""; + const currentPaths = new Set(currentPath.split(":").filter(Boolean)); + const shellPaths = shellPath.split(":").filter(Boolean); + const newPaths = shellPaths.filter((p) => !currentPaths.has(p)); + + if (newPaths.length === 0) { + return false; + } + + process.env.PATH = [...newPaths, currentPath].filter(Boolean).join(":"); + return true; +} + +/** Resolves shell environment for macOS GUI apps (which don't inherit shell env). */ +export async function ensureShellEnvVars(): Promise { + if (process.platform === "win32") { + return; + } + + if (isLaunchedFromTerminal()) { + console.log("[shell-env] Skipping - launched from terminal"); + return; + } + + try { + console.log("[shell-env] Resolving shell environment..."); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("Shell environment resolution timed out")), + SHELL_ENV_TIMEOUT_MS, + ); + }); + + const env = await Promise.race([shellEnv(), timeoutPromise]); + + let resolved = false; + + if (env.ZDOTDIR && !process.env.ZDOTDIR) { + process.env.ZDOTDIR = env.ZDOTDIR; + console.log("[shell-env] Resolved ZDOTDIR:", env.ZDOTDIR); + resolved = true; + } + + if (env.PATH && mergePathFromShell(env.PATH)) { + console.log("[shell-env] Merged PATH from shell"); + resolved = true; + } + + if (!resolved) { + console.log("[shell-env] No additional env vars needed"); + } + } catch (error) { + console.warn("[shell-env] Failed to resolve:", error); + } +} diff --git a/bun.lock b/bun.lock index 2d2b69cf805..3fee4bfd83f 100644 --- a/bun.lock +++ b/bun.lock @@ -122,7 +122,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.52", + "version": "0.0.53", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -207,6 +207,7 @@ "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "semver": "^7.7.3", + "shell-env": "^4.0.1", "shell-quote": "^1.8.3", "simple-git": "^3.30.0", "strip-ansi": "^7.1.2", @@ -3295,6 +3296,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-env": ["shell-env@4.0.1", "", { "dependencies": { "default-shell": "^2.0.0", "execa": "^5.1.1", "strip-ansi": "^7.0.1" } }, "sha512-w3oeZ9qg/P6Lu6qqwavvMnB/bwfsz67gPB3WXmLd/n6zuh7TWQZtGa3iMEdmua0kj8rivkwl+vUjgLWlqZOMPw=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], "sherif": ["sherif@1.9.0", "", { "optionalDependencies": { "sherif-darwin-arm64": "1.9.0", "sherif-darwin-x64": "1.9.0", "sherif-linux-arm64": "1.9.0", "sherif-linux-arm64-musl": "1.9.0", "sherif-linux-x64": "1.9.0", "sherif-linux-x64-musl": "1.9.0", "sherif-windows-arm64": "1.9.0", "sherif-windows-x64": "1.9.0" }, "bin": { "sherif": "index.js" } }, "sha512-5n7zqPAjL+RzR7n09NPKpWBXmDCtuRpQzIL+ycj8pe6MayV7cDuFmceoyPQJ0c95oFj6feY7SZvhX/+S0i1ukg=="], @@ -4077,6 +4080,8 @@ "serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="], + "shell-env/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + "socks-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -4321,6 +4326,16 @@ "schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "shell-env/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "shell-env/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "shell-env/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "shell-env/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "shell-env/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "temp/rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],