Skip to content

Commit 264ca87

Browse files
George-iamclaude
andcommitted
fix(extension): Windows binary spawn must not depend on user-installed Node
Replaces the previous attempt (which assumed `node.exe` was on PATH — true for devs, not for typical chat-IDE users) with a Node-independent path that uses Cursor's bundled Electron runtime. Mechanism: every Electron binary (Cursor.exe, Code.exe) can be invoked as a plain Node interpreter by setting the env var ELECTRON_RUN_AS_NODE=1. In the extension host `process.execPath` is the absolute path to that Electron binary, so we always have it available. This is the same pattern VS Code uses internally for language servers and other Node subprocesses (vscode-languageclient, vscode/extensions/typescript-language-features, etc.). Three call surfaces fixed: 1. extension/src/spawn-binary.ts — used by search-mode toggle, backlog add/update, setup, status webview, auditor auth. Replaces `spawn("node", ...)` with `spawn(process.execPath, ..., { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }})`. 2. extension/src/mcp-register.ts — the headline path. Registers MCP with `command: process.execPath, env: { ELECTRON_RUN_AS_NODE: "1" }` on Windows, so Cursor's MCP runner spawns Cursor.exe-as-Node when it starts the AXME MCP server. 3. extension/src/hooks-install.ts — Cursor's hook runner spawns hook commands via cmd.exe. We can't pass env vars through hooks.json (the schema is just command/type/timeout). On Windows we now write a tiny `~/.cursor/axme-hook.cmd` wrapper at install time that sets ELECTRON_RUN_AS_NODE=1 + invokes Cursor.exe with the bundled binary as argv[0]. hooks.json points at this wrapper. uninstallUserHooks() deletes the wrapper alongside the JSON entries. ELECTRON_RUN_AS_NODE behaviour is documented at: https://www.electronjs.org/docs/latest/api/environment-variables#electron_run_as_node Linux + macOS paths unchanged — shebang shim works natively. Reported by @geobelsky after the v0.1.0 install failed silently on a Windows machine without Node, leaving every MCP tool unreachable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a02e3ac commit 264ca87

3 files changed

Lines changed: 92 additions & 18 deletions

File tree

extension/src/hooks-install.ts

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
* added entries are preserved verbatim.
2222
*/
2323

24-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2525
import { dirname, join } from "node:path";
2626
import { homedir } from "node:os";
2727
import { IdeKind } from "./ide-detect.js";
@@ -58,19 +58,65 @@ function quote(s: string): string {
5858
return `"${s.replace(/"/g, '\\"')}"`;
5959
}
6060

61+
/**
62+
* Path to the Windows wrapper script. Lives next to hooks.json so a
63+
* single uninstall sweep deletes both. The wrapper is a one-liner .cmd
64+
* that sets ELECTRON_RUN_AS_NODE=1 and invokes Cursor.exe as a Node
65+
* interpreter on the bundled binary — see buildHookCommand() rationale
66+
* below for the full explanation.
67+
*/
68+
function windowsHookWrapperPath(): string {
69+
return join(homedir(), ".cursor", "axme-hook.cmd");
70+
}
71+
72+
/**
73+
* Write the Windows .cmd wrapper that lets Cursor's hook runner invoke
74+
* our shebang-shim binary without requiring `node.exe` on PATH. Returns
75+
* the wrapper path (caller writes it into the hook command string).
76+
*
77+
* The wrapper captures the Cursor.exe path (process.execPath in the
78+
* extension host) AND the absolute path to the bundled binary, so it
79+
* works even when the user's PATH lacks Node and even when Cursor is
80+
* installed in a non-standard location. ELECTRON_RUN_AS_NODE=1 tells
81+
* Electron to behave as a plain Node interpreter; same trick VS Code
82+
* uses internally for language servers.
83+
*/
84+
function writeWindowsHookWrapper(binary: string): string {
85+
const path = windowsHookWrapperPath();
86+
// cmd.exe parser quirks:
87+
// - `@echo off` silences the prompt echo
88+
// - `setlocal` scopes the env var to this script invocation
89+
// - `%*` forwards all caller args verbatim (with quoting preserved)
90+
// The Cursor.exe path comes from process.execPath at install time —
91+
// if Cursor relocates, user re-runs setup and we rewrite this file.
92+
const content =
93+
`@echo off\r\n` +
94+
`setlocal\r\n` +
95+
`set ELECTRON_RUN_AS_NODE=1\r\n` +
96+
`"${process.execPath}" "${binary}" %*\r\n`;
97+
mkdirSync(dirname(path), { recursive: true });
98+
writeFileSync(path, content, "utf-8");
99+
log(`Hooks: wrote Windows wrapper at ${path}`);
100+
return path;
101+
}
102+
61103
function buildHookCommand(binary: string, hookName: string): string {
62104
// No --workspace flag — handler core resolves it from stdin
63105
// workspace_roots[0] (PR #129 commit d267b82).
64106
//
65107
// Cross-platform: the bundled binary is a shebang shim (`#!/usr/bin/env
66108
// node` + CJS payload). POSIX honors the shebang and runs it directly.
67109
// Windows ignores shebangs and fails with ENOENT when cmd.exe / Cursor
68-
// tries to exec the file. On Windows we prefix the command with `node`,
69-
// which Cursor users on Windows typically have on PATH (standard dev
70-
// setup). Falls through gracefully — Cursor's hook runner uses cmd.exe
71-
// /c so PATH lookup works the same as in any terminal.
110+
// tries to exec the file. We do NOT rely on Node being on PATH (most
111+
// Windows chat-IDE users do not have it) — instead, the .cmd wrapper
112+
// we write at install time invokes Cursor.exe with the
113+
// ELECTRON_RUN_AS_NODE=1 env var, making Cursor's bundled Electron
114+
// behave as a Node interpreter for our JS payload. The wrapper
115+
// captures absolute Cursor.exe + binary paths at install time so
116+
// the hook fires the same way regardless of the user's shell config.
72117
if (process.platform === "win32") {
73-
return `node ${quote(binary)} hook ${hookName} --ide cursor`;
118+
const wrapper = writeWindowsHookWrapper(binary);
119+
return `${quote(wrapper)} hook ${hookName} --ide cursor`;
74120
}
75121
return `${quote(binary)} hook ${hookName} --ide cursor`;
76122
}
@@ -157,4 +203,16 @@ export function uninstallUserHooks(): void {
157203
} catch (err) {
158204
logError("Hooks: uninstall failed", err);
159205
}
206+
// Drop the Windows .cmd wrapper (no-op on POSIX — the file never existed).
207+
if (process.platform === "win32") {
208+
const wrapper = windowsHookWrapperPath();
209+
if (existsSync(wrapper)) {
210+
try {
211+
unlinkSync(wrapper);
212+
log(`Hooks: removed Windows wrapper ${wrapper}`);
213+
} catch (err) {
214+
logError("Hooks: wrapper removal failed", err);
215+
}
216+
}
217+
}
160218
}

extension/src/mcp-register.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,26 @@ export async function registerMcpServer(
5858
// execute the file as a PE binary → Cursor's MCP runner does
5959
// `spawn(command, args)` directly and gets ENOENT, which surfaces in
6060
// the chat as "MCP server does not exist … No MCP servers available."
61-
// The fix mirrors what spawn-binary.ts does for our own child_process
62-
// spawns: register with command="node" and the binary as the first
63-
// argv on Windows, so Node loads the JS payload regardless of the
64-
// file extension. Linux + macOS keep the direct path.
61+
//
62+
// The fix uses Cursor's own Electron binary as the Node interpreter.
63+
// `process.execPath` in the extension host = path to Cursor.exe (or
64+
// Code.exe in VS Code), which is an Electron binary that can run as
65+
// plain Node when invoked with the env var `ELECTRON_RUN_AS_NODE=1`.
66+
// This eliminates the dependency on the user having `node.exe` on
67+
// PATH — most Windows users of a chat-agent IDE will not. Same
68+
// pattern VS Code uses internally for language servers and other
69+
// Node subprocesses.
70+
//
71+
// Documented: https://www.electronjs.org/docs/latest/api/environment-variables#electron_run_as_node
6572
const isWindows = process.platform === "win32";
66-
const command = isWindows ? "node" : binary;
73+
const command = isWindows ? process.execPath : binary;
6774
const args = isWindows ? [binary, ...serveArgs] : serveArgs;
75+
const env: Record<string, string> = isWindows ? { ELECTRON_RUN_AS_NODE: "1" } : {};
6876
cursor.registerServer({
6977
name: "axme",
70-
server: { command, args, env: {} },
78+
server: { command, args, env },
7179
});
72-
log(`MCP: registered 'axme' (command=${command}, binary=${binary}, workspace=${workspaceRoot ?? "(none)"})`);
80+
log(`MCP: registered 'axme' (command=${command}, binary=${binary}, workspace=${workspaceRoot ?? "(none)"}, electron-as-node=${isWindows ? "yes" : "no"})`);
7381
// Cursor needs ~3s to process the registration before tools become
7482
// available to the chat agent. Verified empirically against the
7583
// browser-devtools-mcp reference implementation.

extension/src/spawn-binary.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@
77
* and rejects the file with ENOENT / UNKNOWN when treated as an
88
* executable, regardless of the .exe / .cjs file-extension we ship.
99
*
10-
* The fix on Windows is to invoke via `node <binary>` so Node executes
11-
* the JS payload directly. Cursor users on Windows nearly always have
12-
* Node installed for dev work; we rely on `node` being on PATH (cmd.exe
13-
* /c looks up commands the same way an interactive shell does).
10+
* The fix on Windows: invoke via Cursor's own Electron binary
11+
* (`process.execPath`) with the env var `ELECTRON_RUN_AS_NODE=1`. That
12+
* makes Cursor.exe behave as a plain Node interpreter and execute the
13+
* JS payload. We DO NOT rely on the user having `node.exe` on PATH —
14+
* most Windows users of a chat-agent IDE will not.
15+
*
16+
* This is the same pattern VS Code itself uses internally for spawning
17+
* Node subprocesses (e.g. language servers via vscode-languageclient).
18+
* Documented at https://www.electronjs.org/docs/latest/api/environment-variables#electron_run_as_node
1419
*
1520
* Every spawn of the bundled binary in the extension should go through
1621
* this helper. A direct `spawn(binary, args)` will work on Linux + macOS
@@ -40,7 +45,10 @@ export function spawnBinary(
4045
): ChildProcess {
4146
const opts = options ?? {};
4247
if (process.platform === "win32") {
43-
return spawn("node", [binary, ...args], opts);
48+
return spawn(process.execPath, [binary, ...args], {
49+
...opts,
50+
env: { ...process.env, ...(opts.env as NodeJS.ProcessEnv | undefined), ELECTRON_RUN_AS_NODE: "1" },
51+
});
4452
}
4553
return spawn(binary, args, opts);
4654
}

0 commit comments

Comments
 (0)