Skip to content

Commit a7a44d0

Browse files
Fix Windows PATH hydration and repair (#1729)
Co-authored-by: Julius Marminge <julius0216@outlook.com>
1 parent d8d3296 commit a7a44d0

11 files changed

Lines changed: 1049 additions & 165 deletions

File tree

apps/desktop/src/syncShellEnvironment.test.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ describe("syncShellEnvironment", () => {
148148
expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
149149
});
150150

151-
it("does nothing outside macOS and linux", () => {
151+
it("does nothing on unsupported platforms", () => {
152152
const env: NodeJS.ProcessEnv = {
153153
SHELL: "C:/Program Files/Git/bin/bash.exe",
154154
PATH: "C:\\Windows\\System32",
@@ -160,12 +160,130 @@ describe("syncShellEnvironment", () => {
160160
}));
161161

162162
syncShellEnvironment(env, {
163-
platform: "win32",
163+
platform: "freebsd",
164164
readEnvironment,
165165
});
166166

167167
expect(readEnvironment).not.toHaveBeenCalled();
168168
expect(env.PATH).toBe("C:\\Windows\\System32");
169169
expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
170170
});
171+
172+
it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => {
173+
const env: NodeJS.ProcessEnv = {
174+
PATH: "C:\\Windows\\System32",
175+
APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
176+
LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
177+
USERPROFILE: "C:\\Users\\testuser",
178+
};
179+
const readWindowsEnvironment = vi.fn(() => ({
180+
PATH: "C:\\Custom\\Bin;C:\\Windows\\System32",
181+
}));
182+
const isWindowsCommandAvailable = vi.fn(() => true);
183+
184+
syncShellEnvironment(env, {
185+
platform: "win32",
186+
readWindowsEnvironment,
187+
isWindowsCommandAvailable,
188+
});
189+
190+
expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
191+
expect(env.PATH).toBe(
192+
[
193+
"C:\\Users\\testuser\\AppData\\Roaming\\npm",
194+
"C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
195+
"C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
196+
"C:\\Users\\testuser\\AppData\\Local\\pnpm",
197+
"C:\\Users\\testuser\\.bun\\bin",
198+
"C:\\Users\\testuser\\scoop\\shims",
199+
"C:\\Custom\\Bin",
200+
"C:\\Windows\\System32",
201+
].join(";"),
202+
);
203+
expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1);
204+
});
205+
206+
it("loads the PowerShell profile on Windows when node is not available", () => {
207+
const env: NodeJS.ProcessEnv = {
208+
PATH: "C:\\Windows\\System32",
209+
APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
210+
LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
211+
USERPROFILE: "C:\\Users\\testuser",
212+
};
213+
const readWindowsEnvironment = vi.fn(
214+
(_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
215+
options?.loadProfile
216+
? {
217+
PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
218+
FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
219+
FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
220+
}
221+
: { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" },
222+
);
223+
const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
224+
225+
syncShellEnvironment(env, {
226+
platform: "win32",
227+
readWindowsEnvironment,
228+
isWindowsCommandAvailable,
229+
});
230+
231+
expect(env.PATH).toBe(
232+
[
233+
"C:\\Profile\\Node",
234+
"C:\\Windows\\System32",
235+
"C:\\Users\\testuser\\AppData\\Roaming\\npm",
236+
"C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
237+
"C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
238+
"C:\\Users\\testuser\\AppData\\Local\\pnpm",
239+
"C:\\Users\\testuser\\.bun\\bin",
240+
"C:\\Users\\testuser\\scoop\\shims",
241+
"C:\\Custom\\Bin",
242+
].join(";"),
243+
);
244+
expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm");
245+
expect(env.FNM_MULTISHELL_PATH).toBe(
246+
"C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
247+
);
248+
expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
249+
expect(readWindowsEnvironment).toHaveBeenNthCalledWith(
250+
2,
251+
["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
252+
{ loadProfile: true },
253+
);
254+
});
255+
256+
it("preserves baseline Windows env when the profile probe fails", () => {
257+
const env: NodeJS.ProcessEnv = {
258+
PATH: "C:\\Windows\\System32",
259+
APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
260+
USERPROFILE: "C:\\Users\\testuser",
261+
};
262+
const readWindowsEnvironment = vi.fn(
263+
(_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => {
264+
if (options?.loadProfile) {
265+
throw new Error("profile load failed");
266+
}
267+
return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" };
268+
},
269+
);
270+
const isWindowsCommandAvailable = vi.fn(() => false);
271+
272+
syncShellEnvironment(env, {
273+
platform: "win32",
274+
readWindowsEnvironment,
275+
isWindowsCommandAvailable,
276+
});
277+
278+
expect(env.PATH).toBe(
279+
[
280+
"C:\\Users\\testuser\\AppData\\Roaming\\npm",
281+
"C:\\Users\\testuser\\.bun\\bin",
282+
"C:\\Users\\testuser\\scoop\\shims",
283+
"C:\\Custom\\Bin",
284+
"C:\\Windows\\System32",
285+
].join(";"),
286+
);
287+
expect(env.SSH_AUTH_SOCK).toBeUndefined();
288+
});
171289
});

apps/desktop/src/syncShellEnvironment.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,18 @@ import {
33
mergePathEntries,
44
readPathFromLaunchctl,
55
readEnvironmentFromLoginShell,
6+
resolveWindowsEnvironment,
67
} from "@t3tools/shared/shell";
7-
import type { ShellEnvironmentReader } from "@t3tools/shared/shell";
8+
import type {
9+
CommandAvailabilityOptions,
10+
ShellEnvironmentReader,
11+
WindowsShellEnvironmentReader,
12+
} from "@t3tools/shared/shell";
13+
14+
type WindowsCommandAvailabilityChecker = (
15+
command: string,
16+
options?: CommandAvailabilityOptions,
17+
) => boolean;
818

919
const LOGIN_SHELL_ENV_NAMES = [
1020
"PATH",
@@ -25,19 +35,39 @@ export function syncShellEnvironment(
2535
options: {
2636
platform?: NodeJS.Platform;
2737
readEnvironment?: ShellEnvironmentReader;
38+
readWindowsEnvironment?: WindowsShellEnvironmentReader;
39+
isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
2840
readLaunchctlPath?: typeof readPathFromLaunchctl;
2941
userShell?: string;
3042
logWarning?: (message: string, error?: unknown) => void;
3143
} = {},
3244
): void {
3345
const platform = options.platform ?? process.platform;
34-
if (platform !== "darwin" && platform !== "linux") return;
3546

3647
const logWarning = options.logWarning ?? logShellEnvironmentWarning;
3748
const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
3849
const shellEnvironment: Partial<Record<string, string>> = {};
3950

4051
try {
52+
if (platform === "win32") {
53+
const repairedEnvironment = resolveWindowsEnvironment(env, {
54+
...(options.readWindowsEnvironment
55+
? { readEnvironment: options.readWindowsEnvironment }
56+
: {}),
57+
...(options.isWindowsCommandAvailable
58+
? { commandAvailable: options.isWindowsCommandAvailable }
59+
: {}),
60+
});
61+
for (const [key, value] of Object.entries(repairedEnvironment)) {
62+
if (value !== undefined) {
63+
env[key] = value;
64+
}
65+
}
66+
return;
67+
}
68+
69+
if (platform !== "darwin" && platform !== "linux") return;
70+
4171
for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
4272
try {
4373
Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));

apps/server/src/open.ts

Lines changed: 2 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,17 @@
77
* @module Open
88
*/
99
import { spawn } from "node:child_process";
10-
import { accessSync, constants, statSync } from "node:fs";
11-
import { extname, join } from "node:path";
1210

1311
import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
12+
import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";
1413
import { Context, Effect, Layer } from "effect";
1514

1615
// ==============================
1716
// Definitions
1817
// ==============================
1918

2019
export { OpenError };
20+
export { isCommandAvailable } from "@t3tools/shared/shell";
2121

2222
export interface OpenInEditorInput {
2323
readonly cwd: string;
@@ -29,11 +29,6 @@ interface EditorLaunch {
2929
readonly args: ReadonlyArray<string>;
3030
}
3131

32-
interface CommandAvailabilityOptions {
33-
readonly platform?: NodeJS.Platform;
34-
readonly env?: NodeJS.ProcessEnv;
35-
}
36-
3732
const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
3833

3934
function parseTargetPathAndPosition(target: string): {
@@ -106,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
106101
}
107102
}
108103

109-
function stripWrappingQuotes(value: string): string {
110-
return value.replace(/^"+|"+$/g, "");
111-
}
112-
113-
function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
114-
return env.PATH ?? env.Path ?? env.path ?? "";
115-
}
116-
117-
function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
118-
const rawValue = env.PATHEXT;
119-
const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
120-
if (!rawValue) return fallback;
121-
122-
const parsed = rawValue
123-
.split(";")
124-
.map((entry) => entry.trim())
125-
.filter((entry) => entry.length > 0)
126-
.map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));
127-
return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
128-
}
129-
130-
function resolveCommandCandidates(
131-
command: string,
132-
platform: NodeJS.Platform,
133-
windowsPathExtensions: ReadonlyArray<string>,
134-
): ReadonlyArray<string> {
135-
if (platform !== "win32") return [command];
136-
const extension = extname(command);
137-
const normalizedExtension = extension.toUpperCase();
138-
139-
if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {
140-
const commandWithoutExtension = command.slice(0, -extension.length);
141-
return Array.from(
142-
new Set([
143-
command,
144-
`${commandWithoutExtension}${normalizedExtension}`,
145-
`${commandWithoutExtension}${normalizedExtension.toLowerCase()}`,
146-
]),
147-
);
148-
}
149-
150-
const candidates: string[] = [];
151-
for (const extension of windowsPathExtensions) {
152-
candidates.push(`${command}${extension}`);
153-
candidates.push(`${command}${extension.toLowerCase()}`);
154-
}
155-
return Array.from(new Set(candidates));
156-
}
157-
158-
function isExecutableFile(
159-
filePath: string,
160-
platform: NodeJS.Platform,
161-
windowsPathExtensions: ReadonlyArray<string>,
162-
): boolean {
163-
try {
164-
const stat = statSync(filePath);
165-
if (!stat.isFile()) return false;
166-
if (platform === "win32") {
167-
const extension = extname(filePath);
168-
if (extension.length === 0) return false;
169-
return windowsPathExtensions.includes(extension.toUpperCase());
170-
}
171-
accessSync(filePath, constants.X_OK);
172-
return true;
173-
} catch {
174-
return false;
175-
}
176-
}
177-
178-
function resolvePathDelimiter(platform: NodeJS.Platform): string {
179-
return platform === "win32" ? ";" : ":";
180-
}
181-
182-
export function isCommandAvailable(
183-
command: string,
184-
options: CommandAvailabilityOptions = {},
185-
): boolean {
186-
const platform = options.platform ?? process.platform;
187-
const env = options.env ?? process.env;
188-
const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
189-
const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
190-
191-
if (command.includes("/") || command.includes("\\")) {
192-
return commandCandidates.some((candidate) =>
193-
isExecutableFile(candidate, platform, windowsPathExtensions),
194-
);
195-
}
196-
197-
const pathValue = resolvePathEnvironmentVariable(env);
198-
if (pathValue.length === 0) return false;
199-
const pathEntries = pathValue
200-
.split(resolvePathDelimiter(platform))
201-
.map((entry) => stripWrappingQuotes(entry.trim()))
202-
.filter((entry) => entry.length > 0);
203-
204-
for (const pathEntry of pathEntries) {
205-
for (const candidate of commandCandidates) {
206-
if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
207-
return true;
208-
}
209-
}
210-
}
211-
return false;
212-
}
213-
214104
export function resolveAvailableEditors(
215105
platform: NodeJS.Platform = process.platform,
216106
env: NodeJS.ProcessEnv = process.env,

0 commit comments

Comments
 (0)