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", () => {...@@ -148,7 +148,7 @@ describe("syncShellEnvironment", () => {
148 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");148 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
149 });149 });
150150
151 it("does nothing outside macOS and linux", () => {151 it("does nothing on unsupported platforms", () => {
152 const env: NodeJS.ProcessEnv = {152 const env: NodeJS.ProcessEnv = {
153 SHELL: "C:/Program Files/Git/bin/bash.exe",153 SHELL: "C:/Program Files/Git/bin/bash.exe",
154 PATH: "C:\\Windows\\System32",154 PATH: "C:\\Windows\\System32",
...@@ -160,7 +160,7 @@ describe("syncShellEnvironment", () => {...@@ -160,7 +160,7 @@ describe("syncShellEnvironment", () => {
160 }));160 }));
161161
162 syncShellEnvironment(env, {162 syncShellEnvironment(env, {
163 platform: "win32",163 platform: "freebsd",
164 readEnvironment,164 readEnvironment,
165 });165 });
166166
...@@ -168,4 +168,122 @@ describe("syncShellEnvironment", () => {...@@ -168,4 +168,122 @@ describe("syncShellEnvironment", () => {
168 expect(env.PATH).toBe("C:\\Windows\\System32");168 expect(env.PATH).toBe("C:\\Windows\\System32");
169 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");169 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
170 });170 });
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 });
171});289});
apps/desktop/src/syncShellEnvironment.ts+32-2
...@@ -3,8 +3,18 @@ import {...@@ -3,8 +3,18 @@ import {
3 mergePathEntries,3 mergePathEntries,
4 readPathFromLaunchctl,4 readPathFromLaunchctl,
5 readEnvironmentFromLoginShell,5 readEnvironmentFromLoginShell,
6 resolveWindowsEnvironment,
6} from "@t3tools/shared/shell";7} 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
9const LOGIN_SHELL_ENV_NAMES = [19const LOGIN_SHELL_ENV_NAMES = [
10 "PATH",20 "PATH",
...@@ -25,19 +35,39 @@ export function syncShellEnvironment(...@@ -25,19 +35,39 @@ export function syncShellEnvironment(
25 options: {35 options: {
26 platform?: NodeJS.Platform;36 platform?: NodeJS.Platform;
27 readEnvironment?: ShellEnvironmentReader;37 readEnvironment?: ShellEnvironmentReader;
38 readWindowsEnvironment?: WindowsShellEnvironmentReader;
39 isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
28 readLaunchctlPath?: typeof readPathFromLaunchctl;40 readLaunchctlPath?: typeof readPathFromLaunchctl;
29 userShell?: string;41 userShell?: string;
30 logWarning?: (message: string, error?: unknown) => void;42 logWarning?: (message: string, error?: unknown) => void;
31 } = {},43 } = {},
32): void {44): void {
33 const platform = options.platform ?? process.platform;45 const platform = options.platform ?? process.platform;
34 if (platform !== "darwin" && platform !== "linux") return;
3546
36 const logWarning = options.logWarning ?? logShellEnvironmentWarning;47 const logWarning = options.logWarning ?? logShellEnvironmentWarning;
37 const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;48 const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
38 const shellEnvironment: Partial<Record<string, string>> = {};49 const shellEnvironment: Partial<Record<string, string>> = {};
3950
40 try {51 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
41 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {71 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
42 try {72 try {
43 Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));73 Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));
apps/server/src/open.ts+2-112
...@@ -7,10 +7,9 @@...@@ -7,10 +7,9 @@
7 * @module Open7 * @module Open
8 */8 */
9import { spawn } from "node:child_process";9import { spawn } from "node:child_process";
10import { accessSync, constants, statSync } from "node:fs";
11import { extname, join } from "node:path";
1210
13import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";11import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
12import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";
14import { Context, Effect, Layer } from "effect";13import { Context, Effect, Layer } from "effect";
1514
16// ==============================15// ==============================
...@@ -18,6 +17,7 @@ import { Context, Effect, Layer } from "effect";...@@ -18,6 +17,7 @@ import { Context, Effect, Layer } from "effect";
18// ==============================17// ==============================
1918
20export { OpenError };19export { OpenError };
20export { isCommandAvailable } from "@t3tools/shared/shell";
2121
22export interface OpenInEditorInput {22export interface OpenInEditorInput {
23 readonly cwd: string;23 readonly cwd: string;
...@@ -29,11 +29,6 @@ interface EditorLaunch {...@@ -29,11 +29,6 @@ interface EditorLaunch {
29 readonly args: ReadonlyArray<string>;29 readonly args: ReadonlyArray<string>;
30}30}
3131
32interface CommandAvailabilityOptions {
33 readonly platform?: NodeJS.Platform;
34 readonly env?: NodeJS.ProcessEnv;
35}
36
37const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;32const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
3833
39function parseTargetPathAndPosition(target: string): {34function parseTargetPathAndPosition(target: string): {
...@@ -106,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {...@@ -106,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
106 }101 }
107}102}
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
214export function resolveAvailableEditors(104export function resolveAvailableEditors(
215 platform: NodeJS.Platform = process.platform,105 platform: NodeJS.Platform = process.platform,
216 env: NodeJS.ProcessEnv = process.env,106 env: NodeJS.ProcessEnv = process.env,
apps/server/src/os-jank.test.ts+115-2
...@@ -53,7 +53,120 @@ describe("fixPath", () => {...@@ -53,7 +53,120 @@ describe("fixPath", () => {
53 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");53 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
54 });54 });
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", () => {
57 const env: NodeJS.ProcessEnv = {170 const env: NodeJS.ProcessEnv = {
58 SHELL: "C:/Program Files/Git/bin/bash.exe",171 SHELL: "C:/Program Files/Git/bin/bash.exe",
59 PATH: "C:\\Windows\\System32",172 PATH: "C:\\Windows\\System32",
...@@ -62,7 +175,7 @@ describe("fixPath", () => {...@@ -62,7 +175,7 @@ describe("fixPath", () => {
62175
63 fixPath({176 fixPath({
64 env,177 env,
65 platform: "win32",178 platform: "freebsd",
66 readPath,179 readPath,
67 });180 });
68181
apps/server/src/os-jank.ts+29-3
...@@ -1,12 +1,21 @@...@@ -1,12 +1,21 @@
1import * as OS from "node:os";1import * as OS from "node:os";
2import { Effect, Path } from "effect";2import { Effect, Path } from "effect";
3import {3import {
4 readPathFromLoginShell,
5 readEnvironmentFromWindowsShell,
6 resolveWindowsEnvironment,
7 type CommandAvailabilityOptions,
8 type WindowsShellEnvironmentReader,
4 listLoginShellCandidates,9 listLoginShellCandidates,
5 mergePathEntries,10 mergePathEntries,
6 readPathFromLaunchctl,11 readPathFromLaunchctl,
7 readPathFromLoginShell,
8} from "@t3tools/shared/shell";12} from "@t3tools/shared/shell";
913
14type WindowsCommandAvailabilityChecker = (
15 command: string,
16 options?: CommandAvailabilityOptions,
17) => boolean;
18
10function logPathHydrationWarning(message: string, error?: unknown): void {19function logPathHydrationWarning(message: string, error?: unknown): void {
11 console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? ""));20 console.warn(`[server] ${message}`, error instanceof Error ? error.message : (error ?? ""));
12}21}
...@@ -16,19 +25,36 @@ export function fixPath(...@@ -16,19 +25,36 @@ export function fixPath(
16 env?: NodeJS.ProcessEnv;25 env?: NodeJS.ProcessEnv;
17 platform?: NodeJS.Platform;26 platform?: NodeJS.Platform;
18 readPath?: typeof readPathFromLoginShell;27 readPath?: typeof readPathFromLoginShell;
28 readWindowsEnvironment?: WindowsShellEnvironmentReader;
29 isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
19 readLaunchctlPath?: typeof readPathFromLaunchctl;30 readLaunchctlPath?: typeof readPathFromLaunchctl;
20 userShell?: string;31 userShell?: string;
21 logWarning?: (message: string, error?: unknown) => void;32 logWarning?: (message: string, error?: unknown) => void;
22 } = {},33 } = {},
23): void {34): void {
24 const platform = options.platform ?? process.platform;35 const platform = options.platform ?? process.platform;
25 if (platform !== "darwin" && platform !== "linux") return;
26
27 const env = options.env ?? process.env;36 const env = options.env ?? process.env;
28 const logWarning = options.logWarning ?? logPathHydrationWarning;37 const logWarning = options.logWarning ?? logPathHydrationWarning;
29 const readPath = options.readPath ?? readPathFromLoginShell;38 const readPath = options.readPath ?? readPathFromLoginShell;
3039
31 try {40 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
32 let shellPath: string | undefined;58 let shellPath: string | undefined;
33 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {59 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
34 try {60 try {
apps/server/src/terminal/Layers/Manager.test.ts+68-3
...@@ -194,6 +194,8 @@ function multiTerminalHistoryLogPath(...@@ -194,6 +194,8 @@ function multiTerminalHistoryLogPath(
194194
195interface CreateManagerOptions {195interface CreateManagerOptions {
196 shellResolver?: () => string;196 shellResolver?: () => string;
197 platform?: NodeJS.Platform;
198 env?: NodeJS.ProcessEnv;
197 subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>;199 subprocessChecker?: (terminalPid: number) => Effect.Effect<boolean>;
198 subprocessPollIntervalMs?: number;200 subprocessPollIntervalMs?: number;
199 processKillGraceMs?: number;201 processKillGraceMs?: number;
...@@ -228,6 +230,8 @@ const createManager = (...@@ -228,6 +230,8 @@ const createManager = (
228 historyLineLimit,230 historyLineLimit,
229 ptyAdapter,231 ptyAdapter,
230 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),232 ...(options.shellResolver !== undefined ? { shellResolver: options.shellResolver } : {}),
233 ...(options.platform !== undefined ? { platform: options.platform } : {}),
234 ...(options.env !== undefined ? { env: options.env } : {}),
231 ...(options.subprocessChecker !== undefined235 ...(options.subprocessChecker !== undefined
232 ? { subprocessChecker: options.subprocessChecker }236 ? { subprocessChecker: options.subprocessChecker }
233 : {}),237 : {}),
...@@ -297,6 +301,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (...@@ -297,6 +301,8 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
297301
298 it.effect("preserves non-notFound cwd stat failures", () =>302 it.effect("preserves non-notFound cwd stat failures", () =>
299 Effect.gen(function* () {303 Effect.gen(function* () {
304 if (process.platform === "win32") return;
305
300 const { manager, baseDir } = yield* createManager();306 const { manager, baseDir } = yield* createManager();
301 const blockedRoot = path.join(baseDir, "blocked-root");307 const blockedRoot = path.join(baseDir, "blocked-root");
302 const blockedCwd = path.join(blockedRoot, "cwd");308 const blockedCwd = path.join(blockedRoot, "cwd");
...@@ -827,8 +833,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (...@@ -827,8 +833,12 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
827833
828 it.effect("retries with fallback shells when preferred shell spawn fails", () =>834 it.effect("retries with fallback shells when preferred shell spawn fails", () =>
829 Effect.gen(function* () {835 Effect.gen(function* () {
836 const missingShell =
837 process.platform === "win32"
838 ? "C:\\definitely\\missing-shell.exe"
839 : "/definitely/missing-shell -l";
830 const { manager, ptyAdapter } = yield* createManager(5, {840 const { manager, ptyAdapter } = yield* createManager(5, {
831 shellResolver: () => "/definitely/missing-shell -l",841 shellResolver: () => missingShell,
832 });842 });
833 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));843 ptyAdapter.spawnFailures.push(new Error("posix_spawnp failed."));
834844
...@@ -836,12 +846,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (...@@ -836,12 +846,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
836846
837 assert.equal(snapshot.status, "running");847 assert.equal(snapshot.status, "running");
838 expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2);848 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
841 if (process.platform === "win32") {853 if (process.platform === "win32") {
842 expect(854 expect(
843 ptyAdapter.spawnInputs.some(855 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",
845 ),860 ),
846 ).toBe(true);861 ).toBe(true);
847 } else {862 } else {
...@@ -854,6 +869,56 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (...@@ -854,6 +869,56 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", (
854 }),869 }),
855 );870 );
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
857 it.effect("filters app runtime env variables from terminal sessions", () =>922 it.effect("filters app runtime env variables from terminal sessions", () =>
858 Effect.gen(function* () {923 Effect.gen(function* () {
859 const originalValues = new Map<string, string | undefined>();924 const originalValues = new Map<string, string | undefined>();
apps/server/src/terminal/Layers/Manager.ts+72-25
...@@ -186,19 +186,25 @@ function enqueueProcessEvent(...@@ -186,19 +186,25 @@ function enqueueProcessEvent(
186 return true;186 return true;
187}187}
188188
189function defaultShellResolver(): string {189function defaultShellResolver(
190 if (process.platform === "win32") {190 platform: NodeJS.Platform = process.platform,
191 return process.env.ComSpec ?? "cmd.exe";191 env: NodeJS.ProcessEnv = process.env,
192): string {
193 if (platform === "win32") {
194 return "pwsh.exe";
192 }195 }
193 return process.env.SHELL ?? "bash";196 return env.SHELL ?? "bash";
194}197}
195198
196function normalizeShellCommand(value: string | undefined): string | null {199function normalizeShellCommand(
200 value: string | undefined,
201 platform: NodeJS.Platform = process.platform,
202): string | null {
197 if (!value) return null;203 if (!value) return null;
198 const trimmed = value.trim();204 const trimmed = value.trim();
199 if (trimmed.length === 0) return null;205 if (trimmed.length === 0) return null;
200206
201 if (process.platform === "win32") {207 if (platform === "win32") {
202 return trimmed;208 return trimmed;
203 }209 }
204210
...@@ -207,15 +213,42 @@ function normalizeShellCommand(value: string | undefined): string | null {...@@ -207,15 +213,42 @@ function normalizeShellCommand(value: string | undefined): string | null {
207 return firstToken.replace(/^['"]|['"]$/g, "");213 return firstToken.replace(/^['"]|['"]$/g, "");
208}214}
209215
210function shellCandidateFromCommand(command: string | null): ShellCandidate | null {216function shellCandidateFromCommand(
217 command: string | null,
218 platform: NodeJS.Platform = process.platform,
219): ShellCandidate | null {
211 if (!command || command.length === 0) return null;220 if (!command || command.length === 0) return null;
212 const shellName = path.basename(command).toLowerCase();221 const shellName =
213 if (process.platform !== "win32" && shellName === "zsh") {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") {
214 return { shell: command, args: ["-o", "nopromptsp"] };229 return { shell: command, args: ["-o", "nopromptsp"] };
215 }230 }
216 return { shell: command };231 return { shell: command };
217}232}
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
219function formatShellCandidate(candidate: ShellCandidate): string {252function formatShellCandidate(candidate: ShellCandidate): string {
220 if (!candidate.args || candidate.args.length === 0) return candidate.shell;253 if (!candidate.args || candidate.args.length === 0) return candidate.shell;
221 return `${candidate.shell} ${candidate.args.join(" ")}`;254 return `${candidate.shell} ${candidate.args.join(" ")}`;
...@@ -234,27 +267,37 @@ function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellC...@@ -234,27 +267,37 @@ function uniqueShellCandidates(candidates: Array<ShellCandidate | null>): ShellC
234 return ordered;267 return ordered;
235}268}
236269
237function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] {270function resolveShellCandidates(
238 const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver()));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") {
241 return uniqueShellCandidates([281 return uniqueShellCandidates([
242 requested,282 requested,
243 shellCandidateFromCommand(process.env.ComSpec ?? null),283 shellCandidateFromCommand("pwsh.exe", platform),
244 shellCandidateFromCommand("powershell.exe"),284 shellCandidateFromCommand(windowsPowerShellPath(env), platform),
245 shellCandidateFromCommand("cmd.exe"),285 shellCandidateFromCommand("powershell.exe", platform),
286 shellCandidateFromCommand(env.ComSpec ?? null, platform),
287 shellCandidateFromCommand(windowsCmdPath(env), platform),
288 shellCandidateFromCommand("cmd.exe", platform),
246 ]);289 ]);
247 }290 }
248291
249 return uniqueShellCandidates([292 return uniqueShellCandidates([
250 requested,293 requested,
251 shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)),294 shellCandidateFromCommand(normalizeShellCommand(env.SHELL, platform), platform),
252 shellCandidateFromCommand("/bin/zsh"),295 shellCandidateFromCommand("/bin/zsh", platform),
253 shellCandidateFromCommand("/bin/bash"),296 shellCandidateFromCommand("/bin/bash", platform),
254 shellCandidateFromCommand("/bin/sh"),297 shellCandidateFromCommand("/bin/sh", platform),
255 shellCandidateFromCommand("zsh"),298 shellCandidateFromCommand("zsh", platform),
256 shellCandidateFromCommand("bash"),299 shellCandidateFromCommand("bash", platform),
257 shellCandidateFromCommand("sh"),300 shellCandidateFromCommand("sh", platform),
258 ]);301 ]);
259}302}
260303
...@@ -651,6 +694,8 @@ interface TerminalManagerOptions {...@@ -651,6 +694,8 @@ interface TerminalManagerOptions {
651 historyLineLimit?: number;694 historyLineLimit?: number;
652 ptyAdapter: PtyAdapterShape;695 ptyAdapter: PtyAdapterShape;
653 shellResolver?: () => string;696 shellResolver?: () => string;
697 platform?: NodeJS.Platform;
698 env?: NodeJS.ProcessEnv;
654 subprocessChecker?: TerminalSubprocessChecker;699 subprocessChecker?: TerminalSubprocessChecker;
655 subprocessPollIntervalMs?: number;700 subprocessPollIntervalMs?: number;
656 processKillGraceMs?: number;701 processKillGraceMs?: number;
...@@ -674,7 +719,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -674,7 +719,9 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
674719
675 const logsDir = options.logsDir;720 const logsDir = options.logsDir;
676 const historyLineLimit = options.historyLineLimit ?? DEFAULT_HISTORY_LINE_LIMIT;721 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));
678 const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;725 const subprocessChecker = options.subprocessChecker ?? defaultSubprocessChecker;
679 const subprocessPollIntervalMs =726 const subprocessPollIntervalMs =
680 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;727 options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS;
...@@ -1337,8 +1384,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith...@@ -1337,8 +1384,8 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith
1337 increment(terminalSessionsTotal, { lifecycle: eventType }).pipe(1384 increment(terminalSessionsTotal, { lifecycle: eventType }).pipe(
1338 Effect.andThen(1385 Effect.andThen(
1339 Effect.gen(function* () {1386 Effect.gen(function* () {
1340 const shellCandidates = resolveShellCandidates(shellResolver);1387 const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv);
1341 const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv);1388 const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv);
1342 const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session);1389 const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session);
1343 ptyProcess = spawnResult.process;1390 ptyProcess = spawnResult.process;
1344 startedShell = spawnResult.shellLabel;1391 startedShell = spawnResult.shellLabel;
apps/server/src/terminal/Layers/NodePTY.ts+21-7
...@@ -2,7 +2,12 @@ import { createRequire } from "node:module";...@@ -2,7 +2,12 @@ import { createRequire } from "node:module";
22
3import { Effect, FileSystem, Layer, Path } from "effect";3import { Effect, FileSystem, Layer, Path } from "effect";
4import { PtyAdapter } from "../Services/PTY.ts";4import { 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
7let didEnsureSpawnHelperExecutable = false;12let didEnsureSpawnHelperExecutable = false;
813
...@@ -108,12 +113,21 @@ export const layer = Layer.effect(...@@ -108,12 +113,21 @@ export const layer = Layer.effect(
108 return {113 return {
109 spawn: Effect.fn(function* (input) {114 spawn: Effect.fn(function* (input) {
110 yield* ensureNodePtySpawnHelperExecutableCached;115 yield* ensureNodePtySpawnHelperExecutableCached;
111 const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], {116 const ptyProcess = yield* Effect.try({
112 cwd: input.cwd,117 try: () =>
113 cols: input.cols,118 nodePty.spawn(input.shell, input.args ?? [], {
114 rows: input.rows,119 cwd: input.cwd,
115 env: input.env,120 cols: input.cols,
116 name: globalThis.process.platform === "win32" ? "xterm-color" : "xterm-256color",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 }),
117 });131 });
118 return new NodePtyProcess(ptyProcess);132 return new NodePtyProcess(ptyProcess);
119 }),133 }),
packages/shared/src/shell.test.ts+268
...@@ -2,11 +2,16 @@ import { describe, expect, it, vi } from "vitest";...@@ -2,11 +2,16 @@ import { describe, expect, it, vi } from "vitest";
22
3import {3import {
4 extractPathFromShellOutput,4 extractPathFromShellOutput,
5 isCommandAvailable,
5 listLoginShellCandidates,6 listLoginShellCandidates,
6 mergePathEntries,7 mergePathEntries,
8 mergePathValues,
7 readEnvironmentFromLoginShell,9 readEnvironmentFromLoginShell,
10 readEnvironmentFromWindowsShell,
8 readPathFromLaunchctl,11 readPathFromLaunchctl,
9 readPathFromLoginShell,12 readPathFromLoginShell,
13 resolveKnownWindowsCliDirs,
14 resolveWindowsEnvironment,
10} from "./shell.ts";15} from "./shell.ts";
1116
12describe("extractPathFromShellOutput", () => {17describe("extractPathFromShellOutput", () => {
...@@ -188,3 +193,266 @@ describe("mergePathEntries", () => {...@@ -188,3 +193,266 @@ describe("mergePathEntries", () => {
188 );193 );
189 });194 });
190});195});
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 @@...@@ -1,9 +1,14 @@
1import * as OS from "node:os";1import * as OS from "node:os";
2import { execFileSync } from "node:child_process";2import { execFileSync } from "node:child_process";
3import { accessSync, constants, statSync } from "node:fs";
4import { extname, join } from "node:path";
35
4const PATH_CAPTURE_START = "__T3CODE_PATH_START__";6const PATH_CAPTURE_START = "__T3CODE_PATH_START__";
5const PATH_CAPTURE_END = "__T3CODE_PATH_END__";7const PATH_CAPTURE_END = "__T3CODE_PATH_END__";
6const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/;8const 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
8type ExecFileSyncLike = (13type ExecFileSyncLike = (
9 file: string,14 file: string,
...@@ -11,6 +16,15 @@ type ExecFileSyncLike = (...@@ -11,6 +16,15 @@ type ExecFileSyncLike = (
11 options: { encoding: "utf8"; timeout: number },16 options: { encoding: "utf8"; timeout: number },
12) => string;17) => 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
14function trimNonEmpty(value: string | null | undefined): string | undefined {28function trimNonEmpty(value: string | null | undefined): string | undefined {
15 const trimmed = value?.trim();29 const trimmed = value?.trim();
16 return trimmed && trimmed.length > 0 ? trimmed : undefined;30 return trimmed && trimmed.length > 0 ? trimmed : undefined;
...@@ -127,6 +141,24 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {...@@ -127,6 +141,24 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray<string>): string {
127 .join("; ");141 .join("; ");
128}142}
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
130function extractEnvironmentValue(output: string, name: string): string | undefined {162function extractEnvironmentValue(output: string, name: string): string | undefined {
131 const startMarker = envCaptureStart(name);163 const startMarker = envCaptureStart(name);
132 const endMarker = envCaptureEnd(name);164 const endMarker = envCaptureEnd(name);
...@@ -137,13 +169,10 @@ function extractEnvironmentValue(output: string, name: string): string | undefin...@@ -137,13 +169,10 @@ function extractEnvironmentValue(output: string, name: string): string | undefin
137 const endIndex = output.indexOf(endMarker, valueStartIndex);169 const endIndex = output.indexOf(endMarker, valueStartIndex);
138 if (endIndex === -1) return undefined;170 if (endIndex === -1) return undefined;
139171
140 let value = output.slice(valueStartIndex, endIndex);172 const value = output
141 if (value.startsWith("\n")) {173 .slice(valueStartIndex, endIndex)
142 value = value.slice(1);174 .replace(/^\r?\n/, "")
143 }175 .replace(/\r?\n$/, "");
144 if (value.endsWith("\n")) {
145 value = value.slice(0, -1);
146 }
147176
148 return value.length > 0 ? value : undefined;177 return value.length > 0 ? value : undefined;
149}178}
...@@ -178,3 +207,284 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = (...@@ -178,3 +207,284 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = (
178207
179 return environment;208 return environment;
180};209};
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* (...@@ -610,12 +610,15 @@ const createBuildConfig = Effect.fn("createBuildConfig")(function* (
610 }610 }
611611
612 if (platform === "win") {612 if (platform === "win") {
613 buildConfig.npmRebuild = false;
613 const winConfig: Record<string, unknown> = {614 const winConfig: Record<string, unknown> = {
614 target: [target],615 target: [target],
615 icon: "icon.ico",616 icon: "icon.ico",
616 };617 };
617 if (signed) {618 if (signed) {
618 winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig;619 winConfig.azureSignOptions = yield* AzureTrustedSigningOptionsConfig;
620 } else {
621 winConfig.signAndEditExecutable = false;
619 }622 }
620 buildConfig.win = winConfig;623 buildConfig.win = winConfig;
621 }624 }
...@@ -811,7 +814,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -811,7 +814,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
811 ...commandOutputOptions(options.verbose),814 ...commandOutputOptions(options.verbose),
812 // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).815 // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).
813 shell: process.platform === "win32",816 shell: process.platform === "win32",
814 })`bun install --production`,817 })`bun install --production --omit optional`,
815 );818 );
816819
817 const buildEnv: NodeJS.ProcessEnv = {820 const buildEnv: NodeJS.ProcessEnv = {
...@@ -851,7 +854,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -851,7 +854,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
851 ...commandOutputOptions(options.verbose),854 ...commandOutputOptions(options.verbose),
852 // Windows needs shell mode to resolve .cmd shims.855 // Windows needs shell mode to resolve .cmd shims.
853 shell: process.platform === "win32",856 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`,
855 );858 );
856859
857 const stageDistDir = path.join(stageAppDir, "dist");860 const stageDistDir = path.join(stageAppDir, "dist");