diff --git a/extension/src/activation-report.ts b/extension/src/activation-report.ts index bdd1682..dfef82f 100644 --- a/extension/src/activation-report.ts +++ b/extension/src/activation-report.ts @@ -20,7 +20,7 @@ import * as vscode from "vscode"; import { show as showOutput } from "./log.js"; -export type StepKind = "mcp" | "hooks" | "auth" | "setup" | "binary"; +export type StepKind = "mcp" | "hooks" | "auth" | "setup" | "binary" | "node"; interface Step { kind: StepKind; @@ -30,6 +30,7 @@ interface Step { const LABELS: Record = { binary: "Binary", + node: "Node", mcp: "MCP", hooks: "Hooks", auth: "Auth", diff --git a/extension/src/extension.ts b/extension/src/extension.ts index 9276269..2fb4f88 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -26,6 +26,7 @@ import * as vscode from "vscode"; import { basename } from "node:path"; +import { execFile } from "node:child_process"; import { detectIde, IdeKind } from "./ide-detect.js"; import { findAxmeBinary, findBundledNode } from "./binary-detect.js"; import { registerMcpServer } from "./mcp-register.js"; @@ -77,6 +78,40 @@ async function runStep( } } +/** + * POSIX-only preflight: the bundled CLI is a `#!/usr/bin/env node` shim and + * the MCP server / hooks are spawned by Cursor OUTSIDE the extension host — + * both need a system Node 20+ on PATH (Windows ships its own node.exe in + * the .vsix; macOS/Linux do not). Without this check, a Node-less user gets + * the completely undebuggable "MCP server does not exist" in chat plus + * silently failing hooks. Returns null when OK, otherwise an actionable + * problem description. Never throws; a broken `node -v` IS the finding. + */ +function checkSystemNode(): Promise { + if (process.platform === "win32") return Promise.resolve(null); + return new Promise((resolveCheck) => { + execFile("node", ["-v"], { timeout: 3000 }, (err, stdout) => { + if (err) { + resolveCheck( + "Node.js 20+ was not found on PATH. The AXME MCP server and safety hooks " + + "cannot start without it. Install Node (https://nodejs.org, or `brew install node` / " + + "your package manager), then reload the window.", + ); + return; + } + const major = parseInt(String(stdout).trim().replace(/^v/, "").split(".")[0] ?? "", 10); + if (Number.isFinite(major) && major < 20) { + resolveCheck( + `Node.js ${String(stdout).trim()} found, but axme-code targets Node 20+. ` + + "The MCP server may fail to start — please upgrade Node and reload the window.", + ); + return; + } + resolveCheck(null); + }); + }); +} + export async function activate(context: vscode.ExtensionContext): Promise { log(`AXME Code v${__EXTENSION_VERSION__} activating…`); @@ -132,6 +167,27 @@ export async function activate(context: vscode.ExtensionContext): Promise log(` Bundled Node: ${bundledNode ?? "(missing — Windows spawns will fail)"}`); } + // ---- Step 2b: system Node preflight (POSIX only) ------------------------- + // Soft-fail: we record + surface the problem but continue activation — + // the extension host's PATH is a close proxy for the env Cursor uses to + // spawn the MCP server, not a perfect one, and a false negative must not + // brick an otherwise-working install. When Node IS genuinely absent the + // user now gets an actionable message instead of a dead "MCP server does + // not exist" chat error with no explanation. + const nodeProblem = await checkSystemNode(); + if (nodeProblem) { + report.record("node", false, nodeProblem.slice(0, 80)); + logError("System Node preflight", new Error(nodeProblem)); + void vscode.window + .showErrorMessage(`AXME Code: ${nodeProblem}`, "Open nodejs.org", "Show output") + .then((c) => { + if (c === "Open nodejs.org") void vscode.env.openExternal(vscode.Uri.parse("https://nodejs.org")); + if (c === "Show output") showOutput(); + }); + } else { + report.record("node", true, process.platform === "win32" ? "bundled" : "system"); + } + // ---- Step 3: MCP registration ------------------------------------------ // We need the workspace folder BEFORE Step 6 — pass it to mcp-register so // the server's --workspace flag points at the real project, not Cursor's diff --git a/extension/src/hooks-install.ts b/extension/src/hooks-install.ts index 67e4a85..e11be38 100644 --- a/extension/src/hooks-install.ts +++ b/extension/src/hooks-install.ts @@ -59,6 +59,22 @@ function quote(s: string): string { return `"${s.replace(/"/g, '\\"')}"`; } +/** + * True when a hooks.json entry was written by us. Matches BOTH command + * shapes we have ever emitted: + * - POSIX: "/bin/axme-code" hook --ide cursor + * - Windows: "%USERPROFILE%\.cursor\axme-hook.cmd" hook --ide cursor + * The old filter matched only "axme-code" — the Windows wrapper path does + * not contain that substring, so every activation APPENDED three fresh + * entries instead of replacing them (N restarts → N× hook fan-out), and + * uninstall could never remove them (after uninstall they pointed at a + * deleted axme-hook.cmd, failing forever). + */ +function isAxmeHookEntry(command: unknown): boolean { + const c = String(command ?? ""); + return c.includes("axme-code") || c.includes("axme-hook.cmd"); +} + /** * Path to the Windows wrapper script. Lives next to hooks.json so a * single uninstall sweep deletes both. The wrapper is a one-liner .cmd @@ -81,7 +97,7 @@ function windowsHookWrapperPath(): string { * worked in theory but proved fragile in practice — Cursor's spawn * behaviour around that env var is inconsistent, and any Cursor update * could change it. Now the wrapper points at the Node.exe we ship - * ourselves (extension/bin/node-windows-x64.exe), which is a plain + * ourselves (extension/bin/node-runtime/node.exe), which is a plain * Node interpreter that just works. */ function writeWindowsHookWrapper(binary: string): string { @@ -90,7 +106,7 @@ function writeWindowsHookWrapper(binary: string): string { if (!bundledNode) { throw new Error( "AXME Code: cannot install Cursor hooks — bundled Node.exe not " + - "found at extension/bin/node-windows-x64.exe. The .vsix may be " + + "found at extension/bin/node-runtime/node.exe. The .vsix may be " + "incomplete; please reinstall the extension.", ); } @@ -151,13 +167,21 @@ export function installUserHooks(ide: IdeKind, binary: string): boolean { const path = userCursorHooksPath(); let cfg: CursorHooksFile = { version: 1, hooks: {} }; if (existsSync(path)) { - try { - const raw = readFileSync(path, "utf-8"); - const parsed = JSON.parse(raw); - if (parsed && typeof parsed === "object") cfg = parsed as CursorHooksFile; - } catch (err) { - logError(`Hooks: existing ${path} is malformed; will overwrite`, err); - cfg = { version: 1, hooks: {} }; + const raw = readFileSync(path, "utf-8"); + if (raw.trim()) { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") cfg = parsed as CursorHooksFile; + } catch (err) { + // Refuse-don't-clobber: this file can contain the user's OWN hooks. + // Overwriting on a parse error (the old behavior) silently destroyed + // them. Throw instead — runStep() surfaces the message as a visible + // warning with recovery instructions. + throw new Error( + `existing ${path} is not valid JSON (${(err as Error).message}). ` + + `Refusing to overwrite it — fix or remove the file, then reload the window to install AXME hooks.`, + ); + } } } if (!cfg.version) cfg.version = 1; @@ -178,9 +202,7 @@ export function installUserHooks(ide: IdeKind, binary: string): boolean { for (const kind of ["preToolUse", "postToolUse", "sessionEnd"] as HookKind[]) { const existing = cfg.hooks[kind] ?? []; - const preserved = existing.filter( - (e) => !String(e.command ?? "").includes("axme-code"), - ); + const preserved = existing.filter((e) => !isAxmeHookEntry(e.command)); const fresh: CursorHookEntry = { command: buildHookCommand(binary, cliNames[kind], wrapper), type: "command", @@ -209,7 +231,7 @@ export function uninstallUserHooks(): void { for (const kind of ["preToolUse", "postToolUse", "sessionEnd"] as HookKind[]) { const arr = cfg.hooks[kind]; if (!arr) continue; - const preserved = arr.filter((e) => !String(e.command ?? "").includes("axme-code")); + const preserved = arr.filter((e) => !isAxmeHookEntry(e.command)); if (preserved.length !== arr.length) { cfg.hooks[kind] = preserved; touched = true; diff --git a/extension/src/mcp-register.ts b/extension/src/mcp-register.ts index 56b38d5..b222416 100644 --- a/extension/src/mcp-register.ts +++ b/extension/src/mcp-register.ts @@ -82,7 +82,7 @@ export async function registerMcpServer( const bundledNode = getBundledNode(); if (!bundledNode) { throw new Error( - "AXME Code: bundled Node.exe not found at extension/bin/node-windows-x64.exe. " + + "AXME Code: bundled Node.exe not found at extension/bin/node-runtime/node.exe. " + "MCP server cannot start. This usually means the .vsix is incomplete — please reinstall.", ); } diff --git a/extension/src/setup-controller.ts b/extension/src/setup-controller.ts index 42a3541..f753136 100644 --- a/extension/src/setup-controller.ts +++ b/extension/src/setup-controller.ts @@ -111,7 +111,13 @@ export async function runSetup(binary: string, ide: IdeKind): Promise { const exitCode = await new Promise((resolve) => { const child = spawnBinary(binary, args, { cwd: root, - env: { ...process.env, AXME_TELEMETRY_DISABLED: "1" }, + // AXME_SETUP_FROM_EXTENSION tells the CLI's cursor-writers to skip + // project-level .cursor/{mcp,hooks}.json: the extension registers + // the MCP server via the cursor API on every activation and owns + // user-level hooks. The project files setup used to write embedded + // this version-numbered extension dir's absolute paths — stale + // after every extension update — and double-fired hooks. + env: { ...process.env, AXME_TELEMETRY_DISABLED: "1", AXME_SETUP_FROM_EXTENSION: "1" }, }); child.stdout!.on("data", (chunk) => log(`setup stdout: ${String(chunk).trimEnd()}`)); child.stderr!.on("data", (chunk) => log(`setup stderr: ${String(chunk).trimEnd()}`)); diff --git a/extension/src/spawn-binary.ts b/extension/src/spawn-binary.ts index 5dc51c0..c8d10f2 100644 --- a/extension/src/spawn-binary.ts +++ b/extension/src/spawn-binary.ts @@ -69,7 +69,7 @@ export function spawnBinary( if (!_bundledNode) { throw new Error( "AXME Code: bundled Node.exe not found. " + - "This usually means extension/bin/node-windows-x64.exe is missing " + + "This usually means extension/bin/node-runtime/node.exe is missing " + "from the .vsix you installed. Try reinstalling the extension; " + "if the problem persists open an issue at https://github.com/AxmeAI/axme-code/issues.", );