From bcb87987894141d43f40c4268108838f443f7b0a Mon Sep 17 00:00:00 2001 From: Varun Anand Patkar Date: Wed, 22 Apr 2026 21:10:29 +0530 Subject: [PATCH 1/2] fix: resolve pi binary on Windows (global paths + custom path .cmd/.exe resolution) --- .gitignore | 1 + src/_resolve.ts | 53 +++++++++++++++++++++++-- test/resolve.test.ts | 94 +++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 334f48a..be6c204 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ node_modules .claude dist *.vsix +package-lock.json diff --git a/src/_resolve.ts b/src/_resolve.ts index 098b8a2..0e3ac2a 100644 --- a/src/_resolve.ts +++ b/src/_resolve.ts @@ -10,6 +10,10 @@ export interface ResolveOptions { home?: string; /** PATH environment variable */ pathEnv?: string; + /** %APPDATA% on Windows (defaults to process.env.APPDATA) */ + appData?: string; + /** %LOCALAPPDATA% on Windows (defaults to process.env.LOCALAPPDATA) */ + localAppData?: string; /** Workspace root directories */ workspaceDirs?: string[]; /** File access check (defaults to fs.accessSync) */ @@ -17,8 +21,6 @@ export interface ResolveOptions { } export function resolvePiBinary(opts: ResolveOptions = {}): string { - if (opts.customPath) return opts.customPath; - const platform = opts.platform ?? process.platform; const home = opts.home ?? process.env.HOME ?? process.env.USERPROFILE ?? ""; const pathEnv = opts.pathEnv ?? process.env.PATH ?? ""; @@ -31,6 +33,17 @@ export function resolvePiBinary(opts: ResolveOptions = {}): string { // Windows lacks Unix-style execute permission; just check the file exists const accessFlag = isWin ? constants.F_OK : constants.X_OK; + // If custom path provided, on Windows try .cmd/.exe/.ps1 variants when + // the path has no recognised executable extension (extensionless npm shims + // are bash scripts that Windows cannot spawn). + if (opts.customPath) { + if (isWin) { + const resolved = resolveWindowsExecutable(opts.customPath, access); + if (resolved) return resolved; + } + return opts.customPath; + } + // Check workspace-local node_modules/.bin first (respects monorepos / multi-root) const workspaceCandidates = workspaceDirs.flatMap((dir) => names.map((n) => join(dir, "node_modules", ".bin", n)), @@ -38,7 +51,14 @@ export function resolvePiBinary(opts: ResolveOptions = {}): string { // Then well-known global paths const globalCandidates = isWin - ? [] + ? (() => { + const appData = opts.appData ?? process.env.APPDATA ?? ""; + const localAppData = opts.localAppData ?? process.env.LOCALAPPDATA ?? ""; + const dirs: string[] = []; + if (appData) dirs.push(join(appData, "npm")); + if (localAppData) dirs.push(join(localAppData, "pnpm")); + return dirs.flatMap((d) => names.map((n) => join(d, n))); + })() : [`${home}/.bun/bin/pi`, `${home}/.local/bin/pi`, `${home}/.npm-global/bin/pi`]; const candidates = [...workspaceCandidates, ...globalCandidates]; @@ -64,3 +84,30 @@ export function resolvePiBinary(opts: ResolveOptions = {}): string { return "pi"; } + +/** + * On Windows, if a path has no recognised executable extension (.cmd/.exe/.ps1), + * try appending each extension and return the first that exists on disk. + * Returns null when the path already has a valid extension or no variant is found. + */ +function resolveWindowsExecutable( + filePath: string, + access: (path: string, mode: number) => void, +): string | null { + const winExts = [".cmd", ".exe", ".ps1"]; + const dot = filePath.lastIndexOf("."); + const sep = Math.max(filePath.lastIndexOf("\\"), filePath.lastIndexOf("/")); + // Only treat as an extension if the dot is after the last path separator + if (dot > sep && dot !== -1) { + const ext = filePath.slice(dot).toLowerCase(); + if (winExts.includes(ext)) return null; // already has valid extension + } + + for (const ext of winExts) { + try { + access(filePath + ext, constants.F_OK); + return filePath + ext; + } catch {} + } + return null; +} diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 9eaab7e..9f7275e 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -14,6 +14,58 @@ describe("resolvePiBinary", () => { expect(resolvePiBinary({ customPath: "/custom/pi" })).toBe("/custom/pi"); }); + it("resolves custom path to .cmd on windows when extensionless", () => { + const customPath = "C:\\nvm4w\\nodejs\\pi"; + const cmdPath = "C:\\nvm4w\\nodejs\\pi.cmd"; + const result = resolvePiBinary({ + customPath, + platform: "win32", + access: mockAccess(new Set([cmdPath])), + }); + expect(result).toBe(cmdPath); + }); + + it("resolves custom path to .exe on windows when .cmd absent", () => { + const customPath = "C:\\nvm4w\\nodejs\\pi"; + const exePath = "C:\\nvm4w\\nodejs\\pi.exe"; + const result = resolvePiBinary({ + customPath, + platform: "win32", + access: mockAccess(new Set([exePath])), + }); + expect(result).toBe(exePath); + }); + + it("returns custom path as-is on windows when it already has .cmd extension", () => { + const customPath = "C:\\nvm4w\\nodejs\\pi.cmd"; + const result = resolvePiBinary({ + customPath, + platform: "win32", + access: mockAccess(new Set()), + }); + expect(result).toBe(customPath); + }); + + it("returns custom path as-is on windows when no variant found", () => { + const customPath = "C:\\nvm4w\\nodejs\\pi"; + const result = resolvePiBinary({ + customPath, + platform: "win32", + access: mockAccess(new Set()), + }); + expect(result).toBe(customPath); + }); + + it("does not resolve custom path extensions on unix", () => { + const customPath = "/usr/local/bin/pi"; + const result = resolvePiBinary({ + customPath, + platform: "linux", + access: mockAccess(new Set()), + }); + expect(result).toBe(customPath); + }); + it("finds pi in workspace node_modules/.bin on unix", () => { const wsDir = "/projects/myapp"; const piPath = join(wsDir, "node_modules", ".bin", "pi"); @@ -51,11 +103,42 @@ describe("resolvePiBinary", () => { expect(result).toBe(bunPath); }); - it("skips global unix paths on windows", () => { - const home = "C:\\Users\\dev"; + it("finds pi.cmd in APPDATA\\npm on windows", () => { + const appData = "C:\\Users\\dev\\AppData\\Roaming"; + const piCmd = join(appData, "npm", "pi.cmd"); const result = resolvePiBinary({ platform: "win32", - home, + home: "C:\\Users\\dev", + appData, + localAppData: "", + workspaceDirs: [], + access: mockAccess(new Set([piCmd])), + pathEnv: "", + }); + expect(result).toBe(piCmd); + }); + + it("finds pi.cmd in LOCALAPPDATA\\pnpm on windows", () => { + const localAppData = "C:\\Users\\dev\\AppData\\Local"; + const piCmd = join(localAppData, "pnpm", "pi.cmd"); + const result = resolvePiBinary({ + platform: "win32", + home: "C:\\Users\\dev", + appData: "", + localAppData, + workspaceDirs: [], + access: mockAccess(new Set([piCmd])), + pathEnv: "", + }); + expect(result).toBe(piCmd); + }); + + it("falls back to 'pi' on windows when nothing found", () => { + const result = resolvePiBinary({ + platform: "win32", + home: "C:\\Users\\dev", + appData: "", + localAppData: "", workspaceDirs: [], access: mockAccess(new Set()), pathEnv: "", @@ -64,14 +147,15 @@ describe("resolvePiBinary", () => { }); it("finds pi in PATH on unix", () => { + const piPath = join("/usr/local/bin", "pi"); const result = resolvePiBinary({ platform: "linux", home: "/home/user", workspaceDirs: [], - access: mockAccess(new Set(["/usr/local/bin/pi"])), + access: mockAccess(new Set([piPath])), pathEnv: "/usr/bin:/usr/local/bin", }); - expect(result).toBe("/usr/local/bin/pi"); + expect(result).toBe(piPath); }); it("finds pi.cmd in PATH on windows", () => { From 478ac76262c22d6a33e50af2fa211de17b11b1ee Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 24 Apr 2026 13:12:20 +0200 Subject: [PATCH 2/2] up --- README.md | 2 ++ src/_resolve.ts | 43 ++++++++++++++++++------------------------- test/resolve.test.ts | 10 ++++++++++ 3 files changed, 30 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 879d277..e43132f 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,5 @@ These bridge tools let pi inspect selections, diagnostics, symbols, definitions, | Setting | Default | Description | | ---------------- | ------- | ------------------------------------------------------- | | `pi-vscode.path` | `""` | Absolute path to the pi binary (auto-detected if empty) | + +On Windows, an extensionless `pi-vscode.path` is auto-probed for `.cmd`/`.exe`/`.ps1` variants so extensionless npm shims work out of the box. diff --git a/src/_resolve.ts b/src/_resolve.ts index 0e3ac2a..c4c76b8 100644 --- a/src/_resolve.ts +++ b/src/_resolve.ts @@ -1,6 +1,8 @@ import { accessSync, constants } from "node:fs"; import { join } from "node:path"; +const WIN_EXECUTABLE_EXTENSIONS = [".cmd", ".exe", ".ps1"]; + export interface ResolveOptions { /** User-configured custom path */ customPath?: string; @@ -29,13 +31,12 @@ export function resolvePiBinary(opts: ResolveOptions = {}): string { const isWin = platform === "win32"; // On Windows, npm/pnpm create .cmd shims; also check .exe and .ps1 - const names = isWin ? ["pi.cmd", "pi.exe", "pi.ps1"] : ["pi"]; + const names = isWin ? WIN_EXECUTABLE_EXTENSIONS.map((ext) => `pi${ext}`) : ["pi"]; // Windows lacks Unix-style execute permission; just check the file exists const accessFlag = isWin ? constants.F_OK : constants.X_OK; - // If custom path provided, on Windows try .cmd/.exe/.ps1 variants when - // the path has no recognised executable extension (extensionless npm shims - // are bash scripts that Windows cannot spawn). + // Extensionless npm shims on Windows are bash scripts that cannot be spawned; + // probe for .cmd/.exe/.ps1 variants when the custom path has no extension. if (opts.customPath) { if (isWin) { const resolved = resolveWindowsExecutable(opts.customPath, access); @@ -51,14 +52,7 @@ export function resolvePiBinary(opts: ResolveOptions = {}): string { // Then well-known global paths const globalCandidates = isWin - ? (() => { - const appData = opts.appData ?? process.env.APPDATA ?? ""; - const localAppData = opts.localAppData ?? process.env.LOCALAPPDATA ?? ""; - const dirs: string[] = []; - if (appData) dirs.push(join(appData, "npm")); - if (localAppData) dirs.push(join(localAppData, "pnpm")); - return dirs.flatMap((d) => names.map((n) => join(d, n))); - })() + ? windowsGlobalDirs(opts).flatMap((d) => names.map((n) => join(d, n))) : [`${home}/.bun/bin/pi`, `${home}/.local/bin/pi`, `${home}/.npm-global/bin/pi`]; const candidates = [...workspaceCandidates, ...globalCandidates]; @@ -85,25 +79,24 @@ export function resolvePiBinary(opts: ResolveOptions = {}): string { return "pi"; } -/** - * On Windows, if a path has no recognised executable extension (.cmd/.exe/.ps1), - * try appending each extension and return the first that exists on disk. - * Returns null when the path already has a valid extension or no variant is found. - */ +function windowsGlobalDirs(opts: ResolveOptions): string[] { + const appData = opts.appData ?? process.env.APPDATA ?? ""; + const localAppData = opts.localAppData ?? process.env.LOCALAPPDATA ?? ""; + const dirs: string[] = []; + if (appData) dirs.push(join(appData, "npm")); + if (localAppData) dirs.push(join(localAppData, "pnpm")); + return dirs; +} + function resolveWindowsExecutable( filePath: string, access: (path: string, mode: number) => void, ): string | null { - const winExts = [".cmd", ".exe", ".ps1"]; - const dot = filePath.lastIndexOf("."); + // If path already has any extension (dot after the last separator), leave it alone. const sep = Math.max(filePath.lastIndexOf("\\"), filePath.lastIndexOf("/")); - // Only treat as an extension if the dot is after the last path separator - if (dot > sep && dot !== -1) { - const ext = filePath.slice(dot).toLowerCase(); - if (winExts.includes(ext)) return null; // already has valid extension - } + if (filePath.lastIndexOf(".") > sep) return null; - for (const ext of winExts) { + for (const ext of WIN_EXECUTABLE_EXTENSIONS) { try { access(filePath + ext, constants.F_OK); return filePath + ext; diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 9f7275e..9010046 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -56,6 +56,16 @@ describe("resolvePiBinary", () => { expect(result).toBe(customPath); }); + it("returns custom path as-is on windows when it has any extension", () => { + const customPath = "C:\\nvm4w\\nodejs\\pi.bat"; + const result = resolvePiBinary({ + customPath, + platform: "win32", + access: mockAccess(new Set()), + }); + expect(result).toBe(customPath); + }); + it("does not resolve custom path extensions on unix", () => { const customPath = "/usr/local/bin/pi"; const result = resolvePiBinary({