Skip to content

Commit 5851939

Browse files
committed
Merge PR pingdotgg#831 branch
2 parents 13eeb07 + e488a7f commit 5851939

4 files changed

Lines changed: 257 additions & 27 deletions

File tree

apps/desktop/src/fixPath.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1-
import { readPathFromLoginShell } from "@t3tools/shared/shell";
1+
import {
2+
defaultShellCandidates,
3+
resolvePathFromLoginShells,
4+
shouldRepairPath,
5+
} from "@t3tools/shared/shell";
26

37
export function fixPath(): void {
4-
if (process.platform !== "darwin") return;
8+
if (!shouldRepairPath()) return;
59

6-
try {
7-
const shell = process.env.SHELL ?? "/bin/zsh";
8-
const result = readPathFromLoginShell(shell);
9-
if (result) {
10-
process.env.PATH = result;
11-
}
12-
} catch {
13-
// Keep inherited PATH if shell lookup fails.
10+
const result = resolvePathFromLoginShells(defaultShellCandidates());
11+
if (result) {
12+
process.env.PATH = result;
1413
}
1514
}

apps/server/src/os-jank.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import * as OS from "node:os";
22
import { Effect, Path } from "effect";
3-
import { readPathFromLoginShell } from "@t3tools/shared/shell";
3+
import {
4+
defaultShellCandidates,
5+
resolvePathFromLoginShells,
6+
shouldRepairPath,
7+
} from "@t3tools/shared/shell";
48

59
export function fixPath(): void {
6-
if (process.platform !== "darwin") return;
10+
if (!shouldRepairPath()) return;
711

8-
try {
9-
const shell = process.env.SHELL ?? "/bin/zsh";
10-
const result = readPathFromLoginShell(shell);
11-
if (result) {
12-
process.env.PATH = result;
13-
}
14-
} catch {
15-
// Silently ignore — keep default PATH
12+
const resolvedPath = resolvePathFromLoginShells(defaultShellCandidates());
13+
if (resolvedPath) {
14+
process.env.PATH = resolvedPath;
1615
}
1716
}
1817

packages/shared/src/shell.test.ts

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, it, vi } from "vitest";
22

3-
import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell";
3+
import {
4+
defaultShellCandidates,
5+
extractPathFromShellOutput,
6+
readPathFromLoginShell,
7+
resolvePathFromLoginShells,
8+
shouldRepairPath,
9+
} from "./shell";
410

511
describe("extractPathFromShellOutput", () => {
612
it("extracts the path between capture markers", () => {
@@ -52,6 +58,110 @@ describe("readPathFromLoginShell", () => {
5258
expect(args?.[1]).toContain("printenv PATH");
5359
expect(args?.[1]).toContain("__T3CODE_PATH_START__");
5460
expect(args?.[1]).toContain("__T3CODE_PATH_END__");
55-
expect(options).toEqual({ encoding: "utf8", timeout: 5000 });
61+
expect(options).toEqual({ encoding: "utf8", timeout: 750 });
62+
});
63+
64+
it("falls back to non-interactive login mode when interactive login fails", () => {
65+
const execFile = vi.fn<
66+
(
67+
file: string,
68+
args: ReadonlyArray<string>,
69+
options: { encoding: "utf8"; timeout: number },
70+
) => string
71+
>((_, args) => {
72+
if (args[0] === "-ilc") {
73+
throw new Error("interactive login unsupported");
74+
}
75+
return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n";
76+
});
77+
78+
expect(readPathFromLoginShell("/bin/sh", execFile)).toBe("/a:/b");
79+
expect(execFile).toHaveBeenCalledTimes(2);
80+
expect(execFile.mock.calls[0]?.[1]?.[0]).toBe("-ilc");
81+
expect(execFile.mock.calls[1]?.[1]?.[0]).toBe("-lc");
82+
});
83+
});
84+
85+
describe("resolvePathFromLoginShells", () => {
86+
it("returns the first resolved PATH from the provided shells", () => {
87+
const execFile = vi.fn<
88+
(
89+
file: string,
90+
args: ReadonlyArray<string>,
91+
options: { encoding: "utf8"; timeout: number },
92+
) => string
93+
>((file) => {
94+
if (file === "/bin/zsh") {
95+
throw new Error("zsh unavailable");
96+
}
97+
return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n";
98+
});
99+
100+
const onError = vi.fn<(shell: string, error: unknown) => void>();
101+
const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile, onError);
102+
expect(result).toBe("/a:/b");
103+
expect(execFile).toHaveBeenCalledTimes(3);
104+
expect(onError).toHaveBeenCalledTimes(2);
105+
expect(onError.mock.calls.map(([shell]) => shell)).toEqual(["/bin/zsh", "/bin/zsh"]);
106+
});
107+
108+
it("returns undefined when all shells fail to resolve PATH", () => {
109+
const execFile = vi.fn<
110+
(
111+
file: string,
112+
args: ReadonlyArray<string>,
113+
options: { encoding: "utf8"; timeout: number },
114+
) => string
115+
>(() => {
116+
throw new Error("no shells available");
117+
});
118+
119+
const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile);
120+
expect(result).toBeUndefined();
121+
expect(execFile).toHaveBeenCalledTimes(4);
122+
});
123+
});
124+
125+
describe("defaultShellCandidates", () => {
126+
it("limits Linux candidates to the configured shell and POSIX fallback", () => {
127+
const originalShell = process.env.SHELL;
128+
process.env.SHELL = "/bin/bash";
129+
130+
try {
131+
expect(defaultShellCandidates("linux")).toEqual(["/bin/bash", "/bin/sh"]);
132+
} finally {
133+
process.env.SHELL = originalShell;
134+
}
135+
});
136+
137+
it("limits macOS candidates to a small bounded fallback set", () => {
138+
const originalShell = process.env.SHELL;
139+
process.env.SHELL = "/opt/homebrew/bin/fish";
140+
141+
try {
142+
expect(defaultShellCandidates("darwin")).toEqual([
143+
"/opt/homebrew/bin/fish",
144+
"/bin/zsh",
145+
"/bin/bash",
146+
]);
147+
} finally {
148+
process.env.SHELL = originalShell;
149+
}
150+
});
151+
});
152+
153+
describe("shouldRepairPath", () => {
154+
it("skips repair when macOS already has a likely interactive PATH", () => {
155+
expect(shouldRepairPath("darwin", "/usr/bin:/bin:/opt/homebrew/bin")).toBe(false);
156+
});
157+
158+
it("requires repair when Linux is missing common user PATH entries", () => {
159+
expect(shouldRepairPath("linux", "/usr/bin:/bin", "/home/tester")).toBe(true);
160+
});
161+
162+
it("skips repair when Linux already exposes ~/.local/bin", () => {
163+
expect(shouldRepairPath("linux", "/home/tester/.local/bin:/usr/bin", "/home/tester")).toBe(
164+
false,
165+
);
56166
});
57167
});

packages/shared/src/shell.ts

Lines changed: 127 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { execFileSync } from "node:child_process";
2+
import { homedir } from "node:os";
23

34
const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
45
const PATH_CAPTURE_END = "__T3CODE_PATH_END__";
@@ -7,12 +8,20 @@ const PATH_CAPTURE_COMMAND = [
78
"printenv PATH",
89
`printf '%s\n' '${PATH_CAPTURE_END}'`,
910
].join("; ");
11+
const LOGIN_SHELL_TIMEOUT_MS = 750;
12+
const PATH_REPAIR_DEADLINE_MS = 2_000;
13+
const LOGIN_SHELL_ARG_SETS = [
14+
["-ilc", PATH_CAPTURE_COMMAND],
15+
["-lc", PATH_CAPTURE_COMMAND],
16+
] as const;
1017

1118
type ExecFileSyncLike = (
1219
file: string,
1320
args: ReadonlyArray<string>,
1421
options: { encoding: "utf8"; timeout: number },
1522
) => string;
23+
type LoginShellErrorReporter = (shell: string, args: ReadonlyArray<string>, error: unknown) => void;
24+
type ShellPathResolveErrorReporter = (shell: string, error: unknown) => void;
1625

1726
export function extractPathFromShellOutput(output: string): string | null {
1827
const startIndex = output.indexOf(PATH_CAPTURE_START);
@@ -29,10 +38,123 @@ export function extractPathFromShellOutput(output: string): string | null {
2938
export function readPathFromLoginShell(
3039
shell: string,
3140
execFile: ExecFileSyncLike = execFileSync,
41+
onError?: LoginShellErrorReporter,
3242
): string | undefined {
33-
const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], {
34-
encoding: "utf8",
35-
timeout: 5000,
36-
});
37-
return extractPathFromShellOutput(output) ?? undefined;
43+
let lastError: unknown;
44+
for (const args of LOGIN_SHELL_ARG_SETS) {
45+
try {
46+
const output = execFile(shell, args, {
47+
encoding: "utf8",
48+
timeout: LOGIN_SHELL_TIMEOUT_MS,
49+
});
50+
const resolvedPath = extractPathFromShellOutput(output) ?? undefined;
51+
if (resolvedPath) {
52+
return resolvedPath;
53+
}
54+
} catch (error) {
55+
lastError = error;
56+
onError?.(shell, args, error);
57+
}
58+
}
59+
60+
if (lastError) {
61+
throw lastError;
62+
}
63+
return undefined;
64+
}
65+
66+
function uniqueShellCandidates(candidates: ReadonlyArray<string | undefined>): string[] {
67+
const unique = new Set<string>();
68+
69+
for (const candidate of candidates) {
70+
if (typeof candidate !== "string") continue;
71+
const normalized = candidate.trim();
72+
if (normalized.length === 0 || unique.has(normalized)) continue;
73+
unique.add(normalized);
74+
}
75+
76+
return [...unique];
77+
}
78+
79+
export function defaultShellCandidates(platform = process.platform): string[] {
80+
if (platform === "linux") {
81+
return uniqueShellCandidates([process.env.SHELL, "/bin/sh"]);
82+
}
83+
84+
if (platform === "darwin") {
85+
return uniqueShellCandidates([process.env.SHELL, "/bin/zsh", "/bin/bash"]);
86+
}
87+
88+
return uniqueShellCandidates([
89+
process.env.SHELL,
90+
"/bin/zsh",
91+
"/usr/bin/zsh",
92+
"/bin/bash",
93+
"/usr/bin/bash",
94+
]);
95+
}
96+
97+
const defaultShellPathErrorReporter: ShellPathResolveErrorReporter | undefined =
98+
process.env.T3CODE_DEBUG_SHELL_PATH === "1"
99+
? (shell, error) => {
100+
const message = error instanceof Error ? error.message : String(error);
101+
console.warn(`[shell] PATH resolution failed for ${shell}: ${message}`);
102+
}
103+
: undefined;
104+
105+
export function resolvePathFromLoginShells(
106+
shells: ReadonlyArray<string>,
107+
execFile: ExecFileSyncLike = execFileSync,
108+
onError: ShellPathResolveErrorReporter | undefined = defaultShellPathErrorReporter,
109+
): string | undefined {
110+
const deadline = Date.now() + PATH_REPAIR_DEADLINE_MS;
111+
112+
for (const shell of shells) {
113+
if (Date.now() >= deadline) {
114+
return undefined;
115+
}
116+
117+
try {
118+
const result = readPathFromLoginShell(shell, execFile, (_failedShell, _args, error) => {
119+
onError?.(shell, error);
120+
});
121+
if (result) {
122+
return result;
123+
}
124+
} catch {
125+
// Per-attempt failures are already reported via onError when enabled.
126+
}
127+
}
128+
129+
return undefined;
130+
}
131+
132+
function pathEntries(pathValue: string | undefined): Set<string> {
133+
return new Set(
134+
(pathValue ?? "")
135+
.split(":")
136+
.map((entry) => entry.trim())
137+
.filter((entry) => entry.length > 0),
138+
);
139+
}
140+
141+
export function shouldRepairPath(
142+
platform = process.platform,
143+
pathValue = process.env.PATH,
144+
homePath = process.env.HOME ?? homedir(),
145+
): boolean {
146+
if (platform !== "darwin" && platform !== "linux") {
147+
return false;
148+
}
149+
150+
const entries = pathEntries(pathValue);
151+
if (entries.size === 0) {
152+
return true;
153+
}
154+
155+
if (platform === "darwin") {
156+
return !entries.has("/opt/homebrew/bin") && !entries.has("/usr/local/bin");
157+
}
158+
159+
return !entries.has(`${homePath}/.local/bin`) && !entries.has("/usr/local/bin");
38160
}

0 commit comments

Comments
 (0)