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
69 changes: 69 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,75 @@ describe("DesktopShellEnvironment", () => {
}),
);

it.effect("falls back to known CLI directories on macOS when neither probe returns a PATH", () =>
Effect.gen(function* () {
// The Finder-launch environment: no SHELL, and the bare PATH launchd hands a .app.
const env: NodeJS.ProcessEnv = {
HOME: "/Users/test",
PATH: "/usr/bin:/bin:/usr/sbin:/sbin",
};

yield* runShellEnvironment({
env,
platform: "darwin",
handler: () => "",
});

assert.equal(
env.PATH,
"/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/Users/test/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
);
}),
);

it.effect("falls back to known CLI directories on linux when the login shell is silent", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = { HOME: "/home/test", PATH: "/usr/bin:/bin" };

yield* runShellEnvironment({
env,
platform: "linux",
handler: () => "",
});

assert.equal(
env.PATH,
"/home/linuxbrew/.linuxbrew/bin:/usr/local/bin:/home/test/.local/bin:/usr/bin:/bin",
);
}),
);

it.effect("omits the known CLI directories when the login shell reports a PATH", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", HOME: "/Users/test", PATH: "/usr/bin" };

yield* runShellEnvironment({
env,
platform: "darwin",
handler: () => envOutput({ PATH: "/Users/test/.rvm/bin:/usr/bin" }),
});

assert.equal(env.PATH, "/Users/test/.rvm/bin:/usr/bin");
}),
);

it.effect("omits the known CLI directories when only launchctl reports a PATH", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", HOME: "/Users/test", PATH: "/usr/bin" };

yield* runShellEnvironment({
env,
platform: "darwin",
handler: (command) =>
command._tag === "StandardCommand" && command.command === "/bin/launchctl"
? "/usr/local/opt/bin:/usr/bin"
: "",
});

assert.equal(env.PATH, "/usr/local/opt/bin:/usr/bin");
}),
);

it.effect("loads PowerShell profile environment on Windows", () =>
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as Schema from "effect/Schema";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

import { resolveKnownPosixCliDirs } from "@t3tools/shared/shell";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";

type EnvironmentPatch = Record<string, string>;
Expand Down Expand Up @@ -442,7 +444,16 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
? yield* readLaunchctlPath
: Option.none<string>();
const mergedPath = mergePaths(config.platform, [
trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)),
trimNonEmpty(shellEnvironment.PATH).pipe(
Option.orElse(() => launchctlPath),
Option.orElse(() =>
trimNonEmpty(
resolveKnownPosixCliDirs(config.env, config.platform).join(
pathDelimiter(config.platform),
),
),
),
),
readEnvPath(config.env),
]);

Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/os-jank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
mergePathEntries,
readPathFromLoginShell,
readPathFromLaunchctl,
resolveKnownPosixCliDirs,
resolveWindowsEnvironment,
} from "@t3tools/shared/shell";
import * as Effect from "effect/Effect";
Expand All @@ -30,7 +31,12 @@ function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): vo
}

const launchctlPath = platform === "darwin" && !shellPath ? readPathFromLaunchctl() : undefined;
const mergedPath = mergePathEntries(shellPath ?? launchctlPath, env.PATH, platform);
const knownCliPath = resolveKnownPosixCliDirs(env, platform).join(":");
const mergedPath = mergePathEntries(
shellPath ?? launchctlPath ?? knownCliPath,
env.PATH,
platform,
);
if (mergedPath) {
env.PATH = mergedPath;
}
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
readPathFromLaunchctl,
readPathFromLoginShell,
resolveCommandPath,
resolveKnownPosixCliDirs,
resolveKnownWindowsCliDirs,
resolveSpawnCommand,
resolveWindowsEnvironment,
Expand Down Expand Up @@ -353,6 +354,33 @@ describe("resolveKnownWindowsCliDirs", () => {
});
});

describe("resolveKnownPosixCliDirs", () => {
it("returns known macOS CLI install directories in priority order", () => {
expect(resolveKnownPosixCliDirs({ HOME: "/Users/testuser" }, "darwin")).toEqual([
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
"/Users/testuser/.local/bin",
]);
});

it("returns known Linux CLI install directories in priority order", () => {
expect(resolveKnownPosixCliDirs({ HOME: "/home/testuser" }, "linux")).toEqual([
"/home/linuxbrew/.linuxbrew/bin",
"/usr/local/bin",
"/home/testuser/.local/bin",
]);
});

it("omits the home directory entry when HOME is unset or blank", () => {
expect(resolveKnownPosixCliDirs({ HOME: " " }, "darwin")).toEqual([
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/usr/local/bin",
]);
});
});

effectIt.layer(NodeServices.layer)("isCommandAvailable", (it) => {
it.effect("returns false when PATH is empty", () =>
Effect.gen(function* () {
Expand Down
15 changes: 15 additions & 0 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,21 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra
];
}

// Both PATH probes can come back empty: a slow profile trips the login-shell timeout, and
// `launchctl getenv PATH` is unset on recent macOS. That leaves a GUI launch on the bare
// `/usr/bin:/bin:/usr/sbin:/sbin` PATH where no user-installed CLI can be spawned.
export function resolveKnownPosixCliDirs(
env: NodeJS.ProcessEnv,
platform: NodeJS.Platform,
): ReadonlyArray<string> {
const home = env.HOME?.trim();
const homeDirs = home ? [`${home}/.local/bin`] : [];

return platform === "darwin"
? ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", ...homeDirs]
: ["/home/linuxbrew/.linuxbrew/bin", "/usr/local/bin", ...homeDirs];
}

function readWindowsEnvironmentSafely(
readEnvironment: WindowsShellEnvironmentReader,
names: ReadonlyArray<string>,
Expand Down
Loading