Skip to content

Commit ca322c3

Browse files
George-iamclaude
andcommitted
fix(extension): Windows works without Node installed — bundle Node.exe in .vsix + sidebar path fix
v0.1.1 release. Two failed Windows attempts (PR #136 attempt 1 used "node" on PATH; attempt 2 used Cursor.exe + ELECTRON_RUN_AS_NODE) both left users with a non-functional extension on Windows. Deep review 2026-05-19 produced two concrete findings + one strategy change. Finding 1 (concrete bug) — sidebar empty on Windows: extension/src/sidebar-webview.ts:326-328 computed the per-project header via `workspaceRoot.split("/").filter(Boolean).pop()`. Windows paths use backslashes (`C:\Users\...\repo`), so split returns the entire path as a single element and the header renders as the literal full path. This corrupts downstream HTML layout — almost certainly the root cause user reported as "монитор пустой экран". Fix: use path.basename(), which is platform-aware. Finding 2 (unverified assumption) — MCP env pass-through unreliable: Cursor's `cursor.mcp.registerServer({command, args, env})` API is undocumented, and there's no confirmation Cursor merges the `env` field into the spawn call. ELECTRON_RUN_AS_NODE was almost certainly never reaching the spawned MCP process. Forum threads document inconsistent Cursor behaviour around this env var. Strategy change — self-contained bundle: Per user requirement, the extension must work on any Windows machine without Node.js installed AND without depending on Cursor's internal Node-as-Electron behaviour. The .vsix now ships an actual node.exe inside extension/bin/node-windows-x64.exe, downloaded from official nodejs.org during CI build (v20.20.2, SHA256-pinned). The extension invokes that bundled Node directly with the bundled JS payload — plain process spawn, no env tricks, no system-Node dependency. Files: - .github/workflows/publish-extension.yml — new Windows-only step downloads node-v20.20.2-win-x64.zip, verifies SHA256, extracts node.exe into extension/bin/node-windows-x64.exe. Runs before the existing "Bundle core CLI" step so the win32-x64 .vsix packages both files (the shebang-shim text payload AND the real Node interpreter). - extension/src/binary-detect.ts — new findBundledNode() helper resolves the bundled-Node path on Windows; returns undefined on Linux/macOS (those execute the shebang shim natively). - extension/src/spawn-binary.ts — Windows branch rewritten: uses the module-cached bundled-Node path instead of process.execPath + ELECTRON_RUN_AS_NODE. Adds setBundledNode() / getBundledNode() so the cache is set once at activation and read everywhere. - extension/src/mcp-register.ts — Windows branch rewritten: registers Cursor MCP with command = bundled-node.exe path, args = [binary, serve, ...]. No env-field dependency. Drops ELECTRON_RUN_AS_NODE. - extension/src/hooks-install.ts — `.cmd` wrapper template rewritten: invokes bundled Node directly with the bundled binary as argv. No env var set; cleaner cmd.exe semantics. - extension/src/extension.ts — activate() now caches the bundled-Node path via setBundledNode() after findAxmeBinary() succeeds, before MCP register / hook install run. Logs the path for diagnostics. Non-Windows is a no-op (cached value is undefined and never read). - extension/src/sidebar-webview.ts — projectName uses path.basename(), import added. - extension/package.json — version 0.1.0 → 0.1.1. VSIX size impact: - linux-x64, linux-arm64, darwin-x64, darwin-arm64: unchanged (~580 KB) - win32-x64: ~580 KB → ~30 MB (bundled node.exe) Verification plan (separate from CI): Real Windows machine without Node installed; install the artifact .vsix; verify (1) Output channel shows "MCP: registered 'axme' (command=...\node-windows-x64.exe, ...)", (2) sidebar renders project name correctly, (3) chat agent can call axme_context successfully, (4) self-test passes all 6 checks, (5) force-push is blocked by hook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 740998c commit ca322c3

8 files changed

Lines changed: 177 additions & 45 deletions

File tree

.github/workflows/publish-extension.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,35 @@ jobs:
7272
if: matrix.target != 'linux-arm64'
7373
run: npm test
7474

75+
- name: Download Node.exe for Windows bundling
76+
# The win32-x64 .vsix ships a real Node interpreter inside
77+
# extension/bin/node-windows-x64.exe. The extension invokes
78+
# bundled-node.exe + bundled-axme-code.exe (text/CJS payload)
79+
# directly — no shebang-shim trickery, no ELECTRON_RUN_AS_NODE
80+
# pass-through dependency, no system-Node-on-PATH requirement.
81+
#
82+
# Version + SHA pinned for reproducible builds. Bump explicitly
83+
# in this file when refreshing the bundled Node. SHA256 source:
84+
# curl -fsSL https://nodejs.org/dist/v20.20.2/SHASUMS256.txt
85+
# Earlier attempts (PR #136) used the user's own Node or
86+
# Cursor's bundled Electron via ELECTRON_RUN_AS_NODE — both
87+
# fragile and inconsistent on real Windows machines.
88+
if: matrix.target == 'win32-x64'
89+
shell: bash
90+
run: |
91+
set -euo pipefail
92+
NODE_VERSION="20.20.2"
93+
ZIP="node-v${NODE_VERSION}-win-x64.zip"
94+
curl -fsSL -o "$ZIP" "https://nodejs.org/dist/v${NODE_VERSION}/${ZIP}"
95+
echo "dc3700fdd57a63eedb8fd7e3c7baaa32e6a740a1b904167ff4204bc68ed8bf77 $ZIP" | sha256sum -c -
96+
# The Windows runner image already has 7z / unzip available.
97+
# `unzip -q` works on the runner's Git-Bash environment.
98+
unzip -q "$ZIP"
99+
mkdir -p extension/bin
100+
cp "node-v${NODE_VERSION}-win-x64/node.exe" extension/bin/node-windows-x64.exe
101+
ls -la extension/bin/node-windows-x64.exe
102+
rm -rf "$ZIP" "node-v${NODE_VERSION}-win-x64"
103+
75104
- name: Bundle core CLI to a single platform-specific file
76105
shell: bash
77106
run: |

extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "axme-code",
33
"displayName": "AXME Code",
44
"description": "Persistent memory, decisions, and safety guardrails for Cursor, GitHub Copilot, Cline, Continue, Roo Code, Windsurf, and VS Code chat agents",
5-
"version": "0.1.0",
5+
"version": "0.1.1",
66
"publisher": "AxmeAI",
77
"repository": {
88
"type": "git",

extension/src/binary-detect.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,35 @@ function bundledBinaryPath(context: vscode.ExtensionContext): string {
4646
return join(context.extensionPath, "bin", `axme-code${ext}`);
4747
}
4848

49+
/**
50+
* Locate the bundled Node.exe that ships inside the .vsix on Windows.
51+
*
52+
* Why we need this: extension/bin/axme-code.exe is a shebang-shim text
53+
* file (`#!/usr/bin/env node` + CJS payload). POSIX systems execute it
54+
* via the shebang. Windows ignores shebangs entirely — it can't execute
55+
* the file as a PE binary. Previous fixes tried using Cursor's own
56+
* Electron binary as a Node interpreter via ELECTRON_RUN_AS_NODE=1, but
57+
* Cursor's `registerServer({env})` API is undocumented and the env var
58+
* doesn't reliably reach the spawned process. The current strategy is
59+
* the simplest one that works: ship an actual Node.exe inside the .vsix
60+
* and invoke it directly.
61+
*
62+
* The CI matrix downloads node-v20.x.x-win-x64.zip during build, copies
63+
* node.exe into extension/bin/node-windows-x64.exe, and packages it
64+
* into the win32-x64 .vsix. This function returns its absolute path at
65+
* runtime so spawn-binary / mcp-register / hooks-install can use it.
66+
*
67+
* Returns undefined on non-Windows platforms (Linux/macOS execute the
68+
* shebang shim natively, no bundled Node needed there) and when the
69+
* file is missing (broken install — caller surfaces an actionable
70+
* error to the user).
71+
*/
72+
export function findBundledNode(context: vscode.ExtensionContext): string | undefined {
73+
if (process.platform !== "win32") return undefined;
74+
const p = join(context.extensionPath, "bin", "node-windows-x64.exe");
75+
return existsSync(p) ? p : undefined;
76+
}
77+
4978
function standardInstallLocations(): string[] {
5079
const home = homedir();
5180
const ext = process.platform === "win32" ? ".cmd" : "";

extension/src/extension.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@
2626

2727
import * as vscode from "vscode";
2828
import { detectIde, IdeKind } from "./ide-detect.js";
29-
import { findAxmeBinary } from "./binary-detect.js";
29+
import { findAxmeBinary, findBundledNode } from "./binary-detect.js";
3030
import { registerMcpServer } from "./mcp-register.js";
3131
import { installUserHooks } from "./hooks-install.js";
32+
import { setBundledNode } from "./spawn-binary.js";
3233
import { ensureAuditorAuth } from "./auditor-auth.js";
3334
import { isAxmeInitialized } from "./setup-controller.js";
3435
import { AxmeStatusBar } from "./status-bar.js";
@@ -116,6 +117,18 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
116117
return;
117118
}
118119

120+
// Cache the bundled Node.exe path (Windows only). Used by mcp-register,
121+
// hooks-install, and every spawn through spawnBinary(). On Linux/macOS
122+
// this resolves to undefined and the shebang shim is executed directly.
123+
// If we're on Windows and node-windows-x64.exe is missing from the
124+
// .vsix, downstream spawns throw a clear "reinstall the extension"
125+
// error rather than failing mysteriously with ENOENT.
126+
const bundledNode = findBundledNode(context);
127+
setBundledNode(bundledNode);
128+
if (process.platform === "win32") {
129+
log(` Bundled Node: ${bundledNode ?? "(missing — Windows spawns will fail)"}`);
130+
}
131+
119132
// ---- Step 3: MCP registration ------------------------------------------
120133
// We need the workspace folder BEFORE Step 6 — pass it to mcp-register so
121134
// the server's --workspace flag points at the real project, not Cursor's

extension/src/hooks-install.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { dirname, join } from "node:path";
2626
import { homedir } from "node:os";
2727
import { IdeKind } from "./ide-detect.js";
2828
import { log, logError } from "./log.js";
29+
import { getBundledNode } from "./spawn-binary.js";
2930

3031
type HookKind = "preToolUse" | "postToolUse" | "sessionEnd";
3132

@@ -71,29 +72,40 @@ function windowsHookWrapperPath(): string {
7172

7273
/**
7374
* 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).
75+
* our shebang-shim binary using the bundled Node.exe that ships inside
76+
* the .vsix. Returns the wrapper path (caller writes it into the hook
77+
* command string).
7678
*
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.
79+
* Previously this wrapper invoked Cursor.exe with ELECTRON_RUN_AS_NODE=1
80+
* to use Cursor's own Electron as a Node interpreter. That approach
81+
* worked in theory but proved fragile in practice — Cursor's spawn
82+
* behaviour around that env var is inconsistent, and any Cursor update
83+
* could change it. Now the wrapper points at the Node.exe we ship
84+
* ourselves (extension/bin/node-windows-x64.exe), which is a plain
85+
* Node interpreter that just works.
8386
*/
8487
function writeWindowsHookWrapper(binary: string): string {
8588
const path = windowsHookWrapperPath();
89+
const bundledNode = getBundledNode();
90+
if (!bundledNode) {
91+
throw new Error(
92+
"AXME Code: cannot install Cursor hooks — bundled Node.exe not " +
93+
"found at extension/bin/node-windows-x64.exe. The .vsix may be " +
94+
"incomplete; please reinstall the extension.",
95+
);
96+
}
8697
// cmd.exe parser quirks:
8798
// - `@echo off` silences the prompt echo
88-
// - `setlocal` scopes the env var to this script invocation
99+
// - `setlocal` scopes any env changes to this script invocation
89100
// - `%*` 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.
101+
// The bundled Node and binary paths are absolute, captured at install
102+
// time. If the extension is uninstalled and reinstalled to a
103+
// different location, the user runs setup again and we rewrite this
104+
// file with the new paths.
92105
const content =
93106
`@echo off\r\n` +
94107
`setlocal\r\n` +
95-
`set ELECTRON_RUN_AS_NODE=1\r\n` +
96-
`"${process.execPath}" "${binary}" %*\r\n`;
108+
`"${bundledNode}" "${binary}" %*\r\n`;
97109
mkdirSync(dirname(path), { recursive: true });
98110
writeFileSync(path, content, "utf-8");
99111
log(`Hooks: wrote Windows wrapper at ${path}`);

extension/src/mcp-register.ts

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import * as vscode from "vscode";
1717
import { log, logError } from "./log.js";
18+
import { getBundledNode } from "./spawn-binary.js";
1819

1920
interface CursorMcpApi {
2021
registerServer(config: {
@@ -59,25 +60,43 @@ export async function registerMcpServer(
5960
// `spawn(command, args)` directly and gets ENOENT, which surfaces in
6061
// the chat as "MCP server does not exist … No MCP servers available."
6162
//
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.
63+
// Earlier attempts:
64+
// 1. spawn("node", ...) — required user to have Node on PATH, which
65+
// most Windows chat-IDE users don't.
66+
// 2. spawn(process.execPath, ..., env: { ELECTRON_RUN_AS_NODE: "1" })
67+
// — relied on Cursor's MCP runner passing the env field through
68+
// to spawn(). Cursor's registerServer() API is undocumented and
69+
// the env pass-through is NOT reliable in practice. User-reported
70+
// MCP still failed to boot on Windows even with this fix.
7071
//
71-
// Documented: https://www.electronjs.org/docs/latest/api/environment-variables#electron_run_as_node
72+
// Current strategy: ship an actual Node.exe inside the .vsix and tell
73+
// Cursor to spawn THAT as the MCP server command, with the bundled JS
74+
// payload as argv[0]. This is a plain process spawn — no env tricks,
75+
// no Electron-as-Node, no system Node dependency. The bundled Node
76+
// path is resolved at activation time via findBundledNode() and
77+
// cached in spawn-binary.ts.
7278
const isWindows = process.platform === "win32";
73-
const command = isWindows ? process.execPath : binary;
74-
const args = isWindows ? [binary, ...serveArgs] : serveArgs;
75-
const env: Record<string, string> = isWindows ? { ELECTRON_RUN_AS_NODE: "1" } : {};
79+
let command: string;
80+
let args: string[];
81+
if (isWindows) {
82+
const bundledNode = getBundledNode();
83+
if (!bundledNode) {
84+
throw new Error(
85+
"AXME Code: bundled Node.exe not found at extension/bin/node-windows-x64.exe. " +
86+
"MCP server cannot start. This usually means the .vsix is incomplete — please reinstall.",
87+
);
88+
}
89+
command = bundledNode;
90+
args = [binary, ...serveArgs];
91+
} else {
92+
command = binary;
93+
args = serveArgs;
94+
}
7695
cursor.registerServer({
7796
name: "axme",
78-
server: { command, args, env },
97+
server: { command, args, env: {} },
7998
});
80-
log(`MCP: registered 'axme' (command=${command}, binary=${binary}, workspace=${workspaceRoot ?? "(none)"}, electron-as-node=${isWindows ? "yes" : "no"})`);
99+
log(`MCP: registered 'axme' (command=${command}, binary=${binary}, workspace=${workspaceRoot ?? "(none)"})`);
81100
// Cursor needs ~3s to process the registration before tools become
82101
// available to the chat agent. Verified empirically against the
83102
// browser-devtools-mcp reference implementation.

extension/src/sidebar-webview.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import * as vscode from "vscode";
1818
import { readFileSync } from "node:fs";
19-
import { join } from "node:path";
19+
import { basename, join } from "node:path";
2020
import { KbWatcher, KbCounts, readCounts } from "./kb-watcher.js";
2121
import { readBacklog, BacklogItemLite } from "./backlog-reader.js";
2222
import { readActiveSession, ActiveSession } from "./session-tracker.js";
@@ -323,9 +323,13 @@ export class AxmeSidebarProvider implements vscode.WebviewViewProvider {
323323
// it obvious WHICH repo the current numbers / setup state belong to.
324324
// VS Code reloads the window on folder switch, so this static bake
325325
// is safe — the webview's lifetime equals one workspace's lifetime.
326-
const projectName = this.workspaceRoot
327-
? this.workspaceRoot.split("/").filter(Boolean).pop() ?? ""
328-
: "";
326+
// path.basename() is platform-aware: returns "repo" on both
327+
// /home/me/repo and C:\Users\me\repo. The previous split("/") form
328+
// broke on Windows backslash paths — the entire workspaceRoot
329+
// rendered into the sidebar header as one long string, which in
330+
// turn corrupted the rest of the HTML layout (reported by
331+
// @geobelsky as "монитор пустой экран" on Windows v0.1.0).
332+
const projectName = this.workspaceRoot ? basename(this.workspaceRoot) : "";
329333
const titleHtml = projectName
330334
? `AXME · ${escapeHtmlServer(projectName)}`
331335
: `AXME Code`;

extension/src/spawn-binary.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@
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: 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.
10+
* The Windows strategy: ship an actual Node.exe inside the .vsix
11+
* (extension/bin/node-windows-x64.exe, copied from the official
12+
* node-v20.x.x-win-x64.zip during CI). Invoke that bundled Node
13+
* directly with the bundled JS payload as argv[0]. No system Node
14+
* dependency, no shebang gymnastics, no ELECTRON_RUN_AS_NODE
15+
* pass-through quirks — works regardless of whether the user has Node
16+
* installed.
1517
*
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
18+
* The bundled-node path is set once on extension activation via
19+
* `setBundledNode()` (called from extension.ts after findBundledNode()
20+
* resolves it from context.extensionPath). `spawnBinary` doesn't
21+
* receive vscode context, so caching it module-level is the cleanest
22+
* way to give every spawn site access without threading context
23+
* through every caller.
1924
*
2025
* Every spawn of the bundled binary in the extension should go through
2126
* this helper. A direct `spawn(binary, args)` will work on Linux + macOS
@@ -24,6 +29,22 @@
2429

2530
import { spawn, ChildProcess, ChildProcessWithoutNullStreams, SpawnOptions } from "node:child_process";
2631

32+
let _bundledNode: string | undefined;
33+
34+
/**
35+
* Cache the bundled Node.exe path discovered by findBundledNode() at
36+
* activation time. Called once from extension.ts. No-op on non-Windows
37+
* platforms where bundled Node isn't shipped or needed.
38+
*/
39+
export function setBundledNode(path: string | undefined): void {
40+
_bundledNode = path;
41+
}
42+
43+
/** For diagnostics / tests. */
44+
export function getBundledNode(): string | undefined {
45+
return _bundledNode;
46+
}
47+
2748
/**
2849
* Cross-platform spawn of the bundled binary. Two overloads mirror
2950
* Node's own `spawn` typing so callers keep the non-null stdio
@@ -45,10 +66,15 @@ export function spawnBinary(
4566
): ChildProcess {
4667
const opts = options ?? {};
4768
if (process.platform === "win32") {
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-
});
69+
if (!_bundledNode) {
70+
throw new Error(
71+
"AXME Code: bundled Node.exe not found. " +
72+
"This usually means extension/bin/node-windows-x64.exe is missing " +
73+
"from the .vsix you installed. Try reinstalling the extension; " +
74+
"if the problem persists open an issue at https://github.com/AxmeAI/axme-code/issues.",
75+
);
76+
}
77+
return spawn(_bundledNode, [binary, ...args], opts);
5278
}
5379
return spawn(binary, args, opts);
5480
}

0 commit comments

Comments
 (0)