Skip to content
Open
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
19 changes: 9 additions & 10 deletions apps/desktop/src/fixPath.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { readPathFromLoginShell } from "@t3tools/shared/shell";
import {
defaultShellCandidates,
resolvePathFromLoginShells,
shouldRepairPath,
} from "@t3tools/shared/shell";

export function fixPath(): void {
if (process.platform !== "darwin") return;
if (!shouldRepairPath()) return;

try {
const shell = process.env.SHELL ?? "/bin/zsh";
const result = readPathFromLoginShell(shell);
if (result) {
process.env.PATH = result;
}
} catch {
// Keep inherited PATH if shell lookup fails.
const result = resolvePathFromLoginShells(defaultShellCandidates());
if (result) {
process.env.PATH = result;
}
Comment on lines 7 to 13
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs a synchronous PATH repair on Linux as well as macOS; with the current 5s timeout and two invocation modes per shell, Electron main startup can block for up to ~30s in the worst case. Consider reducing the timeout and/or short-circuiting when PATH is already populated enough to resolve required binaries.

Copilot uses AI. Check for mistakes.
}
19 changes: 9 additions & 10 deletions apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import * as OS from "node:os";
import { Effect, Path } from "effect";
import { readPathFromLoginShell } from "@t3tools/shared/shell";
import {
defaultShellCandidates,
resolvePathFromLoginShells,
shouldRepairPath,
} from "@t3tools/shared/shell";

export function fixPath(): void {
if (process.platform !== "darwin") return;
if (!shouldRepairPath()) return;

try {
const shell = process.env.SHELL ?? "/bin/zsh";
const result = readPathFromLoginShell(shell);
if (result) {
process.env.PATH = result;
}
} catch {
// Silently ignore — keep default PATH
const resolvedPath = resolvePathFromLoginShells(defaultShellCandidates());
if (resolvedPath) {
process.env.PATH = resolvedPath;
}
Comment on lines 9 to 15
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On darwin/linux this runs a synchronous PATH repair that can block startup for up to timeout * argModes * shellCandidates (currently 5000ms * 2 * up to 3 = 30s on macOS; 20s on Linux). If the PR goal is to cap worst-case startup cost, consider lowering the per-invocation timeout and/or adding an overall deadline / gating the repair to cases where PATH looks incomplete.

Copilot uses AI. Check for mistakes.
}

Expand Down
114 changes: 112 additions & 2 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it, vi } from "vitest";

import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell";
import {
defaultShellCandidates,
extractPathFromShellOutput,
readPathFromLoginShell,
resolvePathFromLoginShells,
shouldRepairPath,
} from "./shell";

describe("extractPathFromShellOutput", () => {
it("extracts the path between capture markers", () => {
Expand Down Expand Up @@ -52,6 +58,110 @@ describe("readPathFromLoginShell", () => {
expect(args?.[1]).toContain("printenv PATH");
expect(args?.[1]).toContain("__T3CODE_PATH_START__");
expect(args?.[1]).toContain("__T3CODE_PATH_END__");
expect(options).toEqual({ encoding: "utf8", timeout: 5000 });
expect(options).toEqual({ encoding: "utf8", timeout: 750 });
});

it("falls back to non-interactive login mode when interactive login fails", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>((_, args) => {
if (args[0] === "-ilc") {
throw new Error("interactive login unsupported");
}
return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n";
});

expect(readPathFromLoginShell("/bin/sh", execFile)).toBe("/a:/b");
expect(execFile).toHaveBeenCalledTimes(2);
expect(execFile.mock.calls[0]?.[1]?.[0]).toBe("-ilc");
expect(execFile.mock.calls[1]?.[1]?.[0]).toBe("-lc");
});
});

describe("resolvePathFromLoginShells", () => {
it("returns the first resolved PATH from the provided shells", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>((file) => {
if (file === "/bin/zsh") {
throw new Error("zsh unavailable");
}
return "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n";
});

const onError = vi.fn<(shell: string, error: unknown) => void>();
const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile, onError);
expect(result).toBe("/a:/b");
expect(execFile).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledTimes(2);
expect(onError.mock.calls.map(([shell]) => shell)).toEqual(["/bin/zsh", "/bin/zsh"]);
});

it("returns undefined when all shells fail to resolve PATH", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>(() => {
throw new Error("no shells available");
});

const result = resolvePathFromLoginShells(["/bin/zsh", "/bin/bash"], execFile);
expect(result).toBeUndefined();
expect(execFile).toHaveBeenCalledTimes(4);
});
});

describe("defaultShellCandidates", () => {
it("limits Linux candidates to the configured shell and POSIX fallback", () => {
const originalShell = process.env.SHELL;
process.env.SHELL = "/bin/bash";

try {
expect(defaultShellCandidates("linux")).toEqual(["/bin/bash", "/bin/sh"]);
} finally {
process.env.SHELL = originalShell;
}
});

it("limits macOS candidates to a small bounded fallback set", () => {
const originalShell = process.env.SHELL;
process.env.SHELL = "/opt/homebrew/bin/fish";

try {
expect(defaultShellCandidates("darwin")).toEqual([
"/opt/homebrew/bin/fish",
"/bin/zsh",
"/bin/bash",
]);
} finally {
process.env.SHELL = originalShell;
}
});
});

describe("shouldRepairPath", () => {
it("skips repair when macOS already has a likely interactive PATH", () => {
expect(shouldRepairPath("darwin", "/usr/bin:/bin:/opt/homebrew/bin")).toBe(false);
});

it("requires repair when Linux is missing common user PATH entries", () => {
expect(shouldRepairPath("linux", "/usr/bin:/bin", "/home/tester")).toBe(true);
});

it("skips repair when Linux already exposes ~/.local/bin", () => {
expect(shouldRepairPath("linux", "/home/tester/.local/bin:/usr/bin", "/home/tester")).toBe(
false,
);
});
});
132 changes: 127 additions & 5 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFileSync } from "node:child_process";
import { homedir } from "node:os";

const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
const PATH_CAPTURE_END = "__T3CODE_PATH_END__";
Expand All @@ -7,12 +8,20 @@ const PATH_CAPTURE_COMMAND = [
"printenv PATH",
`printf '%s\n' '${PATH_CAPTURE_END}'`,
].join("; ");
const LOGIN_SHELL_TIMEOUT_MS = 750;
const PATH_REPAIR_DEADLINE_MS = 2_000;
const LOGIN_SHELL_ARG_SETS = [
["-ilc", PATH_CAPTURE_COMMAND],
["-lc", PATH_CAPTURE_COMMAND],
] as const;

type ExecFileSyncLike = (
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string;
type LoginShellErrorReporter = (shell: string, args: ReadonlyArray<string>, error: unknown) => void;
type ShellPathResolveErrorReporter = (shell: string, error: unknown) => void;

export function extractPathFromShellOutput(output: string): string | null {
const startIndex = output.indexOf(PATH_CAPTURE_START);
Expand All @@ -29,10 +38,123 @@ export function extractPathFromShellOutput(output: string): string | null {
export function readPathFromLoginShell(
shell: string,
execFile: ExecFileSyncLike = execFileSync,
onError?: LoginShellErrorReporter,
): string | undefined {
const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], {
encoding: "utf8",
timeout: 5000,
});
return extractPathFromShellOutput(output) ?? undefined;
let lastError: unknown;
for (const args of LOGIN_SHELL_ARG_SETS) {
try {
const output = execFile(shell, args, {
encoding: "utf8",
timeout: LOGIN_SHELL_TIMEOUT_MS,
});
const resolvedPath = extractPathFromShellOutput(output) ?? undefined;
if (resolvedPath) {
return resolvedPath;
}
} catch (error) {
lastError = error;
onError?.(shell, args, error);
}
}

if (lastError) {
throw lastError;
}
return undefined;
}

function uniqueShellCandidates(candidates: ReadonlyArray<string | undefined>): string[] {
const unique = new Set<string>();

for (const candidate of candidates) {
if (typeof candidate !== "string") continue;
const normalized = candidate.trim();
if (normalized.length === 0 || unique.has(normalized)) continue;
unique.add(normalized);
}

return [...unique];
}

export function defaultShellCandidates(platform = process.platform): string[] {
if (platform === "linux") {
return uniqueShellCandidates([process.env.SHELL, "/bin/sh"]);
}

if (platform === "darwin") {
return uniqueShellCandidates([process.env.SHELL, "/bin/zsh", "/bin/bash"]);
}

return uniqueShellCandidates([
process.env.SHELL,
"/bin/zsh",
"/usr/bin/zsh",
"/bin/bash",
"/usr/bin/bash",
]);
}

const defaultShellPathErrorReporter: ShellPathResolveErrorReporter | undefined =
process.env.T3CODE_DEBUG_SHELL_PATH === "1"
? (shell, error) => {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[shell] PATH resolution failed for ${shell}: ${message}`);
}
: undefined;

export function resolvePathFromLoginShells(
shells: ReadonlyArray<string>,
execFile: ExecFileSyncLike = execFileSync,
onError: ShellPathResolveErrorReporter | undefined = defaultShellPathErrorReporter,
): string | undefined {
const deadline = Date.now() + PATH_REPAIR_DEADLINE_MS;

for (const shell of shells) {
if (Date.now() >= deadline) {
return undefined;
}

try {
const result = readPathFromLoginShell(shell, execFile, (_failedShell, _args, error) => {
onError?.(shell, error);
});
if (result) {
return result;
}
} catch {
// Per-attempt failures are already reported via onError when enabled.
}
}

return undefined;
}

function pathEntries(pathValue: string | undefined): Set<string> {
return new Set(
(pathValue ?? "")
.split(":")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
);
}

export function shouldRepairPath(
platform = process.platform,
pathValue = process.env.PATH,
homePath = process.env.HOME ?? homedir(),
): boolean {
if (platform !== "darwin" && platform !== "linux") {
return false;
}

const entries = pathEntries(pathValue);
if (entries.size === 0) {
return true;
}

if (platform === "darwin") {
return !entries.has("/opt/homebrew/bin") && !entries.has("/usr/local/bin");
}

return !entries.has(`${homePath}/.local/bin`) && !entries.has("/usr/local/bin");
}