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