| author | |
| committer | |
| log | a7a44d0662fb026009e8a5b5d58a5012e2e8665d |
| tree | 8ad20f6959c785cf63348e66fa7985d546dfb317 |
| parent | d8d329691a3db759d6054af35e373005e9efcabd |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 150 | 150 | ||
| 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 | })); |
| 161 | 161 | ||
| 162 | syncShellEnvironment(env, { | 162 | syncShellEnvironment(env, { |
| 163 | platform: "win32", | 163 | platform: "freebsd", |
| 164 | readEnvironment, | 164 | readEnvironment, |
| 165 | }); | 165 | }); |
| 166 | 166 | ||
| ... | @@ -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"; |
| 7 | import type { ShellEnvironmentReader } from "@t3tools/shared/shell"; | 8 | import type { |
| 9 | CommandAvailabilityOptions, | ||
| 10 | ShellEnvironmentReader, | ||
| 11 | WindowsShellEnvironmentReader, | ||
| 12 | } from "@t3tools/shared/shell"; | ||
| 13 | |||
| 14 | type WindowsCommandAvailabilityChecker = ( | ||
| 15 | command: string, | ||
| 16 | options?: CommandAvailabilityOptions, | ||
| 17 | ) => boolean; | ||
| 8 | 18 | ||
| 9 | const LOGIN_SHELL_ENV_NAMES = [ | 19 | const 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; | ||
| 35 | 46 | ||
| 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>> = {}; |
| 39 | 50 | ||
| 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 Open | 7 | * @module Open |
| 8 | */ | 8 | */ |
| 9 | import { spawn } from "node:child_process"; | 9 | import { spawn } from "node:child_process"; |
| 10 | import { accessSync, constants, statSync } from "node:fs"; | ||
| 11 | import { extname, join } from "node:path"; | ||
| 12 | 10 | ||
| 13 | import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; | 11 | import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; |
| 12 | import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell"; | ||
| 14 | import { Context, Effect, Layer } from "effect"; | 13 | import { Context, Effect, Layer } from "effect"; |
| 15 | 14 | ||
| 16 | // ============================== | 15 | // ============================== |
| ... | @@ -18,6 +17,7 @@ import { Context, Effect, Layer } from "effect"; | ... | @@ -18,6 +17,7 @@ import { Context, Effect, Layer } from "effect"; |
| 18 | // ============================== | 17 | // ============================== |
| 19 | 18 | ||
| 20 | export { OpenError }; | 19 | export { OpenError }; |
| 20 | export { isCommandAvailable } from "@t3tools/shared/shell"; | ||
| 21 | 21 | ||
| 22 | export interface OpenInEditorInput { | 22 | export 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 | } |
| 31 | 31 | ||
| 32 | interface CommandAvailabilityOptions { | ||
| 33 | readonly platform?: NodeJS.Platform; | ||
| 34 | readonly env?: NodeJS.ProcessEnv; | ||
| 35 | } | ||
| 36 | |||
| 37 | const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; | 32 | const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; |
| 38 | 33 | ||
| 39 | function parseTargetPathAndPosition(target: string): { | 34 | function 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 | } |
| 108 | 103 | ||
| 109 | function stripWrappingQuotes(value: string): string { | ||
| 110 | return value.replace(/^"+|"+$/g, ""); | ||
| 111 | } | ||
| 112 | |||
| 113 | function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { | ||
| 114 | return env.PATH ?? env.Path ?? env.path ?? ""; | ||
| 115 | } | ||
| 116 | |||
| 117 | function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> { | ||
| 118 | const rawValue = env.PATHEXT; | ||
| 119 | const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; | ||
| 120 | if (!rawValue) return fallback; | ||
| 121 | |||
| 122 | const parsed = rawValue | ||
| 123 | .split(";") | ||
| 124 | .map((entry) => entry.trim()) | ||
| 125 | .filter((entry) => entry.length > 0) | ||
| 126 | .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); | ||
| 127 | return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; | ||
| 128 | } | ||
| 129 | |||
| 130 | function resolveCommandCandidates( | ||
| 131 | command: string, | ||
| 132 | platform: NodeJS.Platform, | ||
| 133 | windowsPathExtensions: ReadonlyArray<string>, | ||
| 134 | ): ReadonlyArray<string> { | ||
| 135 | if (platform !== "win32") return [command]; | ||
| 136 | const extension = extname(command); | ||
| 137 | const normalizedExtension = extension.toUpperCase(); | ||
| 138 | |||
| 139 | if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { | ||
| 140 | const commandWithoutExtension = command.slice(0, -extension.length); | ||
| 141 | return Array.from( | ||
| 142 | new Set([ | ||
| 143 | command, | ||
| 144 | `${commandWithoutExtension}${normalizedExtension}`, | ||
| 145 | `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, | ||
| 146 | ]), | ||
| 147 | ); | ||
| 148 | } | ||
| 149 | |||
| 150 | const candidates: string[] = []; | ||
| 151 | for (const extension of windowsPathExtensions) { | ||
| 152 | candidates.push(`${command}${extension}`); | ||
| 153 | candidates.push(`${command}${extension.toLowerCase()}`); | ||
| 154 | } | ||
| 155 | return Array.from(new Set(candidates)); | ||
| 156 | } | ||
| 157 | |||
| 158 | function isExecutableFile( | ||
| 159 | filePath: string, | ||
| 160 | platform: NodeJS.Platform, | ||
| 161 | windowsPathExtensions: ReadonlyArray<string>, | ||
| 162 | ): boolean { | ||
| 163 | try { | ||
| 164 | const stat = statSync(filePath); | ||
| 165 | if (!stat.isFile()) return false; | ||
| 166 | if (platform === "win32") { | ||
| 167 | const extension = extname(filePath); | ||
| 168 | if (extension.length === 0) return false; | ||
| 169 | return windowsPathExtensions.includes(extension.toUpperCase()); | ||
| 170 | } | ||
| 171 | accessSync(filePath, constants.X_OK); | ||
| 172 | return true; | ||
| 173 | } catch { | ||
| 174 | return false; | ||
| 175 | } | ||
| 176 | } | ||
| 177 | |||
| 178 | function resolvePathDelimiter(platform: NodeJS.Platform): string { | ||
| 179 | return platform === "win32" ? ";" : ":"; | ||
| 180 | } | ||
| 181 | |||
| 182 | export function isCommandAvailable( | ||
| 183 | command: string, | ||
| 184 | options: CommandAvailabilityOptions = {}, | ||
| 185 | ): boolean { | ||
| 186 | const platform = options.platform ?? process.platform; | ||
| 187 | const env = options.env ?? process.env; | ||
| 188 | const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; | ||
| 189 | const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); | ||
| 190 | |||
| 191 | if (command.includes("/") || command.includes("\\")) { | ||
| 192 | return commandCandidates.some((candidate) => | ||
| 193 | isExecutableFile(candidate, platform, windowsPathExtensions), | ||
| 194 | ); | ||
| 195 | } | ||
| 196 | |||
| 197 | const pathValue = resolvePathEnvironmentVariable(env); | ||
| 198 | if (pathValue.length === 0) return false; | ||
| 199 | const pathEntries = pathValue | ||
| 200 | .split(resolvePathDelimiter(platform)) | ||
| 201 | .map((entry) => stripWrappingQuotes(entry.trim())) | ||
| 202 | .filter((entry) => entry.length > 0); | ||
| 203 | |||
| 204 | for (const pathEntry of pathEntries) { | ||
| 205 | for (const candidate of commandCandidates) { | ||
| 206 | if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { | ||
| 207 | return true; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | } | ||
| 211 | return false; | ||
| 212 | } | ||
| 213 | |||
| 214 | export function resolveAvailableEditors( | 104 | export 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 | }); |
| 55 | 55 | ||
| 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", () => { |
| 62 | 175 | ||
| 63 | fixPath({ | 176 | fixPath({ |
| 64 | env, | 177 | env, |
| 65 | platform: "win32", | 178 | platform: "freebsd", |
| 66 | readPath, | 179 | readPath, |
| 67 | }); | 180 | }); |
| 68 | 181 |
apps/server/src/os-jank.ts+29-3| ... | @@ -1,12 +1,21 @@ | ... | @@ -1,12 +1,21 @@ |
| 1 | import * as OS from "node:os"; | 1 | import * as OS from "node:os"; |
| 2 | import { Effect, Path } from "effect"; | 2 | import { Effect, Path } from "effect"; |
| 3 | import { | 3 | import { |
| 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"; |
| 9 | 13 | ||
| 14 | type WindowsCommandAvailabilityChecker = ( | ||
| 15 | command: string, | ||
| 16 | options?: CommandAvailabilityOptions, | ||
| 17 | ) => boolean; | ||
| 18 | |||
| 10 | function logPathHydrationWarning(message: string, error?: unknown): void { | 19 | function 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; |
| 30 | 39 | ||
| 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( |
| 194 | 194 | ||
| 195 | interface CreateManagerOptions { | 195 | interface 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 !== undefined | 235 | ...(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", ( |
| 297 | 301 | ||
| 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", ( |
| 827 | 833 | ||
| 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.")); |
| 834 | 844 | ||
| ... | @@ -836,12 +846,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( | ... | @@ -836,12 +846,17 @@ it.layer(NodeServices.layer, { excludeTestServices: true })("TerminalManager", ( |
| 836 | 846 | ||
| 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 | ); | ||
| 840 | 852 | ||
| 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 | ); |
| 856 | 871 | ||
| 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 | } |
| 188 | 188 | ||
| 189 | function defaultShellResolver(): string { | 189 | function 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 | } |
| 195 | 198 | ||
| 196 | function normalizeShellCommand(value: string | undefined): string | null { | 199 | function 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; |
| 200 | 206 | ||
| 201 | if (process.platform === "win32") { | 207 | if (platform === "win32") { |
| 202 | return trimmed; | 208 | return trimmed; |
| 203 | } | 209 | } |
| 204 | 210 | ||
| ... | @@ -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 | } |
| 209 | 215 | ||
| 210 | function shellCandidateFromCommand(command: string | null): ShellCandidate | null { | 216 | function 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 | } |
| 218 | 233 | ||
| 234 | function windowsSystemRoot(env: NodeJS.ProcessEnv): string { | ||
| 235 | return env.SystemRoot?.trim() || env.windir?.trim() || "C:\\Windows"; | ||
| 236 | } | ||
| 237 | |||
| 238 | function 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 | |||
| 248 | function windowsCmdPath(env: NodeJS.ProcessEnv): string { | ||
| 249 | return path.win32.join(windowsSystemRoot(env), "System32", "cmd.exe"); | ||
| 250 | } | ||
| 251 | |||
| 219 | function formatShellCandidate(candidate: ShellCandidate): string { | 252 | function 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 | } |
| 236 | 269 | ||
| 237 | function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { | 270 | function 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 | ); | ||
| 239 | 279 | ||
| 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 | } |
| 248 | 291 | ||
| 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 | } |
| 260 | 303 | ||
| ... | @@ -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 |
| 674 | 719 | ||
| 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"; |
| 2 | 2 | ||
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | 3 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | import { PtyAdapter } from "../Services/PTY.ts"; | 4 | import { PtyAdapter } from "../Services/PTY.ts"; |
| 5 | import type { PtyAdapterShape, PtyExitEvent, PtyProcess } from "../Services/PTY.ts"; | 5 | import { |
| 6 | PtySpawnError, | ||
| 7 | type PtyAdapterShape, | ||
| 8 | type PtyExitEvent, | ||
| 9 | type PtyProcess, | ||
| 10 | } from "../Services/PTY.ts"; | ||
| 6 | 11 | ||
| 7 | let didEnsureSpawnHelperExecutable = false; | 12 | let didEnsureSpawnHelperExecutable = false; |
| 8 | 13 | ||
| ... | @@ -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"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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"; |
| 11 | 16 | ||
| 12 | describe("extractPathFromShellOutput", () => { | 17 | describe("extractPathFromShellOutput", () => { |
| ... | @@ -188,3 +193,266 @@ describe("mergePathEntries", () => { | ... | @@ -188,3 +193,266 @@ describe("mergePathEntries", () => { |
| 188 | ); | 193 | ); |
| 189 | }); | 194 | }); |
| 190 | }); | 195 | }); |
| 196 | |||
| 197 | describe("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 | |||
| 285 | describe("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 | |||
| 305 | describe("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 | |||
| 324 | describe("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 | |||
| 335 | describe("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 @@ |
| 1 | import * as OS from "node:os"; | 1 | import * as OS from "node:os"; |
| 2 | import { execFileSync } from "node:child_process"; | 2 | import { execFileSync } from "node:child_process"; |
| 3 | import { accessSync, constants, statSync } from "node:fs"; | ||
| 4 | import { extname, join } from "node:path"; | ||
| 3 | 5 | ||
| 4 | const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; | 6 | const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; |
| 5 | const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; | 7 | const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; |
| 6 | const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; | 8 | const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; |
| 9 | const WINDOWS_PATH_DELIMITER = ";"; | ||
| 10 | const POSIX_PATH_DELIMITER = ":"; | ||
| 11 | const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; | ||
| 7 | 12 | ||
| 8 | type ExecFileSyncLike = ( | 13 | type 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; |
| 13 | 18 | ||
| 19 | export interface CommandAvailabilityOptions { | ||
| 20 | readonly platform?: NodeJS.Platform; | ||
| 21 | readonly env?: NodeJS.ProcessEnv; | ||
| 22 | } | ||
| 23 | |||
| 24 | export interface WindowsEnvironmentProbeOptions { | ||
| 25 | readonly loadProfile?: boolean; | ||
| 26 | } | ||
| 27 | |||
| 14 | function trimNonEmpty(value: string | null | undefined): string | undefined { | 28 | function 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 | } |
| 129 | 143 | ||
| 144 | function 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 | |||
| 130 | function extractEnvironmentValue(output: string, name: string): string | undefined { | 162 | function 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; |
| 139 | 171 | ||
| 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 | } | ||
| 147 | 176 | ||
| 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 = ( |
| 178 | 207 | ||
| 179 | return environment; | 208 | return environment; |
| 180 | }; | 209 | }; |
| 210 | |||
| 211 | export type WindowsShellEnvironmentReader = ( | ||
| 212 | names: ReadonlyArray<string>, | ||
| 213 | options?: WindowsEnvironmentProbeOptions, | ||
| 214 | ) => Partial<Record<string, string>>; | ||
| 215 | |||
| 216 | export function readEnvironmentFromWindowsShell( | ||
| 217 | names: ReadonlyArray<string>, | ||
| 218 | execFile?: ExecFileSyncLike, | ||
| 219 | ): Partial<Record<string, string>>; | ||
| 220 | export function readEnvironmentFromWindowsShell( | ||
| 221 | names: ReadonlyArray<string>, | ||
| 222 | options?: WindowsEnvironmentProbeOptions, | ||
| 223 | execFile?: ExecFileSyncLike, | ||
| 224 | ): Partial<Record<string, string>>; | ||
| 225 | export 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 | |||
| 270 | function stripWrappingQuotes(value: string): string { | ||
| 271 | return value.replace(/^"+|"+$/g, ""); | ||
| 272 | } | ||
| 273 | |||
| 274 | function pathDelimiterForPlatform(platform: NodeJS.Platform): string { | ||
| 275 | return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; | ||
| 276 | } | ||
| 277 | |||
| 278 | function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { | ||
| 279 | const normalized = stripWrappingQuotes(entry.trim()); | ||
| 280 | return platform === "win32" ? normalized.toLowerCase() : normalized; | ||
| 281 | } | ||
| 282 | |||
| 283 | export 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 | |||
| 310 | function readEnvPath(env: NodeJS.ProcessEnv): string | undefined { | ||
| 311 | return env.PATH ?? env.Path ?? env.path; | ||
| 312 | } | ||
| 313 | |||
| 314 | function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { | ||
| 315 | return readEnvPath(env) ?? ""; | ||
| 316 | } | ||
| 317 | |||
| 318 | function 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 | |||
| 331 | function 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 | |||
| 359 | function 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 | |||
| 379 | export 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 | |||
| 411 | export 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 | |||
| 424 | export interface WindowsEnvironmentResolverOptions { | ||
| 425 | readonly readEnvironment?: WindowsShellEnvironmentReader; | ||
| 426 | readonly commandAvailable?: typeof isCommandAvailable; | ||
| 427 | } | ||
| 428 | |||
| 429 | function 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 | |||
| 441 | function 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 | |||
| 454 | export 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 | } |
| 611 | 611 | ||
| 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 | ); |
| 816 | 819 | ||
| 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 | ); |
| 856 | 859 | ||
| 857 | const stageDistDir = path.join(stageAppDir, "dist"); | 860 | const stageDistDir = path.join(stageAppDir, "dist"); |