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
6 changes: 3 additions & 3 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ describe("DesktopShellEnvironment", () => {
Effect.gen(function* () {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/Users/test/.local/bin:/usr/bin",
PATH: " /Users/test/.local/bin:/usr/bin",
};
const commands: ChildProcess.Command[] = [];

Expand All @@ -496,7 +496,7 @@ describe("DesktopShellEnvironment", () => {
handler: (command) => {
commands.push(command);
return envOutput({
PATH: "/opt/homebrew/bin:/usr/bin",
PATH: "/opt/homebrew/bin:/usr/bin:/opt/bin ",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
HOMEBREW_PREFIX: "/opt/homebrew",
});
Expand All @@ -505,7 +505,7 @@ describe("DesktopShellEnvironment", () => {

assert.equal(commands.length, 1);
assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh");
assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/opt/bin : /Users/test/.local/bin");
assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew");
assert.isTrue(prepared);
Expand Down
46 changes: 13 additions & 33 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
applyShellEnvironment,
buildEnvironmentCaptureCommand,
listLoginShellCandidates,
mergePathValues,
readUserLoginShell,
setWindowsPath,
} from "@t3tools/shared/shell";
Expand Down Expand Up @@ -131,10 +132,12 @@ const trimNonEmpty = (value: string | null | undefined): Option.Option<string> =
Option.filter((entry) => entry.length > 0),
);

const pathDelimiter = (platform: NodeJS.Platform) => (platform === "win32" ? ";" : ":");
// POSIX directory names may begin or end with whitespace; mergePathValues trims only on Windows.
const nonEmptyPath = (value: string | null | undefined): Option.Option<string> =>
Option.fromNullishOr(value).pipe(Option.filter((entry) => entry.length > 0));

const readEnvPath = (env: NodeJS.ProcessEnv): Option.Option<string> =>
trimNonEmpty(env.PATH ?? env.Path ?? env.path);
nonEmptyPath(env.PATH ?? env.Path ?? env.path);

const normalizeRuntimeDir = (value: string): string => value.replace(/\/+$/u, "");

Expand All @@ -153,39 +156,16 @@ const linuxRuntimeDirCandidates = (
return candidates.filter((candidate) => candidate.length > 0);
};

const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => {
const normalized = entry.trim().replace(/^"+|"+$/g, "");
return platform === "win32" ? normalized.toLowerCase() : normalized;
};

const sanitizePathEntry = (entry: string, platform: NodeJS.Platform) =>
platform === "win32" ? entry.replaceAll('"', "") : entry;

const mergePaths = (
platform: NodeJS.Platform,
values: ReadonlyArray<Option.Option<string>>,
): Option.Option<string> => {
const delimiter = pathDelimiter(platform);
const entries: string[] = [];
const seen = new Set<string>();

for (const value of values) {
if (Option.isNone(value)) continue;

for (const entry of value.value.split(delimiter)) {
const sanitized = sanitizePathEntry(entry.trim(), platform);
if (sanitized.length === 0) continue;

const key = pathComparisonKey(sanitized, platform);
if (key.length === 0 || seen.has(key)) continue;

seen.add(key);
entries.push(sanitized);
}
}

return entries.length > 0 ? Option.some(entries.join(delimiter)) : Option.none();
};
): Option.Option<string> =>
Option.fromNullishOr(
values.reduce<string | undefined>(
(merged, value) => mergePathValues(merged, Option.getOrUndefined(value), platform),
undefined,
),
);

const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
...trimNonEmpty(env.APPDATA).pipe(
Expand Down Expand Up @@ -484,7 +464,7 @@ const installPosixEnvironment = Effect.fn("desktop.shellEnvironment.installPosix
? yield* readLaunchctlPath(config.env)
: Option.none<string>();
const mergedPath = mergePaths(config.platform, [
trimNonEmpty(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)),
nonEmptyPath(shellEnvironment.PATH).pipe(Option.orElse(() => launchctlPath)),
readEnvPath(config.env),
]);

Expand Down
29 changes: 29 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ describe("mergePathEntries", () => {
expect(
mergePathEntries("/opt/homebrew/bin:/usr/bin", "/Users/test/.local/bin:/usr/bin", "darwin"),
).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
expect(mergePathEntries("/opt/bin :/opt/bin", "/opt/bin :/usr/bin", "linux")).toBe(
"/opt/bin :/opt/bin:/usr/bin",
);
});
});

Expand Down Expand Up @@ -511,10 +514,36 @@ describe("mergePathValues", () => {
expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe(
"/usr/local/bin:/usr/bin:/USR/BIN",
);
expect(mergePathValues("/opt/bin :/opt/bin", "/opt/bin :/usr/bin", "linux")).toBe(
"/opt/bin :/opt/bin:/usr/bin",
);
});
});

effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
it.effect("finds executables in a POSIX PATH directory ending in whitespace", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* fs.makeTempDirectoryScoped({ directory: ".", prefix: "t3-path-" });
const directory = `${cwd}/bin \u00a0`;
yield* fs.makeDirectory(directory);
const executable = `${directory}/audit-command`;
yield* fs.writeFileString(executable, "#!/bin/sh\n");
yield* fs.chmod(executable, 0o755);
const env = { PATH: directory };

expect(
yield* resolveCommandPath("audit-command", { env }).pipe(
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provideService(CommandResolutionCache, new Map()),
),
).toBe(path.join(directory, "audit-command"));
const resolveExecutable = yield* SpawnExecutableResolution;
expect(resolveExecutable("audit-command", "linux", env)).toBe(executable);
}),
);

const recordProbes = (env: NodeJS.ProcessEnv) =>
Effect.gen(function* () {
const probed: Array<string> = [];
Expand Down
14 changes: 9 additions & 5 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ function resolveSpawnExecutableWithNode(
}

for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) {
const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim());
const normalizedPathEntry = stripWrappingQuotes(trimPathEntry(pathEntry, platform));
if (normalizedPathEntry.length === 0) continue;
for (const candidate of candidates) {
const candidatePath = path.join(normalizedPathEntry, candidate);
Expand Down Expand Up @@ -188,7 +188,7 @@ export function mergePathEntries(
for (const pathValue of [preferredPath, inheritedPath]) {
if (!pathValue) continue;
for (const entry of pathValue.split(delimiter)) {
const trimmedEntry = entry.trim();
const trimmedEntry = trimPathEntry(entry, platform);
if (!trimmedEntry || seen.has(trimmedEntry)) {
continue;
}
Expand Down Expand Up @@ -443,12 +443,16 @@ function stripWrappingQuotes(value: string): string {
return value.replace(/^"+|"+$/g, "");
}

function trimPathEntry(entry: string, platform: NodeJS.Platform): string {
return platform === "win32" ? entry.trim() : entry;
}

function pathDelimiterForPlatform(platform: NodeJS.Platform): string {
return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER;
}

function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string {
const normalized = stripWrappingQuotes(entry.trim());
const normalized = stripWrappingQuotes(trimPathEntry(entry, platform));
return platform === "win32" ? normalized.toLowerCase() : normalized;
}

Expand All @@ -469,7 +473,7 @@ export function mergePathValues(
if (!rawValue) continue;

for (const entry of rawValue.split(delimiter)) {
const sanitized = sanitizePathEntry(entry.trim(), platform);
const sanitized = sanitizePathEntry(trimPathEntry(entry, platform), platform);
if (sanitized.length === 0) continue;

const normalized = normalizePathEntryForComparison(sanitized, platform);
Expand Down Expand Up @@ -657,7 +661,7 @@ const findOnPath = Effect.fnUntraced(function* (
const pathEntries: string[] = [];
const seenPathEntries = new Set<string>();
for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) {
const pathEntry = stripWrappingQuotes(entry.trim());
const pathEntry = stripWrappingQuotes(trimPathEntry(entry, platform));
if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue;

seenPathEntries.add(pathEntry);
Expand Down
Loading