FN-7418: fix Cursor CLI Windows shim spawning
Fix Cursor runtime probes to execute Windows cmd and bat shims reliably while preserving diagnostics. - Run shared Cursor CLI probe and discovery spawns through the shell only on Windows. - Surface bounded spawn error details in unavailable Cursor probe reasons. - Cover Windows shell spawning, failure diagnostics, probe, and process-manager behavior with tests. - Document the Windows PATH shim invocation contract and add a patch changeset. Files changed: .changeset/fn-7418-cursor-cli-windows-cmd.md | 7 ++ docs/cursor-cli-contract.md | 17 ++++ .../src/__tests__/cli-spawn.test.ts | 103 +++++++++++++++++++++ .../src/__tests__/probe.test.ts | 44 ++++++++- .../src/__tests__/process-manager.test.ts | 24 ++++- .../fusion-plugin-cursor-runtime/src/cli-spawn.ts | 41 ++++++-- plugins/fusion-plugin-cursor-runtime/src/probe.ts | 14 ++- 7 files changed, 236 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-7418 Fusion-Task-Lineage: d689bea7-4676-4190-a60a-f85efab90aba Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { runCursorCommand } from "../cli-spawn.js";
|
||||
|
||||
function mockPlatform(platform: NodeJS.Platform) {
|
||||
return vi.spyOn(process, "platform", "get").mockReturnValue(platform);
|
||||
}
|
||||
|
||||
function createMockChild() {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.kill = vi.fn();
|
||||
vi.mocked(spawn).mockReturnValue(child as never);
|
||||
return child;
|
||||
}
|
||||
|
||||
describe("runCursorCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses the Windows shell so PATH .cmd and .bat Cursor shims can run", async () => {
|
||||
mockPlatform("win32");
|
||||
const child = createMockChild();
|
||||
|
||||
const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith("cursor-agent", ["--version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: true,
|
||||
});
|
||||
|
||||
child.stdout.write("cursor-agent 1.2.3\n");
|
||||
child.stderr.write("diagnostic\n");
|
||||
child.emit("close", 0);
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({
|
||||
code: 0,
|
||||
stdout: "cursor-agent 1.2.3\n",
|
||||
stderr: "diagnostic\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-Windows Cursor invocations on direct spawn", async () => {
|
||||
mockPlatform("darwin");
|
||||
const child = createMockChild();
|
||||
|
||||
const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000);
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith("cursor-agent", ["--version"], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
});
|
||||
|
||||
child.emit("close", 0);
|
||||
await expect(resultPromise).resolves.toMatchObject({ code: 0 });
|
||||
});
|
||||
|
||||
it("returns spawn errors with diagnostics instead of empty stderr", async () => {
|
||||
mockPlatform("win32");
|
||||
const child = createMockChild();
|
||||
|
||||
const resultPromise = runCursorCommand("cursor-agent", ["--version"], 1000);
|
||||
child.emit("error", Object.assign(new Error("spawn cursor-agent ENOENT"), { code: "ENOENT" }));
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.code).toBe(127);
|
||||
expect(result.stderr).toContain("spawn error: ENOENT: spawn cursor-agent ENOENT");
|
||||
});
|
||||
|
||||
it("kills timed-out Cursor commands best-effort and resolves once", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockPlatform("linux");
|
||||
const child = createMockChild();
|
||||
|
||||
const resultPromise = runCursorCommand("cursor-agent", ["models", "--json"], 25);
|
||||
child.stdout.write("partial");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await expect(resultPromise).resolves.toEqual({ code: 124, stdout: "partial", stderr: "" });
|
||||
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
|
||||
child.emit("close", 0);
|
||||
await expect(resultPromise).resolves.toMatchObject({ code: 124 });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
|
||||
|
||||
@@ -6,6 +6,10 @@ import { runCursorCommand } from "../cli-spawn.js";
|
||||
import { probeCursorBinary } from "../probe.js";
|
||||
|
||||
describe("probeCursorBinary", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("reports available when probe succeeds", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValue({ code: 0, stdout: "1.2.3", stderr: "" });
|
||||
const result = await probeCursorBinary({ binaryPath: "cursor-agent" });
|
||||
@@ -29,13 +33,45 @@ describe("probeCursorBinary", () => {
|
||||
expect(result.reason).toContain("installation not found");
|
||||
});
|
||||
|
||||
it("reports binary unavailable when all candidates fail", async () => {
|
||||
it("probes cursor-agent before cursor and reports the first Windows shim success", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: "cursor-agent 0.50.0\n", stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary();
|
||||
|
||||
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenCalledTimes(1);
|
||||
expect(result).toMatchObject({
|
||||
available: true,
|
||||
authenticated: true,
|
||||
binaryName: "cursor-agent",
|
||||
binaryPath: "cursor-agent",
|
||||
version: "cursor-agent 0.50.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to cursor when cursor-agent fails but cursor succeeds", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "" });
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor 0.50.0\n", stderr: "" });
|
||||
|
||||
const result = await probeCursorBinary();
|
||||
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["--version"], 3000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor", ["--version"], 3000);
|
||||
expect(result.available).toBe(true);
|
||||
expect(result.binaryName).toBe("cursor");
|
||||
expect(result.version).toBe("cursor 0.50.0");
|
||||
});
|
||||
|
||||
it("reports binary unavailable with actionable diagnostics when all candidates fail", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor-agent.cmd" })
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT: cursor.cmd" });
|
||||
|
||||
const result = await probeCursorBinary();
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.reason).toContain("not found");
|
||||
expect(result.reason).toContain("cursor-agent: spawn error: ENOENT");
|
||||
expect(result.reason).toContain("cursor: spawn error: ENOENT");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../cli-spawn.js", () => ({ runCursorCommand: vi.fn() }));
|
||||
|
||||
@@ -6,9 +6,14 @@ import { runCursorCommand } from "../cli-spawn.js";
|
||||
import { discoverCursorModels } from "../process-manager.js";
|
||||
|
||||
describe("discoverCursorModels", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses json list when available", async () => {
|
||||
vi.mocked(runCursorCommand).mockResolvedValueOnce({ code: 0, stdout: '[{"id":"cursor/a"},{"id":"cursor/b"}]', stderr: "" });
|
||||
const result = await discoverCursorModels("cursor-agent");
|
||||
expect(runCursorCommand).toHaveBeenCalledWith("cursor-agent", ["models", "--json"], 5000);
|
||||
expect(result.models).toEqual(["cursor/a", "cursor/b"]);
|
||||
expect(result.fallbackUsed).toBe(false);
|
||||
});
|
||||
@@ -19,7 +24,24 @@ describe("discoverCursorModels", () => {
|
||||
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "" })
|
||||
.mockResolvedValueOnce({ code: 0, stdout: "cursor/x\ncursor/y", stderr: "" });
|
||||
const result = await discoverCursorModels("cursor-agent");
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["models", "--json"], 5000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor-agent", ["model", "list", "--json"], 5000);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor-agent", ["models"], 5000);
|
||||
expect(result.models).toEqual(["cursor/x", "cursor/y"]);
|
||||
expect(result.fallbackUsed).toBe(true);
|
||||
});
|
||||
|
||||
it("returns empty discovery when every command fails", async () => {
|
||||
vi.mocked(runCursorCommand)
|
||||
.mockResolvedValueOnce({ code: 127, stdout: "", stderr: "spawn error: ENOENT" })
|
||||
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "unknown command" })
|
||||
.mockResolvedValueOnce({ code: 1, stdout: "", stderr: "unknown command" });
|
||||
|
||||
const result = await discoverCursorModels("cursor-agent", 2500);
|
||||
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(1, "cursor-agent", ["models", "--json"], 2500);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(2, "cursor-agent", ["model", "list", "--json"], 2500);
|
||||
expect(runCursorCommand).toHaveBeenNthCalledWith(3, "cursor-agent", ["models"], 2500);
|
||||
expect(result).toEqual({ models: [], source: "none", fallbackUsed: true, reason: "model discovery command unavailable" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,50 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
function formatSpawnError(error: Error & { code?: unknown }): string {
|
||||
const code = typeof error.code === "string" ? `${error.code}: ` : "";
|
||||
return `spawn error: ${code}${error.message}`.trim();
|
||||
}
|
||||
|
||||
export async function runCursorCommand(binary: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timer = setTimeout(() => {
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const finish = (result: { code: number | null; stdout: string; stderr: string }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
/*
|
||||
FNXC:CursorCli 2026-07-02-00:00:
|
||||
Windows Cursor installers and npm-style shims can expose `cursor-agent.cmd` or `cursor.cmd` on PATH, and Node cannot direct-spawn those batch wrappers without the command shell.
|
||||
Keep Unix/macOS on direct spawn so only the known Cursor CLI probe/discovery seam uses shell resolution where Windows requires it.
|
||||
*/
|
||||
const child = spawn(binary, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
try { child.kill("SIGKILL"); } catch {
|
||||
// best effort
|
||||
}
|
||||
resolve({ code: 124, stdout, stderr });
|
||||
finish({ code: 124, stdout, stderr });
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
|
||||
child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); });
|
||||
child.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: 127, stdout, stderr });
|
||||
child.once("error", (error: Error & { code?: unknown }) => {
|
||||
const diagnostic = formatSpawnError(error);
|
||||
stderr = stderr ? `${stderr}\n${diagnostic}` : diagnostic;
|
||||
finish({ code: 127, stdout, stderr });
|
||||
});
|
||||
child.once("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
finish({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,14 +2,25 @@ import { runCursorCommand } from "./cli-spawn.js";
|
||||
import type { CursorBinaryStatus } from "./types.js";
|
||||
|
||||
const CANDIDATES = ["cursor-agent", "cursor"] as const;
|
||||
const MAX_FAILURE_DETAIL_LENGTH = 180;
|
||||
|
||||
function summarizeFailure(binary: string, stdout: string, stderr: string): string | undefined {
|
||||
const detail = `${stderr || stdout}`.replace(/\s+/g, " ").trim();
|
||||
if (!detail) return undefined;
|
||||
const truncated = detail.length > MAX_FAILURE_DETAIL_LENGTH ? `${detail.slice(0, MAX_FAILURE_DETAIL_LENGTH - 1)}…` : detail;
|
||||
return `${binary}: ${truncated}`;
|
||||
}
|
||||
|
||||
export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPath?: string }): Promise<CursorBinaryStatus> {
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = options?.timeoutMs ?? 3000;
|
||||
const candidates = options?.binaryPath ? [options.binaryPath] : [...CANDIDATES];
|
||||
const failureDetails: string[] = [];
|
||||
|
||||
for (const binary of candidates) {
|
||||
const version = await runCursorCommand(binary, ["--version"], timeoutMs);
|
||||
const failureDetail = summarizeFailure(binary, version.stdout, version.stderr);
|
||||
if (failureDetail) failureDetails.push(failureDetail);
|
||||
if (version.code === 0) {
|
||||
// NOTE: Cursor CLI currently lacks a stable auth-status contract we can
|
||||
// invoke without side effects. Treating successful --version as ready is
|
||||
@@ -49,10 +60,11 @@ export async function probeCursorBinary(options?: { timeoutMs?: number; binaryPa
|
||||
}
|
||||
}
|
||||
|
||||
const baseReason = options?.binaryPath ? `${options.binaryPath} not found on PATH` : "cursor-agent/cursor not found on PATH";
|
||||
return {
|
||||
available: false,
|
||||
authenticated: false,
|
||||
reason: "cursor-agent/cursor not found on PATH",
|
||||
reason: failureDetails.length > 0 ? `${baseReason} (${failureDetails.join("; ")})` : baseReason,
|
||||
probeDurationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user