Skip to content

Commit 504dc75

Browse files
authored
Merge pull request #150 from AxmeAI/fix/extension-launch-readiness-20260611
fix(extension): Windows hook dedupe, POSIX node preflight, stale-path prevention
2 parents 759b1c1 + 9349938 commit 504dc75

6 files changed

Lines changed: 102 additions & 17 deletions

File tree

extension/src/activation-report.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import * as vscode from "vscode";
2121
import { show as showOutput } from "./log.js";
2222

23-
export type StepKind = "mcp" | "hooks" | "auth" | "setup" | "binary";
23+
export type StepKind = "mcp" | "hooks" | "auth" | "setup" | "binary" | "node";
2424

2525
interface Step {
2626
kind: StepKind;
@@ -30,6 +30,7 @@ interface Step {
3030

3131
const LABELS: Record<StepKind, string> = {
3232
binary: "Binary",
33+
node: "Node",
3334
mcp: "MCP",
3435
hooks: "Hooks",
3536
auth: "Auth",

extension/src/extension.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import * as vscode from "vscode";
2828
import { basename } from "node:path";
29+
import { execFile } from "node:child_process";
2930
import { detectIde, IdeKind } from "./ide-detect.js";
3031
import { findAxmeBinary, findBundledNode } from "./binary-detect.js";
3132
import { registerMcpServer } from "./mcp-register.js";
@@ -77,6 +78,40 @@ async function runStep<T>(
7778
}
7879
}
7980

81+
/**
82+
* POSIX-only preflight: the bundled CLI is a `#!/usr/bin/env node` shim and
83+
* the MCP server / hooks are spawned by Cursor OUTSIDE the extension host —
84+
* both need a system Node 20+ on PATH (Windows ships its own node.exe in
85+
* the .vsix; macOS/Linux do not). Without this check, a Node-less user gets
86+
* the completely undebuggable "MCP server does not exist" in chat plus
87+
* silently failing hooks. Returns null when OK, otherwise an actionable
88+
* problem description. Never throws; a broken `node -v` IS the finding.
89+
*/
90+
function checkSystemNode(): Promise<string | null> {
91+
if (process.platform === "win32") return Promise.resolve(null);
92+
return new Promise((resolveCheck) => {
93+
execFile("node", ["-v"], { timeout: 3000 }, (err, stdout) => {
94+
if (err) {
95+
resolveCheck(
96+
"Node.js 20+ was not found on PATH. The AXME MCP server and safety hooks " +
97+
"cannot start without it. Install Node (https://nodejs.org, or `brew install node` / " +
98+
"your package manager), then reload the window.",
99+
);
100+
return;
101+
}
102+
const major = parseInt(String(stdout).trim().replace(/^v/, "").split(".")[0] ?? "", 10);
103+
if (Number.isFinite(major) && major < 20) {
104+
resolveCheck(
105+
`Node.js ${String(stdout).trim()} found, but axme-code targets Node 20+. ` +
106+
"The MCP server may fail to start — please upgrade Node and reload the window.",
107+
);
108+
return;
109+
}
110+
resolveCheck(null);
111+
});
112+
});
113+
}
114+
80115
export async function activate(context: vscode.ExtensionContext): Promise<void> {
81116
log(`AXME Code v${__EXTENSION_VERSION__} activating…`);
82117

@@ -132,6 +167,27 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
132167
log(` Bundled Node: ${bundledNode ?? "(missing — Windows spawns will fail)"}`);
133168
}
134169

170+
// ---- Step 2b: system Node preflight (POSIX only) -------------------------
171+
// Soft-fail: we record + surface the problem but continue activation —
172+
// the extension host's PATH is a close proxy for the env Cursor uses to
173+
// spawn the MCP server, not a perfect one, and a false negative must not
174+
// brick an otherwise-working install. When Node IS genuinely absent the
175+
// user now gets an actionable message instead of a dead "MCP server does
176+
// not exist" chat error with no explanation.
177+
const nodeProblem = await checkSystemNode();
178+
if (nodeProblem) {
179+
report.record("node", false, nodeProblem.slice(0, 80));
180+
logError("System Node preflight", new Error(nodeProblem));
181+
void vscode.window
182+
.showErrorMessage(`AXME Code: ${nodeProblem}`, "Open nodejs.org", "Show output")
183+
.then((c) => {
184+
if (c === "Open nodejs.org") void vscode.env.openExternal(vscode.Uri.parse("https://nodejs.org"));
185+
if (c === "Show output") showOutput();
186+
});
187+
} else {
188+
report.record("node", true, process.platform === "win32" ? "bundled" : "system");
189+
}
190+
135191
// ---- Step 3: MCP registration ------------------------------------------
136192
// We need the workspace folder BEFORE Step 6 — pass it to mcp-register so
137193
// the server's --workspace flag points at the real project, not Cursor's

extension/src/hooks-install.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,22 @@ function quote(s: string): string {
5959
return `"${s.replace(/"/g, '\\"')}"`;
6060
}
6161

62+
/**
63+
* True when a hooks.json entry was written by us. Matches BOTH command
64+
* shapes we have ever emitted:
65+
* - POSIX: "<ext-dir>/bin/axme-code" hook <name> --ide cursor
66+
* - Windows: "%USERPROFILE%\.cursor\axme-hook.cmd" hook <name> --ide cursor
67+
* The old filter matched only "axme-code" — the Windows wrapper path does
68+
* not contain that substring, so every activation APPENDED three fresh
69+
* entries instead of replacing them (N restarts → N× hook fan-out), and
70+
* uninstall could never remove them (after uninstall they pointed at a
71+
* deleted axme-hook.cmd, failing forever).
72+
*/
73+
function isAxmeHookEntry(command: unknown): boolean {
74+
const c = String(command ?? "");
75+
return c.includes("axme-code") || c.includes("axme-hook.cmd");
76+
}
77+
6278
/**
6379
* Path to the Windows wrapper script. Lives next to hooks.json so a
6480
* single uninstall sweep deletes both. The wrapper is a one-liner .cmd
@@ -81,7 +97,7 @@ function windowsHookWrapperPath(): string {
8197
* worked in theory but proved fragile in practice — Cursor's spawn
8298
* behaviour around that env var is inconsistent, and any Cursor update
8399
* 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
100+
* ourselves (extension/bin/node-runtime/node.exe), which is a plain
85101
* Node interpreter that just works.
86102
*/
87103
function writeWindowsHookWrapper(binary: string): string {
@@ -90,7 +106,7 @@ function writeWindowsHookWrapper(binary: string): string {
90106
if (!bundledNode) {
91107
throw new Error(
92108
"AXME Code: cannot install Cursor hooks — bundled Node.exe not " +
93-
"found at extension/bin/node-windows-x64.exe. The .vsix may be " +
109+
"found at extension/bin/node-runtime/node.exe. The .vsix may be " +
94110
"incomplete; please reinstall the extension.",
95111
);
96112
}
@@ -151,13 +167,21 @@ export function installUserHooks(ide: IdeKind, binary: string): boolean {
151167
const path = userCursorHooksPath();
152168
let cfg: CursorHooksFile = { version: 1, hooks: {} };
153169
if (existsSync(path)) {
154-
try {
155-
const raw = readFileSync(path, "utf-8");
156-
const parsed = JSON.parse(raw);
157-
if (parsed && typeof parsed === "object") cfg = parsed as CursorHooksFile;
158-
} catch (err) {
159-
logError(`Hooks: existing ${path} is malformed; will overwrite`, err);
160-
cfg = { version: 1, hooks: {} };
170+
const raw = readFileSync(path, "utf-8");
171+
if (raw.trim()) {
172+
try {
173+
const parsed = JSON.parse(raw);
174+
if (parsed && typeof parsed === "object") cfg = parsed as CursorHooksFile;
175+
} catch (err) {
176+
// Refuse-don't-clobber: this file can contain the user's OWN hooks.
177+
// Overwriting on a parse error (the old behavior) silently destroyed
178+
// them. Throw instead — runStep() surfaces the message as a visible
179+
// warning with recovery instructions.
180+
throw new Error(
181+
`existing ${path} is not valid JSON (${(err as Error).message}). ` +
182+
`Refusing to overwrite it — fix or remove the file, then reload the window to install AXME hooks.`,
183+
);
184+
}
161185
}
162186
}
163187
if (!cfg.version) cfg.version = 1;
@@ -178,9 +202,7 @@ export function installUserHooks(ide: IdeKind, binary: string): boolean {
178202

179203
for (const kind of ["preToolUse", "postToolUse", "sessionEnd"] as HookKind[]) {
180204
const existing = cfg.hooks[kind] ?? [];
181-
const preserved = existing.filter(
182-
(e) => !String(e.command ?? "").includes("axme-code"),
183-
);
205+
const preserved = existing.filter((e) => !isAxmeHookEntry(e.command));
184206
const fresh: CursorHookEntry = {
185207
command: buildHookCommand(binary, cliNames[kind], wrapper),
186208
type: "command",
@@ -209,7 +231,7 @@ export function uninstallUserHooks(): void {
209231
for (const kind of ["preToolUse", "postToolUse", "sessionEnd"] as HookKind[]) {
210232
const arr = cfg.hooks[kind];
211233
if (!arr) continue;
212-
const preserved = arr.filter((e) => !String(e.command ?? "").includes("axme-code"));
234+
const preserved = arr.filter((e) => !isAxmeHookEntry(e.command));
213235
if (preserved.length !== arr.length) {
214236
cfg.hooks[kind] = preserved;
215237
touched = true;

extension/src/mcp-register.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export async function registerMcpServer(
8282
const bundledNode = getBundledNode();
8383
if (!bundledNode) {
8484
throw new Error(
85-
"AXME Code: bundled Node.exe not found at extension/bin/node-windows-x64.exe. " +
85+
"AXME Code: bundled Node.exe not found at extension/bin/node-runtime/node.exe. " +
8686
"MCP server cannot start. This usually means the .vsix is incomplete — please reinstall.",
8787
);
8888
}

extension/src/setup-controller.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,13 @@ export async function runSetup(binary: string, ide: IdeKind): Promise<void> {
111111
const exitCode = await new Promise<number>((resolve) => {
112112
const child = spawnBinary(binary, args, {
113113
cwd: root,
114-
env: { ...process.env, AXME_TELEMETRY_DISABLED: "1" },
114+
// AXME_SETUP_FROM_EXTENSION tells the CLI's cursor-writers to skip
115+
// project-level .cursor/{mcp,hooks}.json: the extension registers
116+
// the MCP server via the cursor API on every activation and owns
117+
// user-level hooks. The project files setup used to write embedded
118+
// this version-numbered extension dir's absolute paths — stale
119+
// after every extension update — and double-fired hooks.
120+
env: { ...process.env, AXME_TELEMETRY_DISABLED: "1", AXME_SETUP_FROM_EXTENSION: "1" },
115121
});
116122
child.stdout!.on("data", (chunk) => log(`setup stdout: ${String(chunk).trimEnd()}`));
117123
child.stderr!.on("data", (chunk) => log(`setup stderr: ${String(chunk).trimEnd()}`));

extension/src/spawn-binary.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function spawnBinary(
6969
if (!_bundledNode) {
7070
throw new Error(
7171
"AXME Code: bundled Node.exe not found. " +
72-
"This usually means extension/bin/node-windows-x64.exe is missing " +
72+
"This usually means extension/bin/node-runtime/node.exe is missing " +
7373
"from the .vsix you installed. Try reinstalling the extension; " +
7474
"if the problem persists open an issue at https://github.com/AxmeAI/axme-code/issues.",
7575
);

0 commit comments

Comments
 (0)