Skip to content

Commit c69a429

Browse files
authored
Merge branch 'main' into attachment-bug
2 parents a015d4e + 765c1dc commit c69a429

6 files changed

Lines changed: 268 additions & 30 deletions

File tree

apps/desktop/src/fixPath.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

apps/desktop/src/main.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import type { ContextMenuItem } from "@t3tools/contracts";
2828
import { NetService } from "@t3tools/shared/Net";
2929
import { RotatingFileSink } from "@t3tools/shared/logging";
3030
import { showDesktopConfirmDialog } from "./confirmDialog";
31-
import { fixPath } from "./fixPath";
31+
import { syncShellEnvironment } from "./syncShellEnvironment";
3232
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
3333
import {
3434
createInitialDesktopUpdateState,
@@ -44,7 +44,7 @@ import {
4444
} from "./updateMachine";
4545
import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";
4646

47-
fixPath();
47+
syncShellEnvironment();
4848

4949
const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
5050
const CONFIRM_CHANNEL = "desktop:confirm";
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import { syncShellEnvironment } from "./syncShellEnvironment";
4+
5+
describe("syncShellEnvironment", () => {
6+
it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
7+
const env: NodeJS.ProcessEnv = {
8+
SHELL: "/bin/zsh",
9+
PATH: "/usr/bin",
10+
};
11+
const readEnvironment = vi.fn(() => ({
12+
PATH: "/opt/homebrew/bin:/usr/bin",
13+
SSH_AUTH_SOCK: "/tmp/secretive.sock",
14+
}));
15+
16+
syncShellEnvironment(env, {
17+
platform: "darwin",
18+
readEnvironment,
19+
});
20+
21+
expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);
22+
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
23+
expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
24+
});
25+
26+
it("preserves an inherited SSH_AUTH_SOCK value", () => {
27+
const env: NodeJS.ProcessEnv = {
28+
SHELL: "/bin/zsh",
29+
PATH: "/usr/bin",
30+
SSH_AUTH_SOCK: "/tmp/inherited.sock",
31+
};
32+
const readEnvironment = vi.fn(() => ({
33+
PATH: "/opt/homebrew/bin:/usr/bin",
34+
SSH_AUTH_SOCK: "/tmp/login-shell.sock",
35+
}));
36+
37+
syncShellEnvironment(env, {
38+
platform: "darwin",
39+
readEnvironment,
40+
});
41+
42+
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
43+
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
44+
});
45+
46+
it("preserves inherited values when the login shell omits them", () => {
47+
const env: NodeJS.ProcessEnv = {
48+
SHELL: "/bin/zsh",
49+
PATH: "/usr/bin",
50+
SSH_AUTH_SOCK: "/tmp/inherited.sock",
51+
};
52+
const readEnvironment = vi.fn(() => ({
53+
PATH: "/opt/homebrew/bin:/usr/bin",
54+
}));
55+
56+
syncShellEnvironment(env, {
57+
platform: "darwin",
58+
readEnvironment,
59+
});
60+
61+
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
62+
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
63+
});
64+
65+
it("does nothing outside macOS", () => {
66+
const env: NodeJS.ProcessEnv = {
67+
SHELL: "/bin/zsh",
68+
PATH: "/usr/bin",
69+
SSH_AUTH_SOCK: "/tmp/inherited.sock",
70+
};
71+
const readEnvironment = vi.fn(() => ({
72+
PATH: "/opt/homebrew/bin:/usr/bin",
73+
SSH_AUTH_SOCK: "/tmp/secretive.sock",
74+
}));
75+
76+
syncShellEnvironment(env, {
77+
platform: "linux",
78+
readEnvironment,
79+
});
80+
81+
expect(readEnvironment).not.toHaveBeenCalled();
82+
expect(env.PATH).toBe("/usr/bin");
83+
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
84+
});
85+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { readEnvironmentFromLoginShell, ShellEnvironmentReader } from "@t3tools/shared/shell";
2+
3+
export function syncShellEnvironment(
4+
env: NodeJS.ProcessEnv = process.env,
5+
options: {
6+
platform?: NodeJS.Platform;
7+
readEnvironment?: ShellEnvironmentReader;
8+
} = {},
9+
): void {
10+
if ((options.platform ?? process.platform) !== "darwin") return;
11+
12+
try {
13+
const shell = env.SHELL ?? "/bin/zsh";
14+
const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [
15+
"PATH",
16+
"SSH_AUTH_SOCK",
17+
]);
18+
19+
if (shellEnvironment.PATH) {
20+
env.PATH = shellEnvironment.PATH;
21+
}
22+
23+
if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
24+
env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
25+
}
26+
} catch {
27+
// Keep inherited environment if shell lookup fails.
28+
}
29+
}

packages/shared/src/shell.test.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it, vi } from "vitest";
22

3-
import { extractPathFromShellOutput, readPathFromLoginShell } from "./shell";
3+
import {
4+
extractPathFromShellOutput,
5+
readEnvironmentFromLoginShell,
6+
readPathFromLoginShell,
7+
} from "./shell";
48

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

3741
expect(readPathFromLoginShell("/opt/homebrew/bin/fish", execFile)).toBe("/a:/b");
3842
expect(execFile).toHaveBeenCalledTimes(1);
@@ -49,9 +53,76 @@ describe("readPathFromLoginShell", () => {
4953
expect(shell).toBe("/opt/homebrew/bin/fish");
5054
expect(args).toHaveLength(2);
5155
expect(args?.[0]).toBe("-ilc");
52-
expect(args?.[1]).toContain("printenv PATH");
53-
expect(args?.[1]).toContain("__T3CODE_PATH_START__");
54-
expect(args?.[1]).toContain("__T3CODE_PATH_END__");
56+
expect(args?.[1]).toContain("printenv PATH || true");
57+
expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__");
58+
expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__");
5559
expect(options).toEqual({ encoding: "utf8", timeout: 5000 });
5660
});
5761
});
62+
63+
describe("readEnvironmentFromLoginShell", () => {
64+
it("extracts multiple environment variables from a login shell command", () => {
65+
const execFile = vi.fn<
66+
(
67+
file: string,
68+
args: ReadonlyArray<string>,
69+
options: { encoding: "utf8"; timeout: number },
70+
) => string
71+
>(() =>
72+
[
73+
"__T3CODE_ENV_PATH_START__",
74+
"/a:/b",
75+
"__T3CODE_ENV_PATH_END__",
76+
"__T3CODE_ENV_SSH_AUTH_SOCK_START__",
77+
"/tmp/secretive.sock",
78+
"__T3CODE_ENV_SSH_AUTH_SOCK_END__",
79+
].join("\n"),
80+
);
81+
82+
expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({
83+
PATH: "/a:/b",
84+
SSH_AUTH_SOCK: "/tmp/secretive.sock",
85+
});
86+
expect(execFile).toHaveBeenCalledTimes(1);
87+
});
88+
89+
it("omits environment variables that are missing or empty", () => {
90+
const execFile = vi.fn<
91+
(
92+
file: string,
93+
args: ReadonlyArray<string>,
94+
options: { encoding: "utf8"; timeout: number },
95+
) => string
96+
>(() =>
97+
[
98+
"__T3CODE_ENV_PATH_START__",
99+
"/a:/b",
100+
"__T3CODE_ENV_PATH_END__",
101+
"__T3CODE_ENV_SSH_AUTH_SOCK_START__",
102+
"__T3CODE_ENV_SSH_AUTH_SOCK_END__",
103+
].join("\n"),
104+
);
105+
106+
expect(readEnvironmentFromLoginShell("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"], execFile)).toEqual({
107+
PATH: "/a:/b",
108+
});
109+
});
110+
111+
it("preserves surrounding whitespace in captured values", () => {
112+
const execFile = vi.fn<
113+
(
114+
file: string,
115+
args: ReadonlyArray<string>,
116+
options: { encoding: "utf8"; timeout: number },
117+
) => string
118+
>(() =>
119+
["__T3CODE_ENV_CUSTOM_VAR_START__", " padded value ", "__T3CODE_ENV_CUSTOM_VAR_END__"].join(
120+
"\n",
121+
),
122+
);
123+
124+
expect(readEnvironmentFromLoginShell("/bin/zsh", ["CUSTOM_VAR"], execFile)).toEqual({
125+
CUSTOM_VAR: " padded value ",
126+
});
127+
});
128+
});

packages/shared/src/shell.ts

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { execFileSync } from "node:child_process";
22

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

117
type ExecFileSyncLike = (
128
file: string,
@@ -30,9 +26,81 @@ export function readPathFromLoginShell(
3026
shell: string,
3127
execFile: ExecFileSyncLike = execFileSync,
3228
): string | undefined {
33-
const output = execFile(shell, ["-ilc", PATH_CAPTURE_COMMAND], {
29+
return readEnvironmentFromLoginShell(shell, ["PATH"], execFile).PATH;
30+
}
31+
32+
function envCaptureStart(name: string): string {
33+
return `__T3CODE_ENV_${name}_START__`;
34+
}
35+
36+
function envCaptureEnd(name: string): string {
37+
return `__T3CODE_ENV_${name}_END__`;
38+
}
39+
40+
function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {
41+
return names
42+
.map((name) => {
43+
if (!SHELL_ENV_NAME_PATTERN.test(name)) {
44+
throw new Error(`Unsupported environment variable name: ${name}`);
45+
}
46+
47+
return [
48+
`printf '%s\\n' '${envCaptureStart(name)}'`,
49+
`printenv ${name} || true`,
50+
`printf '%s\\n' '${envCaptureEnd(name)}'`,
51+
].join("; ");
52+
})
53+
.join("; ");
54+
}
55+
56+
function extractEnvironmentValue(output: string, name: string): string | undefined {
57+
const startMarker = envCaptureStart(name);
58+
const endMarker = envCaptureEnd(name);
59+
const startIndex = output.indexOf(startMarker);
60+
if (startIndex === -1) return undefined;
61+
62+
const valueStartIndex = startIndex + startMarker.length;
63+
const endIndex = output.indexOf(endMarker, valueStartIndex);
64+
if (endIndex === -1) return undefined;
65+
66+
let value = output.slice(valueStartIndex, endIndex);
67+
if (value.startsWith("\n")) {
68+
value = value.slice(1);
69+
}
70+
if (value.endsWith("\n")) {
71+
value = value.slice(0, -1);
72+
}
73+
74+
return value.length > 0 ? value : undefined;
75+
}
76+
77+
export type ShellEnvironmentReader = (
78+
shell: string,
79+
names: ReadonlyArray<string>,
80+
execFile?: ExecFileSyncLike,
81+
) => Partial<Record<string, string>>;
82+
83+
export const readEnvironmentFromLoginShell: ShellEnvironmentReader = (
84+
shell,
85+
names,
86+
execFile = execFileSync,
87+
) => {
88+
if (names.length === 0) {
89+
return {};
90+
}
91+
92+
const output = execFile(shell, ["-ilc", buildEnvironmentCaptureCommand(names)], {
3493
encoding: "utf8",
3594
timeout: 5000,
3695
});
37-
return extractPathFromShellOutput(output) ?? undefined;
38-
}
96+
97+
const environment: Partial<Record<string, string>> = {};
98+
for (const name of names) {
99+
const value = extractEnvironmentValue(output, name);
100+
if (value !== undefined) {
101+
environment[name] = value;
102+
}
103+
}
104+
105+
return environment;
106+
};

0 commit comments

Comments
 (0)