Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ node_modules
.claude
dist
*.vsix
package-lock.json
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
48 changes: 44 additions & 4 deletions src/_resolve.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,15 +12,17 @@ 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) */
access?: (path: string, mode: number) => void;
}

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 ?? "";
Expand All @@ -27,18 +31,28 @@ 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)),
);

// 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];
Expand All @@ -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;
}
104 changes: 99 additions & 5 deletions test/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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: "",
Expand All @@ -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", () => {
Expand Down