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
74 changes: 70 additions & 4 deletions apps/desktop/src/syncShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,31 @@ describe("syncShellEnvironment", () => {
it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
PATH: "/Users/test/.local/bin:/usr/bin",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
HOMEBREW_PREFIX: "/opt/homebrew",
}));

syncShellEnvironment(env, {
platform: "darwin",
readEnvironment,
});

expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
"PATH",
"SSH_AUTH_SOCK",
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
]);
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew");
});

it("preserves an inherited SSH_AUTH_SOCK value", () => {
Expand Down Expand Up @@ -77,11 +87,67 @@ describe("syncShellEnvironment", () => {
readEnvironment,
});

expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
"PATH",
"SSH_AUTH_SOCK",
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
]);
expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
});

it("falls back to launchctl PATH on macOS when shell probing does not return one", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/opt/homebrew/bin/nu",
PATH: "/usr/bin",
};
const readEnvironment = vi
.fn()
.mockImplementationOnce(() => {
throw new Error("unknown flag");
})
.mockImplementationOnce(() => ({}));
const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
const logWarning = vi.fn();

syncShellEnvironment(env, {
platform: "darwin",
readEnvironment,
readLaunchctlPath,
userShell: "/bin/zsh",
logWarning,
});

expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [
"PATH",
"SSH_AUTH_SOCK",
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
]);
expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [
"PATH",
"SSH_AUTH_SOCK",
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
]);
expect(readLaunchctlPath).toHaveBeenCalledTimes(1);
expect(logWarning).toHaveBeenCalledWith(
"Failed to read login shell environment from /opt/homebrew/bin/nu.",
expect.any(Error),
);
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
});

it("does nothing outside macOS and linux", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "C:/Program Files/Git/bin/bash.exe",
Expand Down
67 changes: 55 additions & 12 deletions apps/desktop/src/syncShellEnvironment.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,79 @@
import {
listLoginShellCandidates,
mergePathEntries,
readPathFromLaunchctl,
readEnvironmentFromLoginShell,
resolveLoginShell,
ShellEnvironmentReader,
} from "@t3tools/shared/shell";

const LOGIN_SHELL_ENV_NAMES = [
"PATH",
"SSH_AUTH_SOCK",
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
] as const;

function logShellEnvironmentWarning(message: string, error?: unknown): void {
console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? ""));
}

export function syncShellEnvironment(
env: NodeJS.ProcessEnv = process.env,
options: {
platform?: NodeJS.Platform;
readEnvironment?: ShellEnvironmentReader;
readLaunchctlPath?: typeof readPathFromLaunchctl;
userShell?: string;
logWarning?: (message: string, error?: unknown) => void;
} = {},
): void {
const platform = options.platform ?? process.platform;
if (platform !== "darwin" && platform !== "linux") return;

try {
const shell = resolveLoginShell(platform, env.SHELL);
if (!shell) return;
const logWarning = options.logWarning ?? logShellEnvironmentWarning;
const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
const shellEnvironment: Partial<Record<string, string>> = {};

const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
"PATH",
"SSH_AUTH_SOCK",
]);
try {
for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
try {
Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));
if (shellEnvironment.PATH) {
break;
}
} catch (error) {
logWarning(`Failed to read login shell environment from ${shell}.`, error);
}
}

if (shellEnvironment.PATH) {
env.PATH = shellEnvironment.PATH;
const launchctlPath =
platform === "darwin" && !shellEnvironment.PATH
? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
: undefined;
const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (mergedPath) {
env.PATH = mergedPath;
}

if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
}
} catch {
// Keep inherited environment if shell lookup fails.

for (const name of [
"HOMEBREW_PREFIX",
"HOMEBREW_CELLAR",
"HOMEBREW_REPOSITORY",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
] as const) {
if (!env[name] && shellEnvironment[name]) {
env[name] = shellEnvironment[name];
}
}
} catch (error) {
logWarning("Failed to synchronize the desktop shell environment.", error);
}
}
35 changes: 34 additions & 1 deletion apps/server/src/os-jank.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe("fixPath", () => {
it("hydrates PATH on linux using the resolved login shell", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
PATH: "/Users/test/.local/bin:/usr/bin",
};
const readPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");

Expand All @@ -17,6 +17,39 @@ describe("fixPath", () => {
});

expect(readPath).toHaveBeenCalledWith("/bin/zsh");
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
});

it("falls back to launchctl PATH on macOS when shell probing fails", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/opt/homebrew/bin/nu",
PATH: "/usr/bin",
};
const readPath = vi
.fn()
.mockImplementationOnce(() => {
throw new Error("unknown flag");
})
.mockImplementationOnce(() => undefined);
const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
const logWarning = vi.fn();

fixPath({
env,
platform: "darwin",
readPath,
readLaunchctlPath,
userShell: "/bin/zsh",
logWarning,
});

expect(readPath).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu");
expect(readPath).toHaveBeenNthCalledWith(2, "/bin/zsh");
expect(readLaunchctlPath).toHaveBeenCalledTimes(1);
expect(logWarning).toHaveBeenCalledWith(
"Failed to read PATH from login shell /opt/homebrew/bin/nu.",
expect.any(Error),
);
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
});

Expand Down
45 changes: 37 additions & 8 deletions apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,57 @@
import * as OS from "node:os";
import { Effect, Path } from "effect";
import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell";
import {
listLoginShellCandidates,
mergePathEntries,
readPathFromLaunchctl,
readPathFromLoginShell,
} from "@t3tools/shared/shell";

function logPathHydrationWarning(message: string, error?: unknown): void {
console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? ""));
}

export function fixPath(
options: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
readPath?: typeof readPathFromLoginShell;
readLaunchctlPath?: typeof readPathFromLaunchctl;
userShell?: string;
logWarning?: (message: string, error?: unknown) => void;
} = {},
): void {
const platform = options.platform ?? process.platform;
if (platform !== "darwin" && platform !== "linux") return;

const env = options.env ?? process.env;
const logWarning = options.logWarning ?? logPathHydrationWarning;
const readPath = options.readPath ?? readPathFromLoginShell;

try {
const shell = resolveLoginShell(platform, env.SHELL);
if (!shell) return;
const result = (options.readPath ?? readPathFromLoginShell)(shell);
if (result) {
env.PATH = result;
let shellPath: string | undefined;
for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
try {
shellPath = readPath(shell);
} catch (error) {
logWarning(`Failed to read PATH from login shell ${shell}.`, error);
}

if (shellPath) {
break;
}
}

const launchctlPath =
platform === "darwin" && !shellPath
? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
: undefined;
const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform);
if (mergedPath) {
env.PATH = mergedPath;
}
} catch {
// Silently ignore — keep default PATH
} catch (error) {
logWarning("Failed to hydrate PATH from the user environment.", error);
}
}

Expand Down
62 changes: 62 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, it, vi } from "vitest";

import {
extractPathFromShellOutput,
listLoginShellCandidates,
mergePathEntries,
readEnvironmentFromLoginShell,
readPathFromLaunchctl,
readPathFromLoginShell,
} from "./shell";

Expand Down Expand Up @@ -60,6 +63,38 @@ describe("readPathFromLoginShell", () => {
});
});

describe("readPathFromLaunchctl", () => {
it("returns a trimmed PATH value from launchctl", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>(() => " /opt/homebrew/bin:/usr/bin \n");

expect(readPathFromLaunchctl(execFile)).toBe("/opt/homebrew/bin:/usr/bin");
expect(execFile).toHaveBeenCalledWith("/bin/launchctl", ["getenv", "PATH"], {
encoding: "utf8",
timeout: 2000,
});
});

it("returns undefined when launchctl is unavailable", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>(() => {
throw new Error("spawn /bin/launchctl ENOENT");
});

expect(readPathFromLaunchctl(execFile)).toBeUndefined();
});
});

describe("readEnvironmentFromLoginShell", () => {
it("extracts multiple environment variables from a login shell command", () => {
const execFile = vi.fn<
Expand Down Expand Up @@ -126,3 +161,30 @@ describe("readEnvironmentFromLoginShell", () => {
});
});
});

describe("listLoginShellCandidates", () => {
it("returns env shell, user shell, then the platform fallback without duplicates", () => {
expect(listLoginShellCandidates("darwin", " /opt/homebrew/bin/nu ", "/bin/zsh")).toEqual([
"/opt/homebrew/bin/nu",
"/bin/zsh",
]);
});

it("falls back to the platform default when no shells are available", () => {
expect(listLoginShellCandidates("linux", undefined, "")).toEqual(["/bin/bash"]);
});
});

describe("mergePathEntries", () => {
it("prefers login-shell PATH entries and keeps inherited extras", () => {
expect(
mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"),
).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
});

it("uses the platform-specific delimiter", () => {
expect(mergePathEntries("C:\\Tools;C:\\Windows", "C:\\Windows;C:\\Git", "win32")).toBe(
"C:\\Tools;C:\\Windows;C:\\Git",
);
});
});
Loading
Loading