Skip to content

SessionStart hook reports "Vercel CLI is not installed" on Windows + Node 22+ when CLI is installed #95

@the-owlopus

Description

@the-owlopus

Summary

The SessionStart hook (hooks/session-start-profiler.mjs) emits "IMPORTANT: The Vercel CLI is not installed" on Windows even when vercel --version works fine from PowerShell, Git Bash, and Node child processes. Root cause is in resolveBinaryFromPath() + checkVercelCli() and reproduces reliably on Windows 11 + Node 22+.

Environment

  • Plugin version: vercel@0.43.0 (claude-plugins-official cache)
  • OS: Windows 11 Home 10.0.26200
  • Node: v24.16.0
  • Vercel CLI: 54.7.1, installed via npm i -g vercel to C:\Users\<user>\AppData\Roaming\npm\
  • Shells confirmed working: PowerShell 7, Git Bash, Node child processes spawned directly

Reproduction

  1. On Windows, install Vercel CLI globally: npm i -g vercel. This produces three files in the npm global dir:
    • vercel (extensionless POSIX shell script with #!/bin/sh)
    • vercel.cmd (Windows batch wrapper)
    • vercel.ps1 (PowerShell wrapper)
  2. Confirm CLI works: vercel --version returns the version number from any shell.
  3. Run the hook directly with a synthetic SessionStart payload + debug logging:
    import { spawnSync } from "node:child_process";
    const r = spawnSync(
      process.execPath,
      ["<plugin>/hooks/session-start-profiler.mjs"],
      {
        input: JSON.stringify({ session_id: "x", hook_event_name: "SessionStart", source: "startup", cwd: "<your-vercel-project>" }),
        env: { ...process.env, VERCEL_PLUGIN_HOOK_DEBUG: "1" },
        encoding: "utf8",
      }
    );
    console.log(r.stdout, r.stderr);

Expected

Hook detects the installed CLI and does not emit the "not installed" warning.

Actual

Hook emits the "not installed" warning. Debug stderr shows:

{"event":"session-start-profiler:vercel-version-check-failed",
 "command":"C:\\Users\\<user>\\AppData\\Roaming\\npm\\vercel",
 "args":"--version",
 "error":{"name":"Error","message":"spawnSync ...\\vercel ENOENT","code":"ENOENT", ...}}

Root cause (two layers)

Layer 1 — wrong file resolved

resolveBinaryFromPath() (lines 198-231) builds candidate names with the empty suffix first, then PATHEXT extensions:

const suffixes = hasExecutableExtension ? [""] : ["", ...WINDOWS_EXECUTABLE_EXTENSIONS];

On Windows, NTFS has no POSIX exec bit, so accessSync(path, X_OK) effectively reduces to F_OK. The extensionless vercel (bash script) file exists → passes the access check → wins. The function returns ...\\npm\\vercel instead of ...\\npm\\vercel.cmd.

Layer 2 — execFileSync can't launch what was returned

checkVercelCli() (line 262) then calls:

execFileSync(vercelBinary, VERCEL_VERSION_ARGS, { ... })
  • Extensionless bash script → ENOENT (Windows CreateProcessW requires a recognized extension).

  • Even if Layer 1 were fixed to prefer .cmd, execFileSync on .cmd/.bat files in Node ≥ 18.20.2 / 20.12.2 / 22.x also fails with EINVAL unless shell: true is set. This is the CVE-2024-27980 mitigation. Direct test:

    execFileSync("C:\\Users\\Jaime\\AppData\\Roaming\\npm\\vercel.cmd", ["--version"])
    // Error: spawnSync ... EINVAL

Proposed fix

Two single-line changes; either alone would fix the reported symptom, but both together are correct:

  1. In resolveBinaryFromPath(), on Windows, put the empty suffix last instead of first — extensionless files should be a fallback, not a preference:

    const suffixes = hasExecutableExtension
      ? [""]
      : process.platform === "win32"
        ? [...WINDOWS_EXECUTABLE_EXTENSIONS, ""]
        : ["", ...WINDOWS_EXECUTABLE_EXTENSIONS];
  2. In checkVercelCli(), pass shell: true on Windows so .cmd files launch correctly:

    const raw = execFileSync(vercelBinary, VERCEL_VERSION_ARGS, {
      timeout: EXEC_SYNC_TIMEOUT_MS,
      encoding: "utf-8",
      stdio: SPAWN_STDIO,
      shell: process.platform === "win32",
    }).trim();

    Note: shell: true here is safe because VERCEL_VERSION_ARGS is a hardcoded literal ("--version") and vercelBinary came from accessSync on a PATH-resolved candidate, not user input. No command-injection surface.

The same patches should be applied wherever else the plugin runs binaries this way (e.g. npmBinary on line 276).

Impact

Cosmetic in terms of CLI usability (CLI itself works fine from any shell), but functionally the hook also fails to populate the rest of its env-var injection / context profile for the session because the profiler short-circuits on the failure. Every Claude Code session on Windows with the CLI installed sees a misleading warning recommending re-installing what's already installed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions