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
15 changes: 0 additions & 15 deletions apps/desktop/src/fixPath.ts

This file was deleted.

4 changes: 2 additions & 2 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type { ContextMenuItem } from "@t3tools/contracts";
import { NetService } from "@t3tools/shared/Net";
import { RotatingFileSink } from "@t3tools/shared/logging";
import { showDesktopConfirmDialog } from "./confirmDialog";
import { fixPath } from "./fixPath";
import { syncShellEnvironment } from "./syncShellEnvironment";
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
import {
createInitialDesktopUpdateState,
Expand All @@ -44,7 +44,7 @@ import {
} from "./updateMachine";
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";

fixPath();
syncShellEnvironment();

const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
const CONFIRM_CHANNEL = "desktop:confirm";
Expand Down
85 changes: 85 additions & 0 deletions apps/desktop/src/syncShellEnvironment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";

import { syncShellEnvironment } from "./syncShellEnvironment";

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",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
}));

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

expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
});

it("preserves an inherited SSH_AUTH_SOCK value", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/login-shell.sock",
}));

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

expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
});

it("preserves inherited values when the login shell omits them", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
}));

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

expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
});

it("does nothing outside macOS", () => {
const env: NodeJS.ProcessEnv = {
SHELL: "/bin/zsh",
PATH: "/usr/bin",
SSH_AUTH_SOCK: "/tmp/inherited.sock",
};
const readEnvironment = vi.fn(() => ({
PATH: "/opt/homebrew/bin:/usr/bin",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
}));

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

expect(readEnvironment).not.toHaveBeenCalled();
expect(env.PATH).toBe("/usr/bin");
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
});
});
31 changes: 31 additions & 0 deletions apps/desktop/src/syncShellEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { readEnvironmentFromLoginShell } from "@t3tools/shared/shell";

type ShellEnvironmentReader = typeof readEnvironmentFromLoginShell;

export function syncShellEnvironment(
env: NodeJS.ProcessEnv = process.env,
options: {
platform?: NodeJS.Platform;
readEnvironment?: ShellEnvironmentReader;
} = {},
): void {
if ((options.platform ?? process.platform) !== "darwin") return;

try {
const shell = env.SHELL ?? "/bin/zsh";
const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
"PATH",
"SSH_AUTH_SOCK",
]);

if (shellEnvironment.PATH) {
env.PATH = shellEnvironment.PATH;
}

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.
}
}
87 changes: 82 additions & 5 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, expect, it, vi } from "vitest";

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

describe("extractPathFromShellOutput", () => {
it("extracts the path between capture markers", () => {
Expand Down Expand Up @@ -32,7 +36,7 @@ describe("readPathFromLoginShell", () => {
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>(() => "__T3CODE_PATH_START__\n/a:/b\n__T3CODE_PATH_END__\n");
>(() => "__T3CODE_ENV_PATH_START__\n/a:/b\n__T3CODE_ENV_PATH_END__\n");

expect(readPathFromLoginShell("/opt/homebrew/bin/fish", execFile)).toBe("/a:/b");
expect(execFile).toHaveBeenCalledTimes(1);
Expand All @@ -49,9 +53,82 @@ describe("readPathFromLoginShell", () => {
expect(shell).toBe("/opt/homebrew/bin/fish");
expect(args).toHaveLength(2);
expect(args?.[0]).toBe("-ilc");
expect(args?.[1]).toContain("printenv PATH");
expect(args?.[1]).toContain("__T3CODE_PATH_START__");
expect(args?.[1]).toContain("__T3CODE_PATH_END__");
expect(args?.[1]).toContain("printenv PATH || true");
expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__");
expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__");
expect(options).toEqual({ encoding: "utf8", timeout: 5000 });
});
});

describe("readEnvironmentFromLoginShell", () => {
it("extracts multiple environment variables from a login shell command", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>(() =>
[
"__T3CODE_ENV_PATH_START__",
"/a:/b",
"__T3CODE_ENV_PATH_END__",
"__T3CODE_ENV_SSH_AUTH_SOCK_START__",
"/tmp/secretive.sock",
"__T3CODE_ENV_SSH_AUTH_SOCK_END__",
].join("\n"),
);

expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual(
{
PATH: "/a:/b",
SSH_AUTH_SOCK: "/tmp/secretive.sock",
},
);
expect(execFile).toHaveBeenCalledTimes(1);
});

it("omits environment variables that are missing or empty", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>(() =>
[
"__T3CODE_ENV_PATH_START__",
"/a:/b",
"__T3CODE_ENV_PATH_END__",
"__T3CODE_ENV_SSH_AUTH_SOCK_START__",
"__T3CODE_ENV_SSH_AUTH_SOCK_END__",
].join("\n"),
);

expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual(
{
PATH: "/a:/b",
},
);
});

it("preserves surrounding whitespace in captured values", () => {
const execFile = vi.fn<
(
file: string,
args: ReadonlyArray<string>,
options: { encoding: "utf8"; timeout: number },
) => string
>(() =>
[
"__T3CODE_ENV_CUSTOM_VAR_START__",
" padded value ",
"__T3CODE_ENV_CUSTOM_VAR_END__",
].join("\n"),
);

expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({
CUSTOM_VAR: " padded value ",
});
});
});
76 changes: 69 additions & 7 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ import { execFileSync } from "node:child_process";

const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
const PATH_CAPTURE_END = "__T3CODE_PATH_END__";
const PATH_CAPTURE_COMMAND = [
`printf '%s\n' '${PATH_CAPTURE_START}'`,
"printenv PATH",
`printf '%s\n' '${PATH_CAPTURE_END}'`,
].join("; ");
const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;

type ExecFileSyncLike = (
file: string,
Expand All @@ -30,9 +26,75 @@ export function readPathFromLoginShell(
shell: string,
execFile: ExecFileSyncLike = execFileSync,
): string | undefined {
const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], {
return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH;
}

function envCaptureStart(name: string): string {
return `__T3CODE_ENV_${name}_START__`;
}

function envCaptureEnd(name: string): string {
return `__T3CODE_ENV_${name}_END__`;
}

function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {
return names
.map((name) => {
if (!SHELL_ENV_NAME_PATTERN.test(name)) {
throw new Error(`Unsupported environment variable name: ${name}`);
}

return [
`printf '%s\\n' '${envCaptureStart(name)}'`,
`printenv ${name} || true`,
`printf '%s\\n' '${envCaptureEnd(name)}'`,
].join("; ");
})
.join("; ");
}

function extractEnvironmentValue(output: string, name: string): string | undefined {
const startMarker = envCaptureStart(name);
const endMarker = envCaptureEnd(name);
const startIndex = output.indexOf(startMarker);
if (startIndex === -1) return undefined;

const valueStartIndex = startIndex + startMarker.length;
const endIndex = output.indexOf(endMarker, valueStartIndex);
if (endIndex === -1) return undefined;

let value = output.slice(valueStartIndex, endIndex);
if (value.startsWith("\n")) {
value = value.slice(1);
}
if (value.endsWith("\n")) {
value = value.slice(0, -1);
}

return value.length > 0 ? value : undefined;
}

export function readEnvironmentFromLoginShell(
shell: string,
names: ReadonlyArray<string>,
execFile: ExecFileSyncLike = execFileSync,
): Partial<Record<string, string>> {
if (names.length === 0) {
return {};
}

const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], {
encoding: "utf8",
timeout: 5000,
});
return extractPathFromShellOutput(output) ?? undefined;

const environment: Partial<Record<string, string>> = {};
for (const name of names) {
const value = extractEnvironmentValue(output, name);
if (value !== undefined) {
environment[name] = value;
}
}

return environment;
}
Loading