diff --git a/hooks/session-start-profiler-platform.test.ts b/hooks/session-start-profiler-platform.test.ts index 7521d16..f3469f1 100644 --- a/hooks/session-start-profiler-platform.test.ts +++ b/hooks/session-start-profiler-platform.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { detectSessionStartPlatform } from "./src/session-start-profiler.mts"; +import { + buildWindowsShellCommand, + detectSessionStartPlatform, + getBinaryPathCandidates, +} from "./src/session-start-profiler.mts"; describe("session-start-profiler platform detection", () => { test("test_session_start_profiler_does_not_infer_cursor_from_cursor_project_dir_alone", () => { @@ -26,3 +30,24 @@ describe("session-start-profiler platform detection", () => { ).toBe("claude-code"); }); }); + +describe("session-start-profiler Windows CLI resolution", () => { + test("test_windows_prefers_pathext_launchers_over_extensionless_npm_shims", () => { + const candidates = getBinaryPathCandidates("vercel", "win32"); + const uppercaseCandidates = candidates.map((candidate: string) => candidate.toUpperCase()); + + expect(uppercaseCandidates).toContain("VERCEL.CMD"); + expect(candidates).not.toContain("vercel"); + expect(candidates[0]).toMatch(/^vercel\./i); + }); + + test("test_windows_cmd_shims_are_quoted_for_the_command_shell", () => { + expect( + buildWindowsShellCommand( + "C:\\Users\\Adam\\AppData\\Roaming\\npm\\vercel.cmd", + ["--version"], + "win32", + ), + ).toBe('"C:\\Users\\Adam\\AppData\\Roaming\\npm\\vercel.cmd" "--version"'); + }); +}); diff --git a/hooks/session-start-profiler.mjs b/hooks/session-start-profiler.mjs index 6d71508..58141af 100644 --- a/hooks/session-start-profiler.mjs +++ b/hooks/session-start-profiler.mjs @@ -7,7 +7,7 @@ import { readdirSync } from "fs"; import { delimiter, join, resolve } from "path"; -import { execFileSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { fileURLToPath } from "url"; import { formatOutput, @@ -200,12 +200,12 @@ var SPAWN_STDIO = "ignore pipe ignore".split(" "); var EXEC_SYNC_TIMEOUT_MS = 3e3; var NUMERIC_VERSION_RE = /\d+(?:\.\d+)*/; var WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean); -function getBinaryPathCandidates(binaryName) { - if (process.platform !== "win32") { +function getBinaryPathCandidates(binaryName, platform = process.platform) { + if (platform !== "win32") { return [binaryName]; } const hasExecutableExtension = /\.[^./\\]+$/.test(binaryName); - const suffixes = hasExecutableExtension ? [""] : ["", ...WINDOWS_EXECUTABLE_EXTENSIONS]; + const suffixes = hasExecutableExtension ? [""] : WINDOWS_EXECUTABLE_EXTENSIONS; return suffixes.map((suffix) => `${binaryName}${suffix}`); } function resolveBinaryFromPath(binaryName) { @@ -234,6 +234,28 @@ function resolveBinaryFromPath(binaryName) { }); return null; } +function buildWindowsShellCommand(binaryPath, args, platform = process.platform) { + if (platform !== "win32" || !/\.(?:cmd|bat)$/i.test(binaryPath)) { + return null; + } + const quote = (value) => `"${value.replace(/"/g, '""')}"`; + return [quote(binaryPath), ...args.map(quote)].join(" "); +} +function execResolvedBinarySync(binaryPath, args) { + const windowsShellCommand = buildWindowsShellCommand(binaryPath, args); + const options = { + timeout: EXEC_SYNC_TIMEOUT_MS, + encoding: "utf-8", + stdio: SPAWN_STDIO + }; + if (windowsShellCommand) { + return execSync(windowsShellCommand, { + ...options, + shell: process.env.ComSpec || "cmd.exe" + }).trim(); + } + return execFileSync(binaryPath, args, options).trim(); +} function parseVersionSegments(version) { const matchedVersion = version.match(NUMERIC_VERSION_RE)?.[0]; if (!matchedVersion) { @@ -264,11 +286,7 @@ function checkVercelCli() { } let currentVersion; try { - const raw = execFileSync(vercelBinary, VERCEL_VERSION_ARGS, { - timeout: EXEC_SYNC_TIMEOUT_MS, - encoding: "utf-8", - stdio: SPAWN_STDIO - }).trim(); + const raw = execResolvedBinarySync(vercelBinary, VERCEL_VERSION_ARGS); const lines = raw.split("\n").map((l) => l.trim()).filter(Boolean); currentVersion = lines[lines.length - 1]; } catch (error) { @@ -284,11 +302,7 @@ function checkVercelCli() { } let latestVersion; try { - const raw = execFileSync(npmBinary, NPM_VIEW_ARGS, { - timeout: EXEC_SYNC_TIMEOUT_MS, - encoding: "utf-8", - stdio: SPAWN_STDIO - }).trim(); + const raw = execResolvedBinarySync(npmBinary, NPM_VIEW_ARGS); latestVersion = raw; } catch (error) { logCaughtError(log, "session-start-profiler:npm-latest-version-check-failed", error, { @@ -487,9 +501,11 @@ if (isSessionStartProfilerEntrypoint) { export { buildSessionStartProfilerEnvVars, buildSessionStartProfilerUserMessages, + buildWindowsShellCommand, checkGreenfield, detectSessionStartPlatform, formatSessionStartProfilerCursorOutput, + getBinaryPathCandidates, logBrokenSkillFrontmatterSummary, normalizeSessionStartSessionId, parseSessionStartInput, diff --git a/hooks/src/session-start-profiler.mts b/hooks/src/session-start-profiler.mts index 74b7a59..5c292db 100644 --- a/hooks/src/session-start-profiler.mts +++ b/hooks/src/session-start-profiler.mts @@ -20,7 +20,7 @@ import { } from "node:fs"; import { homedir } from "node:os"; import { delimiter, join, resolve } from "node:path"; -import { execFileSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { formatOutput, @@ -330,13 +330,18 @@ const WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.C .split(";") .filter(Boolean); -function getBinaryPathCandidates(binaryName: string): string[] { - if (process.platform !== "win32") { +export function getBinaryPathCandidates( + binaryName: string, + platform: NodeJS.Platform = process.platform, +): string[] { + if (platform !== "win32") { return [binaryName]; } const hasExecutableExtension = /\.[^./\\]+$/.test(binaryName); - const suffixes = hasExecutableExtension ? [""] : ["", ...WINDOWS_EXECUTABLE_EXTENSIONS]; + // npm installs an extensionless POSIX shim beside PATHEXT launchers on + // Windows. Node's execFileSync cannot execute that shim or a .cmd directly. + const suffixes = hasExecutableExtension ? [""] : WINDOWS_EXECUTABLE_EXTENSIONS; return suffixes.map((suffix: string) => `${binaryName}${suffix}`); } @@ -368,6 +373,37 @@ function resolveBinaryFromPath(binaryName: string): string | null { return null; } +export function buildWindowsShellCommand( + binaryPath: string, + args: string[], + platform: NodeJS.Platform = process.platform, +): string | null { + if (platform !== "win32" || !/\.(?:cmd|bat)$/i.test(binaryPath)) { + return null; + } + + const quote = (value: string): string => `"${value.replace(/"/g, '""')}"`; + return [quote(binaryPath), ...args.map(quote)].join(" "); +} + +function execResolvedBinarySync(binaryPath: string, args: string[]): string { + const windowsShellCommand = buildWindowsShellCommand(binaryPath, args); + const options = { + timeout: EXEC_SYNC_TIMEOUT_MS, + encoding: "utf-8" as const, + stdio: SPAWN_STDIO, + }; + + if (windowsShellCommand) { + return execSync(windowsShellCommand, { + ...options, + shell: process.env.ComSpec || "cmd.exe", + }).trim(); + } + + return execFileSync(binaryPath, args, options).trim(); +} + function parseVersionSegments(version: string): number[] | null { const matchedVersion = version.match(NUMERIC_VERSION_RE)?.[0]; if (!matchedVersion) { @@ -413,11 +449,7 @@ function checkVercelCli(): VercelCliStatus { // 1. Check if vercel is installed let currentVersion: string | undefined; try { - const raw: string = execFileSync(vercelBinary, VERCEL_VERSION_ARGS, { - timeout: EXEC_SYNC_TIMEOUT_MS, - encoding: "utf-8", - stdio: SPAWN_STDIO, - }).trim(); + const raw: string = execResolvedBinarySync(vercelBinary, VERCEL_VERSION_ARGS); // Output may include extra lines; version is typically last non-empty line const lines: string[] = raw.split("\n").map((l: string) => l.trim()).filter(Boolean); currentVersion = lines[lines.length - 1]; @@ -437,11 +469,7 @@ function checkVercelCli(): VercelCliStatus { // 2. Fetch latest version from npm registry let latestVersion: string | undefined; try { - const raw: string = execFileSync(npmBinary, NPM_VIEW_ARGS, { - timeout: EXEC_SYNC_TIMEOUT_MS, - encoding: "utf-8", - stdio: SPAWN_STDIO, - }).trim(); + const raw: string = execResolvedBinarySync(npmBinary, NPM_VIEW_ARGS); latestVersion = raw; } catch (error) { logCaughtError(log, "session-start-profiler:npm-latest-version-check-failed", error, {