Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion extension/src/activation-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +30,7 @@ interface Step {

const LABELS: Record<StepKind, string> = {
binary: "Binary",
node: "Node",
mcp: "MCP",
hooks: "Hooks",
auth: "Auth",
Expand Down
56 changes: 56 additions & 0 deletions extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,6 +78,40 @@ async function runStep<T>(
}
}

/**
* 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<string | null> {
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<void> {
log(`AXME Code v${__EXTENSION_VERSION__} activating…`);

Expand Down Expand Up @@ -132,6 +167,27 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
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
Expand Down
48 changes: 35 additions & 13 deletions extension/src/hooks-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<ext-dir>/bin/axme-code" hook <name> --ide cursor
* - Windows: "%USERPROFILE%\.cursor\axme-hook.cmd" hook <name> --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
Expand All @@ -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 {
Expand All @@ -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.",
);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion extension/src/mcp-register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
}
Expand Down
8 changes: 7 additions & 1 deletion extension/src/setup-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,13 @@ export async function runSetup(binary: string, ide: IdeKind): Promise<void> {
const exitCode = await new Promise<number>((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()}`));
Expand Down
2 changes: 1 addition & 1 deletion extension/src/spawn-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
Expand Down
Loading