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/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 098b8a2..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; @@ -10,6 +12,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 +23,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 ?? ""; @@ -27,10 +31,20 @@ 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; + // 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); + 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 +52,7 @@ export function resolvePiBinary(opts: ResolveOptions = {}): string { // Then well-known global paths const globalCandidates = isWin - ? [] + ? 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]; @@ -64,3 +78,29 @@ export function resolvePiBinary(opts: ResolveOptions = {}): string { return "pi"; } + +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 { + // If path already has any extension (dot after the last separator), leave it alone. + const sep = Math.max(filePath.lastIndexOf("\\"), filePath.lastIndexOf("/")); + if (filePath.lastIndexOf(".") > sep) return null; + + for (const ext of WIN_EXECUTABLE_EXTENSIONS) { + 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..9010046 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -14,6 +14,68 @@ 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("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({ + 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 +113,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 +157,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", () => {