authorgravatar for contact@badbird.devEvan Yu <contact@badbird.dev> 2026-04-17 02:12:17-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-17 06:12:17+00:00
loga7a44d0662fb026009e8a5b5d58a5012e2e8665d
tree8ad20f6959c785cf63348e66fa7985d546dfb317
parentd8d329691a3db759d6054af35e373005e9efcabd
signature Signed by PGP key B5690EEEBB952194

Fix Windows PATH hydration and repair (#1729)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

11 files changed, 1049 insertions(+), 165 deletions(-)

apps/desktop/src/syncShellEnvironment.test.ts+120-2
......@@ -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,7 +160,7 @@ describe("syncShellEnvironment", () => {
160160 }));
161161
162162 syncShellEnvironment(env, {
163 platform: "win32",
163 platform: "freebsd",
164164 readEnvironment,
165165 });
166166
......@@ -168,4 +168,122 @@ describe("syncShellEnvironment", () => {
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+32-2
......@@ -3,8 +3,18 @@ import {
33 mergePathEntries,
44 readPathFromLaunchctl,
55 readEnvironmentFromLoginShell,
6 resolveWindowsEnvironment,
67} from "@t3tools/shared/shell";
7import type { ShellEnvironmentReader } from "@t3tools/shared/shell";
8import type {
9 CommandAvailabilityOptions,
10 ShellEnvironmentReader,
11 WindowsShellEnvironmentReader,
12} from "@t3tools/shared/shell";
13
14type WindowsCommandAvailabilityChecker = (
15 command: string,
16 options?: CommandAvailabilityOptions,
17) => boolean;
818
919const 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+2-112
......@@ -7,10 +7,9 @@
77 * @module Open
88 */
99import { spawn } from "node:child_process";
10import { accessSync, constants, statSync } from "node:fs";
11import { extname, join } from "node:path";
1210
1311import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
12import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";
1413import { Context, Effect, Layer } from "effect";
1514
1615// ==============================
......@@ -18,6 +17,7 @@ import { Context, Effect, Layer } from "effect";
1817// ==============================
1918
2019export { OpenError };
20export { isCommandAvailable } from "@t3tools/shared/shell";
2121
2222export interface OpenInEditorInput {
2323 readonly cwd: string;
......@@ -29,11 +29,6 @@ interface EditorLaunch {
2929 readonly args: ReadonlyArray<string>;
3030}
3131
32interface CommandAvailabilityOptions {
33 readonly platform?: NodeJS.Platform;
34 readonly env?: NodeJS.ProcessEnv;
35}
36
3732const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
3833
3934function parseTargetPathAndPosition(target: string): {
......@@ -106,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
106101 }
107102}
108103
109function stripWrappingQuotes(value: string): string {
110 return value.replace(/^"+|"+$/g, "");
111}
112
113function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
114 return env.PATH ?? env.Path ?? env.path ?? "";
115}
116
117function 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
130function 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
158function 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
178function resolvePathDelimiter(platform: NodeJS.Platform): string {
179 return platform === "win32" ? ";" : ":";
180}
181
182export 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
214104export function resolveAvailableEditors(
215105 platform: NodeJS.Platform = process.platform,
216106 env: NodeJS.ProcessEnv = process.env,
apps/server/src/os-jank.test.ts+115-2
......@@ -53,7 +53,120 @@ describe("fixPath", () => {
5353 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
5454 });
5555
56 it("does nothing outside macOS and linux even when SHELL is set", () => {
56 it("repairs PATH on Windows by merging PowerShell PATH with inherited PATH", () => {
57 const env: NodeJS.ProcessEnv = {
58 PATH: "C:\\Windows\\System32",
59 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
60 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
61 USERPROFILE: "C:\\Users\\testuser",
62 };
63 const readWindowsEnvironment = vi.fn(() => ({
64 PATH: "C:\\Custom\\Bin;C:\\Windows\\System32",
65 }));
66 const isWindowsCommandAvailable = vi.fn(() => true);
67
68 fixPath({
69 env,
70 platform: "win32",
71 readWindowsEnvironment,
72 isWindowsCommandAvailable,
73 });
74
75 expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
76 expect(env.PATH).toBe(
77 [
78 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
79 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
80 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
81 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
82 "C:\\Users\\testuser\\.bun\\bin",
83 "C:\\Users\\testuser\\scoop\\shims",
84 "C:\\Custom\\Bin",
85 "C:\\Windows\\System32",
86 ].join(";"),
87 );
88 });
89
90 it("applies profile-derived fnm variables on Windows when node is missing", () => {
91 const env: NodeJS.ProcessEnv = {
92 PATH: "C:\\Windows\\System32",
93 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
94 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
95 USERPROFILE: "C:\\Users\\testuser",
96 };
97 const readWindowsEnvironment = vi.fn(
98 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
99 options?.loadProfile
100 ? {
101 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
102 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
103 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
104 }
105 : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" },
106 );
107 const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
108
109 fixPath({
110 env,
111 platform: "win32",
112 readWindowsEnvironment,
113 isWindowsCommandAvailable,
114 });
115
116 expect(env.PATH).toBe(
117 [
118 "C:\\Profile\\Node",
119 "C:\\Windows\\System32",
120 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
121 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
122 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
123 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
124 "C:\\Users\\testuser\\.bun\\bin",
125 "C:\\Users\\testuser\\scoop\\shims",
126 "C:\\Custom\\Bin",
127 ].join(";"),
128 );
129 expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm");
130 expect(env.FNM_MULTISHELL_PATH).toBe(
131 "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
132 );
133 });
134
135 it("preserves baseline PATH on Windows when the profile probe fails", () => {
136 const env: NodeJS.ProcessEnv = {
137 PATH: "C:\\Windows\\System32",
138 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
139 USERPROFILE: "C:\\Users\\testuser",
140 };
141 const readWindowsEnvironment = vi.fn(
142 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => {
143 if (options?.loadProfile) {
144 throw new Error("profile load failed");
145 }
146 return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" };
147 },
148 );
149 const isWindowsCommandAvailable = vi.fn(() => false);
150
151 fixPath({
152 env,
153 platform: "win32",
154 readWindowsEnvironment,
155 isWindowsCommandAvailable,
156 });
157
158 expect(env.PATH).toBe(
159 [
160 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
161 "C:\\Users\\testuser\\.bun\\bin",
162 "C:\\Users\\testuser\\scoop\\shims",
163 "C:\\Custom\\Bin",
164 "C:\\Windows\\System32",
165 ].join(";"),
166 );
167 });
168
169 it("does nothing on unsupported platforms", () => {
57170 const env: NodeJS.ProcessEnv = {
58171 SHELL: "C:/Program Files/Git/bin/bash.exe",
59172 PATH: "C:\\Windows\\System32",
......@@ -62,7 +175,7 @@ describe("fixPath", () => {
62175
63176 fixPath({
64177 env,
65 platform: "win32",
178 platform: "freebsd",
66179 readPath,
67180 });
68181
apps/server/src/os-jank.ts+29-3
......@@ -1,12 +1,21 @@
11import * as OS from "node:os";
22import { Effect, Path } from "effect";
33import {
4 readPathFromLoginShell,
5 readEnvironmentFromWindowsShell,
6 resolveWindowsEnvironment,
7 type CommandAvailabilityOptions,
8 type WindowsShellEnvironmentReader,
49 listLoginShellCandidates,
510 mergePathEntries,
611 readPathFromLaunchctl,
7 readPathFromLoginShell,
812} from "@t3tools/shared/shell";
913
14type WindowsCommandAvailabilityChecker = (
15 command: string,
16 options?: CommandAvailabilityOptions,
17) => boolean;
18
1019function logPathHydrationWarning(message: string, error?: unknown): void {
1120 console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? ""));
1221}
......@@ -16,19 +25,36 @@ export function fixPath(
1625 env?: NodeJS.ProcessEnv;
1726 platform?: NodeJS.Platform;
1827 readPath?: typeof readPathFromLoginShell;
28 readWindowsEnvironment?: WindowsShellEnvironmentReader;
29 isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
1930 readLaunchctlPath?: typeof readPathFromLaunchctl;
2031 userShell?: string;
2132 logWarning?: (message: string, error?: unknown) => void;
2233 } = {},
2334): void {
2435 const platform = options.platform ?? process.platform;
25 if (platform !== "darwin" && platform !== "linux") return;
26
2736 const env = options.env ?? process.env;
2837 const logWarning = options.logWarning ?? logPathHydrationWarning;
2938 const readPath = options.readPath ?? readPathFromLoginShell;
3039
3140 try {
41 if (platform === "win32") {
42 const repairedEnvironment = resolveWindowsEnvironment(env, {
43 readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell,
44 ...(options.isWindowsCommandAvailable
45 ? { commandAvailable: options.isWindowsCommandAvailable }
46 : {}),
47 });
48 for (const [key, value] of Object.entries(repairedEnvironment)) {
49 if (value !== undefined) {
50 env[key] = value;
51 }
52 }
53 return;
54 }
55
56 if (platform !== "darwin" && platform !== "linux") return;
57
3258 let shellPath: string | undefined;
3359 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
3460 try {
apps/server/src/terminal/Layers/Manager.test.ts+68-3
......@@ -194,6 +194,8 @@ function multiTerminalHistoryLogPath(
194194
195195interface CreateManagerOptions {
196196 shellResolver?: () => string;
197 platform?: NodeJS.Platform;
198 env?: NodeJS.ProcessEnv;
197199 subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>;
198200 subprocessPollIntervalMs?: number;
199201 processKillGraceMs?: number;
......@@ -228,6 +230,8 @@ const createManager = (
228230 historyLineLimit,
229231 ptyAdapter,
230232 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
233 ...(options.platform !== undefined ? { platform: options.platform } : {}),
234 ...(options.env !== undefined ? { env: options.env } : {}),
231235 ...(options.subprocessChecker !== undefined
232236 ? { subprocessChecker: options.subprocessChecker }
233237 : {}),
......@@ -297,6 +301,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
297301
298302 it.effect("preserves non-notFound cwd stat failures", () =>
299303 Effect.gen(function* () {
304 if (process.platform === "win32") return;
305
300306 const { manager, baseDir } = yield* createManager();
301307 const blockedRoot = path.join(baseDir, "blocked-root");
302308 const blockedCwd = path.join(blockedRoot, "cwd");
......@@ -827,8 +833,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
827833
828834 it.effect("retries with fallback shells when preferred shell spawn fails", () =>
829835 Effect.gen(function* () {
836 const missingShell =
837 process.platform === "win32"
838 ? "C:\\definitely\\missing-shell.exe"
839 : "/definitely/missing-shell -l";
830840 const { manager, ptyAdapter } = yield* createManager(5, {
831 shellResolver: () => "/definitely/missing-shell -l",
841 shellResolver: () => missingShell,
832842 });
833843 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
834844
......@@ -836,12 +846,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
836846
837847 assert.equal(snapshot.status, "running");
838848 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);
839 expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell");
849 expect(ptyAdapter.spawnInputs[0]?.shell).toBe(
850 process.platform === "win32" ? missingShell : "/definitely/missing-shell",
851 );
840852
841853 if (process.platform === "win32") {
842854 expect(
843855 ptyAdapter.spawnInputs.some(
844 (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe",
856 (input) =>
857 input.shell === "pwsh.exe" ||
858 input.shell === "powershell.exe" ||
859 input.shell === "cmd.exe",
845860 ),
846861 ).toBe(true);
847862 } else {
......@@ -854,6 +869,56 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
854869 }),
855870 );
856871
872 it.effect("prefers PowerShell over ComSpec for Windows terminals", () =>
873 Effect.gen(function* () {
874 const { manager, ptyAdapter } = yield* createManager(5, {
875 platform: "win32",
876 env: {
877 ComSpec: "C:\\Windows\\System32\\cmd.exe",
878 PATH: "C:\\Windows\\System32",
879 SystemRoot: "C:\\Windows",
880 },
881 });
882
883 yield* manager.open(openInput());
884
885 expect(ptyAdapter.spawnInputs[0]).toEqual(
886 expect.objectContaining({
887 shell: "pwsh.exe",
888 args: ["-NoLogo"],
889 }),
890 );
891 }),
892 );
893
894 it.effect("falls back to built-in PowerShell by absolute path on Windows", () =>
895 Effect.gen(function* () {
896 const { manager, ptyAdapter } = yield* createManager(5, {
897 platform: "win32",
898 env: {
899 ComSpec: "C:\\Windows\\System32\\cmd.exe",
900 PATH: "C:\\Windows\\System32",
901 SystemRoot: "C:\\Windows",
902 },
903 shellResolver: () => "C:\\missing\\custom-shell.exe",
904 });
905 ptyAdapter.spawnFailures.push(
906 new Error("spawn custom-shell.exe ENOENT"),
907 new Error("spawn pwsh.exe ENOENT"),
908 );
909
910 yield* manager.open(openInput());
911
912 expect(ptyAdapter.spawnInputs.map((input) => input.shell)).toEqual([
913 "C:\\missing\\custom-shell.exe",
914 "pwsh.exe",
915 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
916 ]);
917 expect(ptyAdapter.spawnInputs[1]?.args).toEqual(["-NoLogo"]);
918 expect(ptyAdapter.spawnInputs[2]?.args).toEqual(["-NoLogo"]);
919 }),
920 );
921
857922 it.effect("filters app runtime env variables from terminal sessions", () =>
858923 Effect.gen(function* () {
859924 const originalValues = new Map<string, string | undefined>();
apps/server/src/terminal/Layers/Manager.ts+72-25
......@@ -186,19 +186,25 @@ function enqueueProcessEvent(
186186 return true;
187187}
188188
189function defaultShellResolver(): string {
190 if (process.platform === "win32") {
191 return process.env.ComSpec ?? "cmd.exe";
189function defaultShellResolver(
190 platform: NodeJS.Platform = process.platform,
191 env: NodeJS.ProcessEnv = process.env,
192): string {
193 if (platform === "win32") {
194 return "pwsh.exe";
192195 }
193 return process.env.SHELL ?? "bash";
196 return env.SHELL ?? "bash";
194197}
195198
196function normalizeShellCommand(value: string | undefined): string | null {
199function normalizeShellCommand(
200 value: string | undefined,
201 platform: NodeJS.Platform = process.platform,
202): string | null {
197203 if (!value) return null;
198204 const trimmed = value.trim();
199205 if (trimmed.length === 0) return null;
200206
201 if (process.platform === "win32") {
207 if (platform === "win32") {
202208 return trimmed;
203209 }
204210
......@@ -207,15 +213,42 @@ function normalizeShellCommand(value: string | undefined): string | null {
207213 return firstToken.replace(/^['"]|['"]$/g, "");
208214}
209215
210function shellCandidateFromCommand(command: string | null): ShellCandidate | null {
216function shellCandidateFromCommand(
217 command: string | null,
218 platform: NodeJS.Platform = process.platform,
219): ShellCandidate | null {
211220 if (!command || command.length === 0) return null;
212 const shellName = path.basename(command).toLowerCase();
213 if (process.platform !== "win32" && shellName === "zsh") {
221 const shellName =
222 platform === "win32"
223 ? path.win32.basename(command).toLowerCase()
224 : path.basename(command).toLowerCase();
225 if (platform === "win32" && (shellName === "pwsh.exe" || shellName === "powershell.exe")) {
226 return { shell: command, args: ["-NoLogo"] };
227 }
228 if (platform !== "win32" && shellName === "zsh") {
214229 return { shell: command, args: ["-o", "nopromptsp"] };
215230 }
216231 return { shell: command };
217232}
218233
234function windowsSystemRoot(env: NodeJS.ProcessEnv): string {
235 return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows";
236}
237
238function windowsPowerShellPath(env: NodeJS.ProcessEnv): string {
239 return path.win32.join(
240 windowsSystemRoot(env),
241 "System32",
242 "WindowsPowerShell",
243 "v1.0",
244 "powershell.exe",
245 );
246}
247
248function windowsCmdPath(env: NodeJS.ProcessEnv): string {
249 return path.win32.join(windowsSystemRoot(env), "System32", "cmd.exe");
250}
251
219252function formatShellCandidate(candidate: ShellCandidate): string {
220253 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
221254 return `${candidate.shell} ${candidate.args.join(" ")}`;
......@@ -234,27 +267,37 @@ function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellC
234267 return ordered;
235268}
236269
237function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {
238 const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver()));
270function resolveShellCandidates(
271 shellResolver: () => string,
272 platform: NodeJS.Platform = process.platform,
273 env: NodeJS.ProcessEnv = process.env,
274): ShellCandidate[] {
275 const requested = shellCandidateFromCommand(
276 normalizeShellCommand(shellResolver(), platform),
277 platform,
278 );
239279
240 if (process.platform === "win32") {
280 if (platform === "win32") {
241281 return uniqueShellCandidates([
242282 requested,
243 shellCandidateFromCommand(process.env.ComSpec ?? null),
244 shellCandidateFromCommand("powershell.exe"),
245 shellCandidateFromCommand("cmd.exe"),
283 shellCandidateFromCommand("pwsh.exe", platform),
284 shellCandidateFromCommand(windowsPowerShellPath(env), platform),
285 shellCandidateFromCommand("powershell.exe", platform),
286 shellCandidateFromCommand(env.ComSpec ?? null, platform),
287 shellCandidateFromCommand(windowsCmdPath(env), platform),
288 shellCandidateFromCommand("cmd.exe", platform),
246289 ]);
247290 }
248291
249292 return uniqueShellCandidates([
250293 requested,
251 shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)),
252 shellCandidateFromCommand("/bin/zsh"),
253 shellCandidateFromCommand("/bin/bash"),
254 shellCandidateFromCommand("/bin/sh"),
255 shellCandidateFromCommand("zsh"),
256 shellCandidateFromCommand("bash"),
257 shellCandidateFromCommand("sh"),
294 shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform),
295 shellCandidateFromCommand("/bin/zsh", platform),
296 shellCandidateFromCommand("/bin/bash", platform),
297 shellCandidateFromCommand("/bin/sh", platform),
298 shellCandidateFromCommand("zsh", platform),
299 shellCandidateFromCommand("bash", platform),
300 shellCandidateFromCommand("sh", platform),
258301 ]);
259302}
260303
......@@ -651,6 +694,8 @@ interface TerminalManagerOptions {
651694 historyLineLimit?: number;
652695 ptyAdapter: PtyAdapterShape;
653696 shellResolver?: () => string;
697 platform?: NodeJS.Platform;
698 env?: NodeJS.ProcessEnv;
654699 subprocessChecker?: TerminalSubprocessChecker;
655700 subprocessPollIntervalMs?: number;
656701 processKillGraceMs?: number;
......@@ -674,7 +719,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
674719
675720 const logsDir = options.logsDir;
676721 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;
677 const shellResolver = options.shellResolver ?? defaultShellResolver;
722 const platform = options.platform ?? process.platform;
723 const baseEnv = options.env ?? process.env;
724 const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv));
678725 const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
679726 const subprocessPollIntervalMs =
680727 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
......@@ -1337,8 +1384,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
13371384 increment(terminalSessionsTotal, { lifecycle: eventType }).pipe(
13381385 Effect.andThen(
13391386 Effect.gen(function* () {
1340 const shellCandidates = resolveShellCandidates(shellResolver);
1341 const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv);
1387 const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv);
1388 const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv);
13421389 const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session);
13431390 ptyProcess = spawnResult.process;
13441391 startedShell = spawnResult.shellLabel;
apps/server/src/terminal/Layers/NodePTY.ts+21-7
......@@ -2,7 +2,12 @@ import { createRequire } from "node:module";
22
33import { Effect, FileSystem, Layer, Path } from "effect";
44import { PtyAdapter } from "../Services/PTY.ts";
5import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts";
5import {
6 PtySpawnError,
7 type PtyAdapterShape,
8 type PtyExitEvent,
9 type PtyProcess,
10} from "../Services/PTY.ts";
611
712let didEnsureSpawnHelperExecutable = false;
813
......@@ -108,12 +113,21 @@ export const layer = Layer.effect(
108113 return {
109114 spawn: Effect.fn(function* (input) {
110115 yield* ensureNodePtySpawnHelperExecutableCached;
111 const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], {
112 cwd: input.cwd,
113 cols: input.cols,
114 rows: input.rows,
115 env: input.env,
116 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",
116 const ptyProcess = yield* Effect.try({
117 try: () =>
118 nodePty.spawn(input.shell, input.args ?? [], {
119 cwd: input.cwd,
120 cols: input.cols,
121 rows: input.rows,
122 env: input.env,
123 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",
124 }),
125 catch: (cause) =>
126 new PtySpawnError({
127 adapter: "node-pty",
128 message: cause instanceof Error ? cause.message : "Failed to spawn PTY process",
129 cause,
130 }),
117131 });
118132 return new NodePtyProcess(ptyProcess);
119133 }),
packages/shared/src/shell.test.ts+268
......@@ -2,11 +2,16 @@ import { describe, expect, it, vi } from "vitest";
22
33import {
44 extractPathFromShellOutput,
5 isCommandAvailable,
56 listLoginShellCandidates,
67 mergePathEntries,
8 mergePathValues,
79 readEnvironmentFromLoginShell,
10 readEnvironmentFromWindowsShell,
811 readPathFromLaunchctl,
912 readPathFromLoginShell,
13 resolveKnownWindowsCliDirs,
14 resolveWindowsEnvironment,
1015} from "./shell.ts";
1116
1217describe("extractPathFromShellOutput", () => {
......@@ -188,3 +193,266 @@ describe("mergePathEntries", () => {
188193 );
189194 });
190195});
196
197describe("readEnvironmentFromWindowsShell", () => {
198 it("extracts environment variables from a PowerShell command", () => {
199 const execFile = vi.fn<
200 (
201 file: string,
202 args: ReadonlyArray<string>,
203 options: { encoding: "utf8"; timeout: number },
204 ) => string
205 >(
206 () =>
207 "__T3CODE_ENV_PATH_START__\nC:\\Users\\testuser\\AppData\\Roaming\\npm\n__T3CODE_ENV_PATH_END__\n",
208 );
209
210 expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({
211 PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm",
212 });
213 expect(execFile).toHaveBeenCalledWith(
214 "pwsh.exe",
215 expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]),
216 { encoding: "utf8", timeout: 5000 },
217 );
218 });
219
220 it("strips CRLF delimiters from captured PowerShell values", () => {
221 const execFile = vi.fn<
222 (
223 file: string,
224 args: ReadonlyArray<string>,
225 options: { encoding: "utf8"; timeout: number },
226 ) => string
227 >(
228 () =>
229 "__T3CODE_ENV_FNM_DIR_START__\r\nC:\\Users\\testuser\\AppData\\Roaming\\fnm\r\n__T3CODE_ENV_FNM_DIR_END__\r\n",
230 );
231
232 expect(readEnvironmentFromWindowsShell(["FNM_DIR"], execFile)).toEqual({
233 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
234 });
235 });
236
237 it("omits -NoProfile when loadProfile is enabled", () => {
238 const execFile = vi.fn<
239 (
240 file: string,
241 args: ReadonlyArray<string>,
242 options: { encoding: "utf8"; timeout: number },
243 ) => string
244 >(() => "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n");
245
246 expect(readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, execFile)).toEqual({
247 PATH: "C:\\Tools",
248 });
249 expect(execFile).toHaveBeenCalledWith(
250 "pwsh.exe",
251 expect.arrayContaining(["-NoLogo", "-NonInteractive", "-Command"]),
252 { encoding: "utf8", timeout: 5000 },
253 );
254 expect(execFile.mock.calls[0]?.[1]).not.toContain("-NoProfile");
255 });
256
257 it("falls back to Windows PowerShell when pwsh.exe is unavailable", () => {
258 const execFile = vi.fn<
259 (
260 file: string,
261 args: ReadonlyArray<string>,
262 options: { encoding: "utf8"; timeout: number },
263 ) => string
264 >((file) => {
265 if (file === "pwsh.exe") {
266 throw new Error("spawn pwsh.exe ENOENT");
267 }
268 return "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n";
269 });
270
271 expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({
272 PATH: "C:\\Tools",
273 });
274 expect(execFile).toHaveBeenNthCalledWith(1, "pwsh.exe", expect.any(Array), {
275 encoding: "utf8",
276 timeout: 5000,
277 });
278 expect(execFile).toHaveBeenNthCalledWith(2, "powershell.exe", expect.any(Array), {
279 encoding: "utf8",
280 timeout: 5000,
281 });
282 });
283});
284
285describe("mergePathValues", () => {
286 it("dedupes case-insensitively on Windows while preserving preferred order", () => {
287 expect(
288 mergePathValues(
289 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"',
290 "c:\\users\\testuser\\appdata\\roaming\\npm;C:\\Windows\\System32",
291 "win32",
292 ),
293 ).toBe(
294 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs";C:\\Windows\\System32',
295 );
296 });
297
298 it("dedupes case-sensitively on POSIX", () => {
299 expect(mergePathValues("/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN", "linux")).toBe(
300 "/usr/local/bin:/usr/bin:/USR/BIN",
301 );
302 });
303});
304
305describe("resolveKnownWindowsCliDirs", () => {
306 it("returns known Windows CLI install directories in priority order", () => {
307 expect(
308 resolveKnownWindowsCliDirs({
309 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
310 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
311 USERPROFILE: "C:\\Users\\testuser",
312 }),
313 ).toEqual([
314 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
315 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
316 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
317 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
318 "C:\\Users\\testuser\\.bun\\bin",
319 "C:\\Users\\testuser\\scoop\\shims",
320 ]);
321 });
322});
323
324describe("isCommandAvailable", () => {
325 it("returns false when PATH is empty", () => {
326 expect(
327 isCommandAvailable("definitely-not-installed", {
328 platform: "win32",
329 env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" },
330 }),
331 ).toBe(false);
332 });
333});
334
335describe("resolveWindowsEnvironment", () => {
336 it("returns the baseline no-profile PATH patch when node is already available", () => {
337 const readEnvironment = vi.fn(
338 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
339 options?.loadProfile
340 ? { PATH: "C:\\Profile\\Bin" }
341 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
342 );
343 const commandAvailable = vi.fn(() => true);
344
345 expect(
346 resolveWindowsEnvironment(
347 {
348 PATH: "C:\\Windows\\System32",
349 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
350 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
351 USERPROFILE: "C:\\Users\\testuser",
352 },
353 {
354 readEnvironment,
355 commandAvailable,
356 },
357 ),
358 ).toEqual({
359 PATH: [
360 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
361 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
362 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
363 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
364 "C:\\Users\\testuser\\.bun\\bin",
365 "C:\\Users\\testuser\\scoop\\shims",
366 "C:\\Shell\\Bin",
367 "C:\\Windows\\System32",
368 ].join(";"),
369 });
370 expect(readEnvironment).toHaveBeenCalledTimes(1);
371 expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
372 expect(commandAvailable).toHaveBeenCalledWith(
373 "node",
374 expect.objectContaining({
375 platform: "win32",
376 }),
377 );
378 });
379
380 it("loads the PowerShell profile when baseline env cannot resolve node", () => {
381 const readEnvironment = vi.fn(
382 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
383 options?.loadProfile
384 ? {
385 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
386 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
387 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
388 }
389 : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" },
390 );
391 const commandAvailable = vi.fn(() => false);
392
393 expect(
394 resolveWindowsEnvironment(
395 {
396 PATH: "C:\\Windows\\System32",
397 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
398 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
399 USERPROFILE: "C:\\Users\\testuser",
400 },
401 {
402 readEnvironment,
403 commandAvailable,
404 },
405 ),
406 ).toEqual({
407 PATH: [
408 "C:\\Profile\\Node",
409 "C:\\Windows\\System32",
410 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
411 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
412 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
413 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
414 "C:\\Users\\testuser\\.bun\\bin",
415 "C:\\Users\\testuser\\scoop\\shims",
416 "C:\\Shell\\Bin",
417 ].join(";"),
418 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
419 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
420 });
421 expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
422 expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], {
423 loadProfile: true,
424 });
425 expect(commandAvailable).toHaveBeenCalledTimes(1);
426 });
427
428 it("keeps the baseline env when profiled probe still does not resolve node", () => {
429 const readEnvironment = vi.fn(
430 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
431 options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {},
432 );
433 const commandAvailable = vi.fn(() => false);
434
435 expect(
436 resolveWindowsEnvironment(
437 {
438 PATH: "C:\\Windows\\System32",
439 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
440 USERPROFILE: "C:\\Users\\testuser",
441 },
442 {
443 readEnvironment,
444 commandAvailable,
445 },
446 ),
447 ).toEqual({
448 PATH: [
449 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
450 "C:\\Users\\testuser\\.bun\\bin",
451 "C:\\Users\\testuser\\scoop\\shims",
452 "C:\\Windows\\System32",
453 ].join(";"),
454 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
455 });
456 expect(commandAvailable).toHaveBeenCalledTimes(1);
457 });
458});
packages/shared/src/shell.ts+317-7
......@@ -1,9 +1,14 @@
11import * as OS from "node:os";
22import { execFileSync } from "node:child_process";
3import { accessSync, constants, statSync } from "node:fs";
4import { extname, join } from "node:path";
35
46const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
57const PATH_CAPTURE_END = "__T3CODE_PATH_END__";
68const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;
9const WINDOWS_PATH_DELIMITER = ";";
10const POSIX_PATH_DELIMITER = ":";
11const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const;
712
813type ExecFileSyncLike = (
914 file: string,
......@@ -11,6 +16,15 @@ type ExecFileSyncLike = (
1116 options: { encoding: "utf8"; timeout: number },
1217) => string;
1318
19export interface CommandAvailabilityOptions {
20 readonly platform?: NodeJS.Platform;
21 readonly env?: NodeJS.ProcessEnv;
22}
23
24export interface WindowsEnvironmentProbeOptions {
25 readonly loadProfile?: boolean;
26}
27
1428function trimNonEmpty(value: string | null | undefined): string | undefined {
1529 const trimmed = value?.trim();
1630 return trimmed && trimmed.length > 0 ? trimmed : undefined;
......@@ -127,6 +141,24 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {
127141 .join("; ");
128142}
129143
144function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {
145 return [
146 "$ErrorActionPreference = 'Stop'",
147 ...names.flatMap((name) => {
148 if (!SHELL_ENV_NAME_PATTERN.test(name)) {
149 throw new Error(`Unsupported environment variable name: ${name}`);
150 }
151
152 return [
153 `Write-Output '${envCaptureStart(name)}'`,
154 `$value = [Environment]::GetEnvironmentVariable('${name}')`,
155 "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }",
156 `Write-Output '${envCaptureEnd(name)}'`,
157 ];
158 }),
159 ].join("; ");
160}
161
130162function extractEnvironmentValue(output: string, name: string): string | undefined {
131163 const startMarker = envCaptureStart(name);
132164 const endMarker = envCaptureEnd(name);
......@@ -137,13 +169,10 @@ function extractEnvironmentValue(output: string, name: string): string | undefin
137169 const endIndex = output.indexOf(endMarker, valueStartIndex);
138170 if (endIndex === -1) return undefined;
139171
140 let value = output.slice(valueStartIndex, endIndex);
141 if (value.startsWith("\n")) {
142 value = value.slice(1);
143 }
144 if (value.endsWith("\n")) {
145 value = value.slice(0, -1);
146 }
172 const value = output
173 .slice(valueStartIndex, endIndex)
174 .replace(/^\r?\n/, "")
175 .replace(/\r?\n$/, "");
147176
148177 return value.length > 0 ? value : undefined;
149178}
......@@ -178,3 +207,284 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = (
178207
179208 return environment;
180209};
210
211export type WindowsShellEnvironmentReader = (
212 names: ReadonlyArray<string>,
213 options?: WindowsEnvironmentProbeOptions,
214) => Partial<Record<string, string>>;
215
216export function readEnvironmentFromWindowsShell(
217 names: ReadonlyArray<string>,
218 execFile?: ExecFileSyncLike,
219): Partial<Record<string, string>>;
220export function readEnvironmentFromWindowsShell(
221 names: ReadonlyArray<string>,
222 options?: WindowsEnvironmentProbeOptions,
223 execFile?: ExecFileSyncLike,
224): Partial<Record<string, string>>;
225export function readEnvironmentFromWindowsShell(
226 names: ReadonlyArray<string>,
227 optionsOrExecFile?: WindowsEnvironmentProbeOptions | ExecFileSyncLike,
228 maybeExecFile?: ExecFileSyncLike,
229): Partial<Record<string, string>> {
230 if (names.length === 0) {
231 return {};
232 }
233
234 const options =
235 typeof optionsOrExecFile === "function"
236 ? ({} satisfies WindowsEnvironmentProbeOptions)
237 : (optionsOrExecFile ?? {});
238 const execFile: ExecFileSyncLike =
239 typeof optionsOrExecFile === "function"
240 ? optionsOrExecFile
241 : (maybeExecFile ?? (execFileSync as ExecFileSyncLike));
242 const command = buildWindowsEnvironmentCaptureCommand(names);
243 const args = [
244 "-NoLogo",
245 ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)),
246 "-NonInteractive",
247 "-Command",
248 command,
249 ];
250 for (const shell of WINDOWS_SHELL_CANDIDATES) {
251 try {
252 const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 });
253
254 const environment: Partial<Record<string, string>> = {};
255 for (const name of names) {
256 const value = extractEnvironmentValue(output, name);
257 if (value !== undefined) {
258 environment[name] = value;
259 }
260 }
261 return environment;
262 } catch {
263 continue;
264 }
265 }
266
267 return {};
268}
269
270function stripWrappingQuotes(value: string): string {
271 return value.replace(/^"+|"+$/g, "");
272}
273
274function pathDelimiterForPlatform(platform: NodeJS.Platform): string {
275 return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER;
276}
277
278function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string {
279 const normalized = stripWrappingQuotes(entry.trim());
280 return platform === "win32" ? normalized.toLowerCase() : normalized;
281}
282
283export function mergePathValues(
284 preferredPath: string | undefined,
285 inheritedPath: string | undefined,
286 platform: NodeJS.Platform,
287): string | undefined {
288 const delimiter = pathDelimiterForPlatform(platform);
289 const merged: string[] = [];
290 const seen = new Set<string>();
291
292 for (const rawValue of [preferredPath, inheritedPath]) {
293 if (!rawValue) continue;
294
295 for (const entry of rawValue.split(delimiter)) {
296 const trimmed = entry.trim();
297 if (trimmed.length === 0) continue;
298
299 const normalized = normalizePathEntryForComparison(trimmed, platform);
300 if (normalized.length === 0 || seen.has(normalized)) continue;
301
302 seen.add(normalized);
303 merged.push(trimmed);
304 }
305 }
306
307 return merged.length > 0 ? merged.join(delimiter) : undefined;
308}
309
310function readEnvPath(env: NodeJS.ProcessEnv): string | undefined {
311 return env.PATH ?? env.Path ?? env.path;
312}
313
314function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
315 return readEnvPath(env) ?? "";
316}
317
318function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
319 const rawValue = env.PATHEXT;
320 const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
321 if (!rawValue) return fallback;
322
323 const parsed = rawValue
324 .split(";")
325 .map((entry) => entry.trim())
326 .filter((entry) => entry.length > 0)
327 .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));
328 return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
329}
330
331function resolveCommandCandidates(
332 command: string,
333 platform: NodeJS.Platform,
334 windowsPathExtensions: ReadonlyArray<string>,
335): ReadonlyArray<string> {
336 if (platform !== "win32") return [command];
337 const extension = extname(command);
338 const normalizedExtension = extension.toUpperCase();
339
340 if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {
341 const commandWithoutExtension = command.slice(0, -extension.length);
342 return Array.from(
343 new Set([
344 command,
345 `${commandWithoutExtension}${normalizedExtension}`,
346 `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`,
347 ]),
348 );
349 }
350
351 const candidates: string[] = [];
352 for (const candidateExtension of windowsPathExtensions) {
353 candidates.push(`${command}${candidateExtension}`);
354 candidates.push(`${command}${candidateExtension.toLowerCase()}`);
355 }
356 return Array.from(new Set(candidates));
357}
358
359function isExecutableFile(
360 filePath: string,
361 platform: NodeJS.Platform,
362 windowsPathExtensions: ReadonlyArray<string>,
363): boolean {
364 try {
365 const stat = statSync(filePath);
366 if (!stat.isFile()) return false;
367 if (platform === "win32") {
368 const extension = extname(filePath);
369 if (extension.length === 0) return false;
370 return windowsPathExtensions.includes(extension.toUpperCase());
371 }
372 accessSync(filePath, constants.X_OK);
373 return true;
374 } catch {
375 return false;
376 }
377}
378
379export function isCommandAvailable(
380 command: string,
381 options: CommandAvailabilityOptions = {},
382): boolean {
383 const platform = options.platform ?? process.platform;
384 const env = options.env ?? process.env;
385 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
386 const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
387
388 if (command.includes("/") || command.includes("\\")) {
389 return commandCandidates.some((candidate) =>
390 isExecutableFile(candidate, platform, windowsPathExtensions),
391 );
392 }
393
394 const pathValue = resolvePathEnvironmentVariable(env);
395 if (pathValue.length === 0) return false;
396 const pathEntries = pathValue
397 .split(pathDelimiterForPlatform(platform))
398 .map((entry) => stripWrappingQuotes(entry.trim()))
399 .filter((entry) => entry.length > 0);
400
401 for (const pathEntry of pathEntries) {
402 for (const candidate of commandCandidates) {
403 if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
404 return true;
405 }
406 }
407 }
408 return false;
409}
410
411export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
412 const appData = env.APPDATA?.trim();
413 const localAppData = env.LOCALAPPDATA?.trim();
414 const userProfile = env.USERPROFILE?.trim();
415
416 return [
417 ...(appData ? [`${appData}\\npm`] : []),
418 ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []),
419 ...(localAppData ? [`${localAppData}\\pnpm`] : []),
420 ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []),
421 ];
422}
423
424export interface WindowsEnvironmentResolverOptions {
425 readonly readEnvironment?: WindowsShellEnvironmentReader;
426 readonly commandAvailable?: typeof isCommandAvailable;
427}
428
429function readWindowsEnvironmentSafely(
430 readEnvironment: WindowsShellEnvironmentReader,
431 names: ReadonlyArray<string>,
432 options?: WindowsEnvironmentProbeOptions,
433): Partial<Record<string, string>> {
434 try {
435 return readEnvironment(names, options);
436 } catch {
437 return {};
438 }
439}
440
441function mergeWindowsEnv(
442 currentEnv: NodeJS.ProcessEnv,
443 patch: Partial<Record<string, string>>,
444): NodeJS.ProcessEnv {
445 const nextEnv: NodeJS.ProcessEnv = { ...currentEnv };
446 for (const [key, value] of Object.entries(patch)) {
447 if (value !== undefined) {
448 nextEnv[key] = value;
449 }
450 }
451 return nextEnv;
452}
453
454export function resolveWindowsEnvironment(
455 env: NodeJS.ProcessEnv,
456 options: WindowsEnvironmentResolverOptions = {},
457): Partial<NodeJS.ProcessEnv> {
458 const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell;
459 const commandAvailable = options.commandAvailable ?? isCommandAvailable;
460 const inheritedPath = readEnvPath(env);
461 const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], {
462 loadProfile: false,
463 }).PATH;
464 const mergedPath = mergePathValues(shellPath, inheritedPath, "win32");
465 const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER);
466 const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32");
467 const baselinePatch: Partial<NodeJS.ProcessEnv> = baselinePath ? { PATH: baselinePath } : {};
468 const baselineEnv = mergeWindowsEnv(env, baselinePatch);
469
470 if (commandAvailable("node", { platform: "win32", env: baselineEnv })) {
471 return baselinePatch;
472 }
473
474 const profiledEnvironment = readWindowsEnvironmentSafely(
475 readEnvironment,
476 ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
477 { loadProfile: true },
478 );
479 const profiledPath = mergePathValues(profiledEnvironment.PATH, baselinePath, "win32");
480 const profiledPatch: Partial<NodeJS.ProcessEnv> = {
481 ...(profiledPath ? { PATH: profiledPath } : {}),
482 ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}),
483 ...(profiledEnvironment.FNM_MULTISHELL_PATH
484 ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH }
485 : {}),
486 };
487 return Object.keys(profiledPatch).length > 0
488 ? { ...baselinePatch, ...profiledPatch }
489 : baselinePatch;
490}
scripts/build-desktop-artifact.ts+5-2
......@@ -610,12 +610,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
610610 }
611611
612612 if (platform === "win") {
613 buildConfig.npmRebuild = false;
613614 const winConfig: Record<string, unknown> = {
614615 target: [target],
615616 icon: "icon.ico",
616617 };
617618 if (signed) {
618619 winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig;
620 } else {
621 winConfig.signAndEditExecutable = false;
619622 }
620623 buildConfig.win = winConfig;
621624 }
......@@ -811,7 +814,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
811814 ...commandOutputOptions(options.verbose),
812815 // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
813816 shell: process.platform === "win32",
814 })`bun install --production`,
817 })`bun install --production --omit optional`,
815818 );
816819
817820 const buildEnv: NodeJS.ProcessEnv = {
......@@ -851,7 +854,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
851854 ...commandOutputOptions(options.verbose),
852855 // Windows needs shell mode to resolve .cmd shims.
853856 shell: process.platform === "win32",
854 })`bunx electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`,
857 })`bun x --install=fallback electron-builder ${platformConfig.cliFlag} --${options.arch} --publish never`,
855858 );
856859
857860 const stageDistDir = path.join(stageAppDir, "dist");