Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { CheckItem, GitHubStatus } from "@superset/local-db";
import { branchExistsOnRemote } from "../git";
import { execWithShellEnv } from "../shell-env";
import {
type GHPRResponse,
GHPRResponseSchema,
Expand Down Expand Up @@ -68,12 +69,10 @@ export async function fetchGitHubPRStatus(

async function getRepoUrl(worktreePath: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync(
const { stdout } = await execWithShellEnv(
"gh",
["repo", "view", "--json", "url"],
{
cwd: worktreePath,
},
{ cwd: worktreePath },
);
const raw = JSON.parse(stdout);
const result = GHRepoResponseSchema.safeParse(raw);
Expand All @@ -93,8 +92,8 @@ async function getPRForBranch(
branch: string,
): Promise<GitHubStatus["pr"]> {
try {
// Use execFile with args array to prevent command injection
const { stdout } = await execFileAsync(
// Use execWithShellEnv to handle macOS GUI app PATH issues
const { stdout } = await execWithShellEnv(
"gh",
[
"pr",
Expand Down
72 changes: 70 additions & 2 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/shell-env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { execFile } from "node:child_process";
import {
type ExecFileOptionsWithStringEncoding,
execFile,
} from "node:child_process";
import os from "node:os";
import { promisify } from "node:util";

Expand All @@ -11,6 +14,10 @@ let isFallbackCache = false;
const CACHE_TTL_MS = 60_000; // 1 minute cache
const FALLBACK_CACHE_TTL_MS = 10_000; // 10 second cache for fallback (retry sooner)

// Track PATH fix state for macOS GUI app PATH fix
let pathFixAttempted = false;
let pathFixSucceeded = false;

/**
* Gets the full shell environment by spawning a login shell.
* This captures PATH and other environment variables set in shell profiles
Expand All @@ -29,7 +36,9 @@ export async function getShellEnvironment(): Promise<Record<string, string>> {
return { ...cachedEnv };
}

const shell = process.env.SHELL || "/bin/bash";
const shell =
process.env.SHELL ||
(process.platform === "darwin" ? "/bin/zsh" : "/bin/bash");

try {
// Use -lc flags (not -ilc):
Expand Down Expand Up @@ -103,3 +112,62 @@ export function clearShellEnvCache(): void {
cacheTime = 0;
isFallbackCache = false;
}

/**
* Execute a command, retrying once with shell environment if it fails with ENOENT.
* On macOS, GUI apps launched from Finder/Dock get minimal PATH that excludes
* homebrew and other user-installed tools. This lazily derives the user's
* shell environment only when needed, then persists the fix to process.env.PATH.
*/
export async function execWithShellEnv(
cmd: string,
args: string[],
options?: Omit<ExecFileOptionsWithStringEncoding, "encoding">,
): Promise<{ stdout: string; stderr: string }> {
try {
return await execFileAsync(cmd, args, { ...options, encoding: "utf8" });
} catch (error) {
// Only retry on ENOENT (command not found), only on macOS
// Skip if we've already successfully fixed PATH, or if a fix attempt is in progress
if (
process.platform !== "darwin" ||
pathFixSucceeded ||
pathFixAttempted ||
!(error instanceof Error) ||
!("code" in error) ||
error.code !== "ENOENT"
) {
throw error;
}

pathFixAttempted = true;
console.log("[shell-env] Command not found, deriving shell environment");

try {
const shellEnv = await getShellEnvironment();

// Persist the fix to process.env so all subsequent calls benefit
if (shellEnv.PATH) {
process.env.PATH = shellEnv.PATH;
pathFixSucceeded = true;
console.log("[shell-env] Fixed process.env.PATH for GUI app");
}

// Retry with fixed env (respect caller's other env vars, force PATH if present)
const retryEnv = shellEnv.PATH
? { ...shellEnv, ...options?.env, PATH: shellEnv.PATH }
: { ...shellEnv, ...options?.env };

return await execFileAsync(cmd, args, {
...options,
encoding: "utf8",
env: retryEnv,
});
} catch (retryError) {
// Shell env derivation or retry failed - allow future retries
pathFixAttempted = false;
console.error("[shell-env] Retry failed:", retryError);
throw retryError;
}
}
}
Loading