From 32efe2a5dd5a46f944daabc4d3007cf5081c5a9e Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:29:29 -0400 Subject: [PATCH 1/3] Fix Windows PATH hydration and repair - hydrate desktop and server PATH handling for Windows CLI tools - probe shells without profiles first, then fall back to profile-loaded env when node is missing - share command availability and PATH repair utilities across desktop and server --- CLAUDE.md | 2 +- apps/desktop/src/syncShellEnvironment.test.ts | 122 ++++++- apps/desktop/src/syncShellEnvironment.ts | 30 +- apps/server/src/open.ts | 114 +------ apps/server/src/os-jank.test.ts | 117 ++++++- apps/server/src/os-jank.ts | 35 +- packages/shared/src/shell.test.ts | 249 ++++++++++++++ packages/shared/src/shell.ts | 319 ++++++++++++++++++ 8 files changed, 867 insertions(+), 121 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d86..c317064255 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/apps/desktop/src/syncShellEnvironment.test.ts b/apps/desktop/src/syncShellEnvironment.test.ts index cda78a20b2..2bf934e911 100644 --- a/apps/desktop/src/syncShellEnvironment.test.ts +++ b/apps/desktop/src/syncShellEnvironment.test.ts @@ -82,7 +82,7 @@ describe("syncShellEnvironment", () => { expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock"); }); - it("does nothing outside macOS and linux", () => { + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", PATH: "C:\\Windows\\System32", @@ -94,7 +94,7 @@ describe("syncShellEnvironment", () => { })); syncShellEnvironment(env, { - platform: "win32", + platform: "freebsd", readEnvironment, }); @@ -102,4 +102,122 @@ describe("syncShellEnvironment", () => { expect(env.PATH).toBe("C:\\Windows\\System32"); expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock"); }); + + it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn(() => ({ + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", + })); + const isWindowsCommandAvailable = vi.fn(() => true); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1); + }); + + it("loads the PowerShell profile on Windows when node is not available", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ); + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + expect(env.FNM_MULTISHELL_PATH).toBe( + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readWindowsEnvironment).toHaveBeenNthCalledWith( + 2, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + }); + + it("preserves baseline Windows env when the profile probe fails", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { + if (options?.loadProfile) { + throw new Error("profile load failed"); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }, + ); + const isWindowsCommandAvailable = vi.fn(() => false); + + syncShellEnvironment(env, { + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(env.SSH_AUTH_SOCK).toBeUndefined(); + }); }); diff --git a/apps/desktop/src/syncShellEnvironment.ts b/apps/desktop/src/syncShellEnvironment.ts index 13036149b8..a6935e5bb7 100644 --- a/apps/desktop/src/syncShellEnvironment.ts +++ b/apps/desktop/src/syncShellEnvironment.ts @@ -1,20 +1,48 @@ import { readEnvironmentFromLoginShell, + resolveWindowsEnvironment, resolveLoginShell, ShellEnvironmentReader, + WindowsShellEnvironmentReader, + type CommandAvailabilityOptions, } from "@t3tools/shared/shell"; +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; + export function syncShellEnvironment( env: NodeJS.ProcessEnv = process.env, options: { platform?: NodeJS.Platform; readEnvironment?: ShellEnvironmentReader; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; try { + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + ...(options.readWindowsEnvironment + ? { readEnvironment: options.readWindowsEnvironment } + : {}), + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } + + if (platform !== "darwin" && platform !== "linux") return; + const shell = resolveLoginShell(platform, env.SHELL); if (!shell) return; diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 58074ceef2..0d1d4f0da8 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -7,10 +7,9 @@ * @module Open */ import { spawn } from "node:child_process"; -import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts"; +import { isCommandAvailable } from "@t3tools/shared/shell"; import { ServiceMap, Effect, Layer } from "effect"; // ============================== @@ -18,6 +17,7 @@ import { ServiceMap, Effect, Layer } from "effect"; // ============================== export { OpenError }; +export { isCommandAvailable } from "@t3tools/shared/shell"; export interface OpenInEditorInput { readonly cwd: string; @@ -29,11 +29,6 @@ interface EditorLaunch { readonly args: ReadonlyArray; } -interface CommandAvailabilityOptions { - readonly platform?: NodeJS.Platform; - readonly env?: NodeJS.ProcessEnv; -} - const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/; function parseTargetPathAndPosition(target: string): { @@ -86,111 +81,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { } } -function stripWrappingQuotes(value: string): string { - return value.replace(/^"+|"+$/g, ""); -} - -function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { - return env.PATH ?? env.Path ?? env.path ?? ""; -} - -function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { - const rawValue = env.PATHEXT; - const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; - if (!rawValue) return fallback; - - const parsed = rawValue - .split(";") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); - return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; -} - -function resolveCommandCandidates( - command: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): ReadonlyArray { - if (platform !== "win32") return [command]; - const extension = extname(command); - const normalizedExtension = extension.toUpperCase(); - - if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { - const commandWithoutExtension = command.slice(0, -extension.length); - return Array.from( - new Set([ - command, - `${commandWithoutExtension}${normalizedExtension}`, - `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, - ]), - ); - } - - const candidates: string[] = []; - for (const extension of windowsPathExtensions) { - candidates.push(`${command}${extension}`); - candidates.push(`${command}${extension.toLowerCase()}`); - } - return Array.from(new Set(candidates)); -} - -function isExecutableFile( - filePath: string, - platform: NodeJS.Platform, - windowsPathExtensions: ReadonlyArray, -): boolean { - try { - const stat = statSync(filePath); - if (!stat.isFile()) return false; - if (platform === "win32") { - const extension = extname(filePath); - if (extension.length === 0) return false; - return windowsPathExtensions.includes(extension.toUpperCase()); - } - accessSync(filePath, constants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePathDelimiter(platform: NodeJS.Platform): string { - return platform === "win32" ? ";" : ":"; -} - -export function isCommandAvailable( - command: string, - options: CommandAvailabilityOptions = {}, -): boolean { - const platform = options.platform ?? process.platform; - const env = options.env ?? process.env; - const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; - const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); - - if (command.includes("/") || command.includes("\\")) { - return commandCandidates.some((candidate) => - isExecutableFile(candidate, platform, windowsPathExtensions), - ); - } - - const pathValue = resolvePathEnvironmentVariable(env); - if (pathValue.length === 0) return false; - const pathEntries = pathValue - .split(resolvePathDelimiter(platform)) - .map((entry) => stripWrappingQuotes(entry.trim())) - .filter((entry) => entry.length > 0); - - for (const pathEntry of pathEntries) { - for (const candidate of commandCandidates) { - if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { - return true; - } - } - } - return false; -} - export function resolveAvailableEditors( platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env, diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index ca03ab5868..ed82136b9d 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -20,7 +20,120 @@ describe("fixPath", () => { expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin"); }); - it("does nothing outside macOS and linux even when SHELL is set", () => { + it("repairs PATH on Windows by merging PowerShell PATH with inherited PATH", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn(() => ({ + PATH: "C:\\Custom\\Bin;C:\\Windows\\System32", + })); + const isWindowsCommandAvailable = vi.fn(() => true); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + }); + + it("applies profile-derived fnm variables on Windows when node is missing", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }, + ); + const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + ].join(";"), + ); + expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm"); + expect(env.FNM_MULTISHELL_PATH).toBe( + "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + ); + }); + + it("preserves baseline PATH on Windows when the profile probe fails", () => { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }; + const readWindowsEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => { + if (options?.loadProfile) { + throw new Error("profile load failed"); + } + return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" }; + }, + ); + const isWindowsCommandAvailable = vi.fn(() => false); + + fixPath({ + env, + platform: "win32", + readWindowsEnvironment, + isWindowsCommandAvailable, + }); + + expect(env.PATH).toBe( + [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Custom\\Bin", + "C:\\Windows\\System32", + ].join(";"), + ); + }); + + it("does nothing on unsupported platforms", () => { const env: NodeJS.ProcessEnv = { SHELL: "C:/Program Files/Git/bin/bash.exe", PATH: "C:\\Windows\\System32", @@ -29,7 +142,7 @@ describe("fixPath", () => { fixPath({ env, - platform: "win32", + platform: "freebsd", readPath, }); diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index c3629e8fde..a30cd77163 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -1,20 +1,49 @@ import * as OS from "node:os"; import { Effect, Path } from "effect"; -import { readPathFromLoginShell, resolveLoginShell } from "@t3tools/shared/shell"; +import { + readPathFromLoginShell, + readEnvironmentFromWindowsShell, + resolveLoginShell, + resolveWindowsEnvironment, + type CommandAvailabilityOptions, + type WindowsShellEnvironmentReader, +} from "@t3tools/shared/shell"; + +type WindowsCommandAvailabilityChecker = ( + command: string, + options?: CommandAvailabilityOptions, +) => boolean; export function fixPath( options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; readPath?: typeof readPathFromLoginShell; + readWindowsEnvironment?: WindowsShellEnvironmentReader; + isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker; } = {}, ): void { const platform = options.platform ?? process.platform; - if (platform !== "darwin" && platform !== "linux") return; - const env = options.env ?? process.env; try { + if (platform === "win32") { + const repairedEnvironment = resolveWindowsEnvironment(env, { + readEnvironment: options.readWindowsEnvironment ?? readEnvironmentFromWindowsShell, + ...(options.isWindowsCommandAvailable + ? { commandAvailable: options.isWindowsCommandAvailable } + : {}), + }); + for (const [key, value] of Object.entries(repairedEnvironment)) { + if (value !== undefined) { + env[key] = value; + } + } + return; + } + + if (platform !== "darwin" && platform !== "linux") return; + const shell = resolveLoginShell(platform, env.SHELL); if (!shell) return; const result = (options.readPath ?? readPathFromLoginShell)(shell); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e2393eefff..51454386e9 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -2,8 +2,13 @@ import { describe, expect, it, vi } from "vitest"; import { extractPathFromShellOutput, + isCommandAvailable, + mergePathValues, readEnvironmentFromLoginShell, + readEnvironmentFromWindowsShell, readPathFromLoginShell, + resolveKnownWindowsCliDirs, + resolveWindowsEnvironment, } from "./shell"; describe("extractPathFromShellOutput", () => { @@ -126,3 +131,247 @@ describe("readEnvironmentFromLoginShell", () => { }); }); }); + +describe("readEnvironmentFromWindowsShell", () => { + it("extracts environment variables from a PowerShell command", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__T3CODE_ENV_PATH_START__\nC:\\Users\\testuser\\AppData\\Roaming\\npm\n__T3CODE_ENV_PATH_END__\n", + ); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Users\\testuser\\AppData\\Roaming\\npm", + }); + expect(execFile).toHaveBeenCalledWith( + "pwsh.exe", + expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]), + { encoding: "utf8", timeout: 5000 }, + ); + }); + + it("omits -NoProfile when loadProfile is enabled", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >(() => "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n"); + + expect(readEnvironmentFromWindowsShell(["PATH"], { loadProfile: true }, execFile)).toEqual({ + PATH: "C:\\Tools", + }); + expect(execFile).toHaveBeenCalledWith( + "pwsh.exe", + expect.arrayContaining(["-NoLogo", "-NonInteractive", "-Command"]), + { encoding: "utf8", timeout: 5000 }, + ); + expect(execFile.mock.calls[0]?.[1]).not.toContain("-NoProfile"); + }); + + it("falls back to Windows PowerShell when pwsh.exe is unavailable", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((file) => { + if (file === "pwsh.exe") { + throw new Error("spawn pwsh.exe ENOENT"); + } + return "__T3CODE_ENV_PATH_START__\nC:\\Tools\n__T3CODE_ENV_PATH_END__\n"; + }); + + expect(readEnvironmentFromWindowsShell(["PATH"], execFile)).toEqual({ + PATH: "C:\\Tools", + }); + expect(execFile).toHaveBeenNthCalledWith(1, "pwsh.exe", expect.any(Array), { + encoding: "utf8", + timeout: 5000, + }); + expect(execFile).toHaveBeenNthCalledWith(2, "powershell.exe", expect.any(Array), { + encoding: "utf8", + timeout: 5000, + }); + }); +}); + +describe("mergePathValues", () => { + it("dedupes case-insensitively on Windows while preserving preferred order", () => { + expect( + mergePathValues( + "win32", + 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs"', + "c:\\users\\testuser\\appdata\\roaming\\npm;C:\\Windows\\System32", + ), + ).toBe( + 'C:\\Users\\testuser\\AppData\\Roaming\\npm;"C:\\Program Files\\nodejs";C:\\Windows\\System32', + ); + }); + + it("dedupes case-sensitively on POSIX", () => { + expect(mergePathValues("linux", "/usr/local/bin:/usr/bin", "/usr/bin:/USR/BIN")).toBe( + "/usr/local/bin:/usr/bin:/USR/BIN", + ); + }); +}); + +describe("resolveKnownWindowsCliDirs", () => { + it("returns known Windows CLI install directories in priority order", () => { + expect( + resolveKnownWindowsCliDirs({ + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }), + ).toEqual([ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + ]); + }); +}); + +describe("isCommandAvailable", () => { + it("returns false when PATH is empty", () => { + expect( + isCommandAvailable("definitely-not-installed", { + platform: "win32", + env: { PATH: "", PATHEXT: ".COM;.EXE;.BAT;.CMD" }, + }), + ).toBe(false); + }); +}); + +describe("resolveWindowsEnvironment", () => { + it("returns the baseline no-profile PATH patch when node is already available", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { PATH: "C:\\Profile\\Bin" } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => true); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + "C:\\Windows\\System32", + ].join(";"), + }); + expect(readEnvironment).toHaveBeenCalledTimes(1); + expect(readEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false }); + expect(commandAvailable).toHaveBeenCalledWith( + "node", + expect.objectContaining({ + platform: "win32", + }), + ); + }); + + it("loads the PowerShell profile when baseline env cannot resolve node", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile + ? { + PATH: "C:\\Profile\\Node;C:\\Windows\\System32", + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + } + : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Profile\\Node", + "C:\\Windows\\System32", + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Shell\\Bin", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123", + }); + expect(readEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false }); + expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { + loadProfile: true, + }); + }); + + it("keeps the baseline env when profiled probe still does not resolve node", () => { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile ? { FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm" } : {}, + ); + const commandAvailable = vi.fn(() => false); + + expect( + resolveWindowsEnvironment( + { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + USERPROFILE: "C:\\Users\\testuser", + }, + { + readEnvironment, + commandAvailable, + }, + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Windows\\System32", + ].join(";"), + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + }); +}); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index d9e8a7881b..e8d6acd8df 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -1,8 +1,13 @@ import { execFileSync } from "node:child_process"; +import { accessSync, constants, statSync } from "node:fs"; +import { extname, join } from "node:path"; const PATH_CAPTURE_START = "__T3CODE_PATH_START__"; const PATH_CAPTURE_END = "__T3CODE_PATH_END__"; const SHELL_ENV_NAME_PATTERN = /^[A-Z0-9_]+$/; +const WINDOWS_PATH_DELIMITER = ";"; +const POSIX_PATH_DELIMITER = ":"; +const WINDOWS_SHELL_CANDIDATES = ["pwsh.exe", "powershell.exe"] as const; type ExecFileSyncLike = ( file: string, @@ -10,6 +15,15 @@ type ExecFileSyncLike = ( options: { encoding: "utf8"; timeout: number }, ) => string; +export interface CommandAvailabilityOptions { + readonly platform?: NodeJS.Platform; + readonly env?: NodeJS.ProcessEnv; +} + +export interface WindowsEnvironmentProbeOptions { + readonly loadProfile?: boolean; +} + export function resolveLoginShell( platform: NodeJS.Platform, shell: string | undefined, @@ -73,6 +87,24 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { .join("; "); } +function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { + return [ + "$ErrorActionPreference = 'Stop'", + ...names.flatMap((name) => { + if (!SHELL_ENV_NAME_PATTERN.test(name)) { + throw new Error(`Unsupported environment variable name: ${name}`); + } + + return [ + `Write-Output '${envCaptureStart(name)}'`, + `$value = [Environment]::GetEnvironmentVariable('${name}')`, + "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", + `Write-Output '${envCaptureEnd(name)}'`, + ]; + }), + ].join("; "); +} + function extractEnvironmentValue(output: string, name: string): string | undefined { const startMarker = envCaptureStart(name); const endMarker = envCaptureEnd(name); @@ -124,3 +156,290 @@ export const readEnvironmentFromLoginShell: ShellEnvironmentReader = ( return environment; }; + +export type WindowsShellEnvironmentReader = ( + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, +) => Partial>; + +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + execFile?: ExecFileSyncLike, +): Partial>; +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, + execFile?: ExecFileSyncLike, +): Partial>; +export function readEnvironmentFromWindowsShell( + names: ReadonlyArray, + optionsOrExecFile?: WindowsEnvironmentProbeOptions | ExecFileSyncLike, + maybeExecFile?: ExecFileSyncLike, +): Partial> { + if (names.length === 0) { + return {}; + } + + const options = + typeof optionsOrExecFile === "function" + ? ({} satisfies WindowsEnvironmentProbeOptions) + : (optionsOrExecFile ?? {}); + const execFile: ExecFileSyncLike = + typeof optionsOrExecFile === "function" + ? optionsOrExecFile + : (maybeExecFile ?? (execFileSync as ExecFileSyncLike)); + const command = buildWindowsEnvironmentCaptureCommand(names); + const args = [ + "-NoLogo", + ...(options.loadProfile ? ([] as const) : (["-NoProfile"] as const)), + "-NonInteractive", + "-Command", + command, + ]; + for (const shell of WINDOWS_SHELL_CANDIDATES) { + try { + const output = execFile(shell, args, { encoding: "utf8", timeout: 5000 }); + + const environment: Partial> = {}; + for (const name of names) { + const value = extractEnvironmentValue(output, name); + if (value !== undefined) { + environment[name] = value; + } + } + return environment; + } catch { + continue; + } + } + + return {}; +} + +function stripWrappingQuotes(value: string): string { + return value.replace(/^"+|"+$/g, ""); +} + +function pathDelimiterForPlatform(platform: NodeJS.Platform): string { + return platform === "win32" ? WINDOWS_PATH_DELIMITER : POSIX_PATH_DELIMITER; +} + +function normalizePathEntryForComparison(entry: string, platform: NodeJS.Platform): string { + const normalized = stripWrappingQuotes(entry.trim()); + return platform === "win32" ? normalized.toLowerCase() : normalized; +} + +export function mergePathValues( + platform: NodeJS.Platform, + preferredPath: string | undefined, + inheritedPath: string | undefined, +): string | undefined { + const delimiter = pathDelimiterForPlatform(platform); + const merged: string[] = []; + const seen = new Set(); + + for (const rawValue of [preferredPath, inheritedPath]) { + if (!rawValue) continue; + + for (const entry of rawValue.split(delimiter)) { + const trimmed = entry.trim(); + if (trimmed.length === 0) continue; + + const normalized = normalizePathEntryForComparison(trimmed, platform); + if (normalized.length === 0 || seen.has(normalized)) continue; + + seen.add(normalized); + merged.push(trimmed); + } + } + + return merged.length > 0 ? merged.join(delimiter) : undefined; +} + +function readEnvPath(env: NodeJS.ProcessEnv): string | undefined { + return env.PATH ?? env.Path ?? env.path; +} + +function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { + return readEnvPath(env) ?? ""; +} + +function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray { + const rawValue = env.PATHEXT; + const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; + if (!rawValue) return fallback; + + const parsed = rawValue + .split(";") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`)); + return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback; +} + +function resolveCommandCandidates( + command: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): ReadonlyArray { + if (platform !== "win32") return [command]; + const extension = extname(command); + const normalizedExtension = extension.toUpperCase(); + + if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) { + const commandWithoutExtension = command.slice(0, -extension.length); + return Array.from( + new Set([ + command, + `${commandWithoutExtension}${normalizedExtension}`, + `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`, + ]), + ); + } + + const candidates: string[] = []; + for (const candidateExtension of windowsPathExtensions) { + candidates.push(`${command}${candidateExtension}`); + candidates.push(`${command}${candidateExtension.toLowerCase()}`); + } + return Array.from(new Set(candidates)); +} + +function isExecutableFile( + filePath: string, + platform: NodeJS.Platform, + windowsPathExtensions: ReadonlyArray, +): boolean { + try { + const stat = statSync(filePath); + if (!stat.isFile()) return false; + if (platform === "win32") { + const extension = extname(filePath); + if (extension.length === 0) return false; + return windowsPathExtensions.includes(extension.toUpperCase()); + } + accessSync(filePath, constants.X_OK); + return true; + } catch { + return false; + } +} + +export function isCommandAvailable( + command: string, + options: CommandAvailabilityOptions = {}, +): boolean { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : []; + const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions); + + if (command.includes("/") || command.includes("\\")) { + return commandCandidates.some((candidate) => + isExecutableFile(candidate, platform, windowsPathExtensions), + ); + } + + const pathValue = resolvePathEnvironmentVariable(env); + if (pathValue.length === 0) return false; + const pathEntries = pathValue + .split(pathDelimiterForPlatform(platform)) + .map((entry) => stripWrappingQuotes(entry.trim())) + .filter((entry) => entry.length > 0); + + for (const pathEntry of pathEntries) { + for (const candidate of commandCandidates) { + if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) { + return true; + } + } + } + return false; +} + +export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArray { + const appData = env.APPDATA?.trim(); + const localAppData = env.LOCALAPPDATA?.trim(); + const userProfile = env.USERPROFILE?.trim(); + + return [ + ...(appData ? [`${appData}\\npm`] : []), + ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), + ...(localAppData ? [`${localAppData}\\pnpm`] : []), + ...(userProfile ? [`${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), + ]; +} + +export interface WindowsEnvironmentResolverOptions { + readonly readEnvironment?: WindowsShellEnvironmentReader; + readonly commandAvailable?: typeof isCommandAvailable; +} + +function readWindowsEnvironmentSafely( + readEnvironment: WindowsShellEnvironmentReader, + names: ReadonlyArray, + options?: WindowsEnvironmentProbeOptions, +): Partial> { + try { + return readEnvironment(names, options); + } catch { + return {}; + } +} + +function mergeWindowsEnv( + currentEnv: NodeJS.ProcessEnv, + patch: Partial>, +): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { ...currentEnv }; + for (const [key, value] of Object.entries(patch)) { + if (value !== undefined) { + nextEnv[key] = value; + } + } + return nextEnv; +} + +export function resolveWindowsEnvironment( + env: NodeJS.ProcessEnv, + options: WindowsEnvironmentResolverOptions = {}, +): Partial { + const readEnvironment = options.readEnvironment ?? readEnvironmentFromWindowsShell; + const commandAvailable = options.commandAvailable ?? isCommandAvailable; + const inheritedPath = readEnvPath(env); + const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { + loadProfile: false, + }).PATH; + const mergedPath = mergePathValues("win32", shellPath, inheritedPath); + const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); + const baselinePath = mergePathValues("win32", knownCliPath, mergedPath); + const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; + const baselineEnv = mergeWindowsEnv(env, baselinePatch); + + if (commandAvailable("node", { platform: "win32", env: baselineEnv })) { + return baselinePatch; + } + + const profiledEnvironment = readWindowsEnvironmentSafely( + readEnvironment, + ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], + { loadProfile: true }, + ); + const profiledPath = mergePathValues("win32", profiledEnvironment.PATH, baselinePath); + const profiledPatch: Partial = { + ...(profiledPath ? { PATH: profiledPath } : {}), + ...(profiledEnvironment.FNM_DIR ? { FNM_DIR: profiledEnvironment.FNM_DIR } : {}), + ...(profiledEnvironment.FNM_MULTISHELL_PATH + ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } + : {}), + }; + const finalPatch = + Object.keys(profiledPatch).length > 0 ? { ...baselinePatch, ...profiledPatch } : baselinePatch; + const finalEnv = mergeWindowsEnv(env, finalPatch); + + if (commandAvailable("node", { platform: "win32", env: finalEnv })) { + return finalPatch; + } + + return finalPatch; +} From 35ca32cf7f587b3e74cd380a5eba2d2eedd6d9ca Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:17:20 -0400 Subject: [PATCH 2/3] Handle CRLF-delimited PowerShell env values - Normalize captured shell output by trimming `\r\n` around extracted values - Add coverage for Windows PowerShell env parsing --- packages/shared/src/shell.test.ts | 17 +++++++++++++++++ packages/shared/src/shell.ts | 7 +------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 51454386e9..afbff3df7c 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -155,6 +155,23 @@ describe("readEnvironmentFromWindowsShell", () => { ); }); + it("strips CRLF delimiters from captured PowerShell values", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >( + () => + "__T3CODE_ENV_FNM_DIR_START__\r\nC:\\Users\\testuser\\AppData\\Roaming\\fnm\r\n__T3CODE_ENV_FNM_DIR_END__\r\n", + ); + + expect(readEnvironmentFromWindowsShell(["FNM_DIR"], execFile)).toEqual({ + FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", + }); + }); + it("omits -NoProfile when loadProfile is enabled", () => { const execFile = vi.fn< ( diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index e8d6acd8df..ea0ad8dbce 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -116,12 +116,7 @@ function extractEnvironmentValue(output: string, name: string): string | undefin if (endIndex === -1) return undefined; let value = output.slice(valueStartIndex, endIndex); - if (value.startsWith("\n")) { - value = value.slice(1); - } - if (value.endsWith("\n")) { - value = value.slice(0, -1); - } + value = value.replace(/^\r?\n/, "").replace(/\r?\n$/, ""); return value.length > 0 ? value : undefined; } From bfff5f26adb03a2f27e4e76aa198e4426abf49a0 Mon Sep 17 00:00:00 2001 From: Evan Yu <50347938+Badbird5907@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:20:36 -0400 Subject: [PATCH 3/3] Simplify Windows env resolution - Remove redundant node availability probe - Return the profiled patch directly when present --- packages/shared/src/shell.test.ts | 4 +++- packages/shared/src/shell.ts | 12 +++--------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index afbff3df7c..81da5fa4d8 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -326,7 +326,7 @@ describe("resolveWindowsEnvironment", () => { } : { PATH: "C:\\Shell\\Bin;C:\\Windows\\System32" }, ); - const commandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true); + const commandAvailable = vi.fn(() => false); expect( resolveWindowsEnvironment( @@ -360,6 +360,7 @@ describe("resolveWindowsEnvironment", () => { expect(readEnvironment).toHaveBeenNthCalledWith(2, ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"], { loadProfile: true, }); + expect(commandAvailable).toHaveBeenCalledTimes(1); }); it("keeps the baseline env when profiled probe still does not resolve node", () => { @@ -390,5 +391,6 @@ describe("resolveWindowsEnvironment", () => { ].join(";"), FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", }); + expect(commandAvailable).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index ea0ad8dbce..1680714fbe 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -428,13 +428,7 @@ export function resolveWindowsEnvironment( ? { FNM_MULTISHELL_PATH: profiledEnvironment.FNM_MULTISHELL_PATH } : {}), }; - const finalPatch = - Object.keys(profiledPatch).length > 0 ? { ...baselinePatch, ...profiledPatch } : baselinePatch; - const finalEnv = mergeWindowsEnv(env, finalPatch); - - if (commandAvailable("node", { platform: "win32", env: finalEnv })) { - return finalPatch; - } - - return finalPatch; + return Object.keys(profiledPatch).length > 0 + ? { ...baselinePatch, ...profiledPatch } + : baselinePatch; }