Skip to content

Commit aaccef1

Browse files
committed
Improved shell PATH hydration with candidate iteration, launchctl fallback, and PATH merging
Adapted from upstream t3code #1799. Adds listLoginShellCandidates, mergePathEntries, and readPathFromLaunchctl to @bigcode/shared/shell. Updated fixPath() in os-jank to iterate over shell candidates with per-shell error logging, merge shell PATH with inherited PATH (shell entries first, deduped), and fall back to launchctl on macOS when all shell reads fail.
1 parent 49d7588 commit aaccef1

4 files changed

Lines changed: 250 additions & 10 deletions

File tree

apps/server/src/utils/os-jank.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,81 @@ describe("fixPath", () => {
3636
expect(readPath).not.toHaveBeenCalled();
3737
expect(env.PATH).toBe("C:\\Windows\\System32");
3838
});
39+
40+
it("merges shell PATH with env PATH, shell entries first", () => {
41+
const env: NodeJS.ProcessEnv = {
42+
SHELL: "/bin/zsh",
43+
PATH: "/usr/bin:/usr/local/bin",
44+
};
45+
const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
46+
47+
fixPath({
48+
env,
49+
platform: "darwin",
50+
readPath,
51+
});
52+
53+
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/usr/local/bin");
54+
});
55+
56+
it("tries multiple shell candidates before falling back to launchctl", () => {
57+
const env: NodeJS.ProcessEnv = {
58+
PATH: "/usr/bin",
59+
};
60+
const readPath = vi.fn(() => {
61+
throw new Error("shell not found");
62+
});
63+
const readLaunchctlPath = vi.fn(() => "/usr/bin:/bin:/usr/sbin:/sbin");
64+
const warnings: string[] = [];
65+
66+
fixPath({
67+
env,
68+
platform: "darwin",
69+
readPath,
70+
readLaunchctlPath,
71+
logWarning: (msg) => warnings.push(msg),
72+
});
73+
74+
expect(readLaunchctlPath).toHaveBeenCalled();
75+
expect(warnings.length).toBeGreaterThan(0);
76+
});
77+
78+
it("falls back to launchctl on macOS when all shell reads fail", () => {
79+
const env: NodeJS.ProcessEnv = {
80+
PATH: "/usr/bin",
81+
};
82+
const readPath = vi.fn(() => {
83+
throw new Error("shell not found");
84+
});
85+
const readLaunchctlPath = vi.fn(() => "/usr/bin:/bin:/usr/sbin:/sbin");
86+
87+
fixPath({
88+
env,
89+
platform: "darwin",
90+
readPath,
91+
readLaunchctlPath,
92+
});
93+
94+
expect(readLaunchctlPath).toHaveBeenCalled();
95+
expect(env.PATH).toBe("/usr/bin:/bin:/usr/sbin:/sbin");
96+
});
97+
98+
it("does not attempt launchctl on linux", () => {
99+
const env: NodeJS.ProcessEnv = {
100+
PATH: "/usr/bin",
101+
};
102+
const readPath = vi.fn(() => {
103+
throw new Error("shell not found");
104+
});
105+
const readLaunchctlPath = vi.fn(() => "/launchctl/path");
106+
107+
fixPath({
108+
env,
109+
platform: "linux",
110+
readPath,
111+
readLaunchctlPath,
112+
});
113+
114+
expect(readLaunchctlPath).not.toHaveBeenCalled();
115+
});
39116
});

apps/server/src/utils/os-jank.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,53 @@
11
import * as OS from "node:os";
22
import { Effect, Path } from "effect";
3-
import { readPathFromLoginShell, resolveLoginShell } from "@bigcode/shared/shell";
3+
import {
4+
listLoginShellCandidates,
5+
mergePathEntries,
6+
readPathFromLaunchctl,
7+
readPathFromLoginShell,
8+
} from "@bigcode/shared/shell";
49

510
export function fixPath(
611
options: {
712
env?: NodeJS.ProcessEnv;
813
platform?: NodeJS.Platform;
914
readPath?: typeof readPathFromLoginShell;
15+
readLaunchctlPath?: typeof readPathFromLaunchctl;
16+
logWarning?: (message: string) => void;
1017
} = {},
1118
): void {
1219
const platform = options.platform ?? process.platform;
1320
if (platform !== "darwin" && platform !== "linux") return;
1421

1522
const env = options.env ?? process.env;
23+
const warn = options.logWarning ?? (() => {});
24+
const candidates = listLoginShellCandidates(platform, env.SHELL);
1625

17-
try {
18-
const shell = resolveLoginShell(platform, env.SHELL);
19-
if (!shell) return;
20-
const result = (options.readPath ?? readPathFromLoginShell)(shell);
21-
if (result) {
22-
env.PATH = result;
26+
for (const shell of candidates) {
27+
try {
28+
const shellPath = (options.readPath ?? readPathFromLoginShell)(shell);
29+
if (shellPath) {
30+
env.PATH = mergePathEntries(shellPath, env.PATH, platform);
31+
return;
32+
}
33+
} catch (error) {
34+
warn(
35+
`Failed to read PATH from login shell '${shell}': ${error instanceof Error ? error.message : String(error)}`,
36+
);
37+
}
38+
}
39+
40+
if (platform === "darwin") {
41+
try {
42+
const launchctlPath = (options.readLaunchctlPath ?? readPathFromLaunchctl)();
43+
if (launchctlPath) {
44+
env.PATH = mergePathEntries(launchctlPath, env.PATH, platform);
45+
}
46+
} catch (error) {
47+
warn(
48+
`Failed to read PATH from launchctl: ${error instanceof Error ? error.message : String(error)}`,
49+
);
2350
}
24-
} catch {
25-
// Silently ignore — keep default PATH
2651
}
2752
}
2853

packages/shared/src/shell.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest";
22

33
import {
44
extractPathFromShellOutput,
5+
listLoginShellCandidates,
6+
mergePathEntries,
57
readEnvironmentFromLoginShell,
8+
readPathFromLaunchctl,
69
readPathFromLoginShell,
710
} from "./shell";
811

@@ -126,3 +129,75 @@ describe("readEnvironmentFromLoginShell", () => {
126129
});
127130
});
128131
});
132+
133+
describe("listLoginShellCandidates", () => {
134+
it("returns envShell first, then userShell, then platform defaults (macOS)", () => {
135+
const candidates = listLoginShellCandidates("darwin", "/opt/homebrew/bin/fish", "/bin/bash");
136+
expect(candidates).toEqual(["/opt/homebrew/bin/fish", "/bin/bash", "/bin/zsh"]);
137+
});
138+
139+
it("deduplicates entries", () => {
140+
const candidates = listLoginShellCandidates("darwin", "/bin/zsh", "/bin/zsh");
141+
expect(candidates).toEqual(["/bin/zsh", "/bin/bash"]);
142+
});
143+
144+
it("returns platform defaults when no shells are specified (macOS)", () => {
145+
const candidates = listLoginShellCandidates("darwin");
146+
expect(candidates).toEqual(["/bin/zsh", "/bin/bash"]);
147+
});
148+
149+
it("returns platform defaults when no shells are specified (linux)", () => {
150+
const candidates = listLoginShellCandidates("linux");
151+
expect(candidates).toEqual(["/bin/bash", "/bin/sh"]);
152+
});
153+
154+
it("returns an empty array for unsupported platforms", () => {
155+
const candidates = listLoginShellCandidates("win32");
156+
expect(candidates).toEqual([]);
157+
});
158+
});
159+
160+
describe("mergePathEntries", () => {
161+
it("places shell entries before env entries", () => {
162+
expect(mergePathEntries("/a:/b", "/b:/c", "darwin")).toBe("/a:/b:/c");
163+
});
164+
165+
it("deduplicates entries preserving first occurrence", () => {
166+
expect(
167+
mergePathEntries("/usr/bin:/opt/homebrew/bin", "/opt/homebrew/bin:/usr/local/bin", "darwin"),
168+
).toBe("/usr/bin:/opt/homebrew/bin:/usr/local/bin");
169+
});
170+
171+
it("returns shell entries alone when envPath is undefined", () => {
172+
expect(mergePathEntries("/usr/bin:/opt/homebrew/bin", undefined, "darwin")).toBe(
173+
"/usr/bin:/opt/homebrew/bin",
174+
);
175+
});
176+
177+
it("filters empty segments", () => {
178+
expect(mergePathEntries("/a::/b", "/c", "linux")).toBe("/a:/b:/c");
179+
});
180+
});
181+
182+
describe("readPathFromLaunchctl", () => {
183+
it("returns the PATH value from launchctl on macOS", () => {
184+
const execCommand = vi.fn(() => "/usr/bin:/bin:/usr/sbin:/sbin\n");
185+
expect(readPathFromLaunchctl(execCommand as never)).toBe("/usr/bin:/bin:/usr/sbin:/sbin");
186+
expect(execCommand).toHaveBeenCalledWith("launchctl getenv PATH", {
187+
encoding: "utf8",
188+
timeout: 3000,
189+
});
190+
});
191+
192+
it("returns undefined when launchctl returns empty", () => {
193+
const execCommand = vi.fn(() => " \n");
194+
expect(readPathFromLaunchctl(execCommand as never)).toBeUndefined();
195+
});
196+
197+
it("returns undefined when launchctl throws", () => {
198+
const execCommand = vi.fn(() => {
199+
throw new Error("launchctl not available");
200+
});
201+
expect(readPathFromLaunchctl(execCommand as never)).toBeUndefined();
202+
});
203+
});

packages/shared/src/shell.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execFileSync } from "node:child_process";
1+
import { execFileSync, execSync } from "node:child_process";
22

33
const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
44
const PATH_CAPTURE_END = "__T3CODE_PATH_END__";
@@ -10,6 +10,8 @@ type ExecFileSyncLike = (
1010
options: { encoding: "utf8"; timeout: number },
1111
) => string;
1212

13+
type ExecSyncLike = (command: string, options: { encoding: "utf8"; timeout: number }) => string;
14+
1315
export function resolveLoginShell(
1416
platform: NodeJS.Platform,
1517
shell: string | undefined,
@@ -30,6 +32,67 @@ export function resolveLoginShell(
3032
return undefined;
3133
}
3234

35+
export function listLoginShellCandidates(
36+
platform: NodeJS.Platform,
37+
envShell?: string | undefined,
38+
userShell?: string | undefined,
39+
): ReadonlyArray<string> {
40+
const candidates: string[] = [];
41+
42+
const add = (shell: string | undefined) => {
43+
const trimmed = shell?.trim();
44+
if (trimmed && !candidates.includes(trimmed)) {
45+
candidates.push(trimmed);
46+
}
47+
};
48+
49+
add(envShell);
50+
add(userShell);
51+
52+
if (platform === "darwin") {
53+
add("/bin/zsh");
54+
add("/bin/bash");
55+
} else if (platform === "linux") {
56+
add("/bin/bash");
57+
add("/bin/sh");
58+
}
59+
60+
return candidates;
61+
}
62+
63+
export function mergePathEntries(
64+
shellPath: string,
65+
envPath: string | undefined,
66+
_platform: NodeJS.Platform,
67+
): string {
68+
const shellEntries = shellPath.split(":").filter((e) => e.length > 0);
69+
const envEntries = envPath ? envPath.split(":").filter((e) => e.length > 0) : [];
70+
71+
const seen = new Set<string>();
72+
const merged: string[] = [];
73+
74+
for (const entry of [...shellEntries, ...envEntries]) {
75+
if (!seen.has(entry)) {
76+
seen.add(entry);
77+
merged.push(entry);
78+
}
79+
}
80+
81+
return merged.join(":");
82+
}
83+
84+
export function readPathFromLaunchctl(execCommand: ExecSyncLike = execSync): string | undefined {
85+
try {
86+
const result = execCommand("launchctl getenv PATH", {
87+
encoding: "utf8",
88+
timeout: 3000,
89+
}).trim();
90+
return result.length > 0 ? result : undefined;
91+
} catch {
92+
return undefined;
93+
}
94+
}
95+
3396
export function extractPathFromShellOutput(output: string): string | null {
3497
const startIndex = output.indexOf(PATH_CAPTURE_START);
3598
if (startIndex === -1) return null;

0 commit comments

Comments
 (0)