import { lstat, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { resolveClientConfigTransactionPath } from "../../../src/application/ClientConfigPath.js"; import { readConfigurationFile } from "../../../src/application/ClientConfigurationFiles.js"; import { configureClientConfiguration, inspectClientConfiguration, } from "../../../src/application/SetupClientConfiguration.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; const pinnedPackage = PRODUCT_IDENTITY.registrationPackageSpecifier; describe("JSON client configuration transaction: writing configuration through safe paths", () => { it.skipIf(typeof process.getuid !== "function")( "rejects a symlink target not owned by the current user", async () => { const currentUid = process.getuid?.(); if (currentUid === undefined) throw new Error("UID support is required by this test"); let statCalls = 0; expect( await resolveClientConfigTransactionPath("/config", { lstat: () => { statCalls += 1; return Promise.resolve({ uid: statCalls === 1 ? currentUid : currentUid + 1, isFile: () => statCalls > 1, isSymbolicLink: () => statCalls === 1, }); }, realpath: () => Promise.resolve("/target"), }), ).toBeUndefined(); }, ); it("preserves existing keys, creates a backup, and reads back the MCP entry", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); const original = '{"theme":"dark","mcpServers":{"other":{"command":"other"}}}\n'; await writeFile(configPath, original); const result = await configureClientConfiguration({ name: "cursor", configPath, format: "json", }); expect(result.status).toBe("configured"); expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ theme: "dark", mcpServers: { other: { command: "other" }, rea: { command: "npx", args: ["-y", pinnedPackage, "mcp"], }, }, }); }); it("updates a symlink target without replacing the JSON config symlink", async () => { const directory = await createTestTempDirectory("rea-setup-"); const targetPath = join(directory, "managed.json"); const configPath = join(directory, "mcp.json"); const original = '{"theme":"dark"}\n'; await writeFile(targetPath, original); await symlink(targetPath, configPath); expect( await configureClientConfiguration({ name: "cursor", configPath, format: "json", }), ).toMatchObject({ status: "configured" }); expect((await lstat(configPath)).isSymbolicLink()).toBe(true); expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); expect(JSON.parse(await readFile(targetPath, "utf8"))).toMatchObject({ theme: "dark", mcpServers: { rea: { command: "npx", args: ["-y", pinnedPackage, "mcp"], }, }, }); expect( await configureClientConfiguration({ name: "cursor", configPath, format: "json", }), ).toEqual({ status: "unchanged", }); }); it("fails before mutation when a JSON config symlink is dangling", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); await symlink(join(directory, "missing.json"), configPath); expect( await configureClientConfiguration({ name: "cursor", configPath, format: "json", }), ).toEqual({ status: "failed", reason: "path", }); expect((await lstat(configPath)).isSymbolicLink()).toBe(true); await expect( readFile(`${configPath}.rea.backup`, "utf8"), ).rejects.toThrow(); }); }); describe("JSON client configuration transaction: keeping matching configuration unchanged", () => { it("performs no write or second backup when configuration already matches", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); await writeFile( configPath, `${JSON.stringify({ mcpServers: { rea: { command: "npx", args: ["-y", pinnedPackage, "mcp"] } } })}\n`, ); expect( await configureClientConfiguration({ name: "cursor", configPath, format: "json", }), ).toEqual({ status: "unchanged", }); await expect( readFile(`${configPath}.rea.backup`, "utf8"), ).rejects.toThrow(); }); it("does not rewrite an equivalent configuration with reordered environment keys", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); await writeFile( configPath, `${JSON.stringify({ mcpServers: { rea: { env: { JAVA_HOME: "/usr/lib/jvm/jdk-21", GHIDRA_INSTALL_DIR: "/opt/ghidra", }, args: ["-y", pinnedPackage, "mcp"], command: "npx", }, }, })}\n`, ); expect( await configureClientConfiguration( { name: "cursor", configPath, format: "json" }, { GHIDRA_INSTALL_DIR: "/opt/ghidra", JAVA_HOME: "/usr/lib/jvm/jdk-21", }, ), ).toEqual({ status: "unchanged" }); await expect( readFile(`${configPath}.rea.backup`, "utf8"), ).rejects.toThrow(); }); }); describe("JSON client configuration migration and provider paths", () => { it("migrates an unversioned npx registration and preserves sibling configuration", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); const original = '{"theme":"dark","mcpServers":{"rea":{"command":"npx","args":["-y","rea-agents","mcp"]},"other":{"command":"other"}}}\n'; await writeFile(configPath, original); expect( await configureClientConfiguration({ name: "cursor", configPath, format: "json", }), ).toEqual(expect.objectContaining({ status: "configured" })); expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ theme: "dark", mcpServers: { rea: { command: "npx", args: ["-y", pinnedPackage, "mcp"], }, other: { command: "other" }, }, }); expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); }); it("persists a custom Hopper launcher and remains idempotent", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); const hopperPath = "/Applications/Hopper v6.app/Contents/MacOS/hopper"; const client = { name: "cursor", configPath, format: "json" } as const; const original = "{}\n"; await writeFile(configPath, original); expect( await configureClientConfiguration(client, { HOPPER_LAUNCHER_PATH: hopperPath, }), ).toMatchObject({ status: "configured", }); expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ mcpServers: { rea: { command: "npx", args: ["-y", pinnedPackage, "mcp"], env: { HOPPER_LAUNCHER_PATH: hopperPath }, }, }, }); expect( await configureClientConfiguration(client, { HOPPER_LAUNCHER_PATH: hopperPath, }), ).toEqual({ status: "unchanged", }); expect( await configureClientConfiguration(client, { HOPPER_LAUNCHER_PATH: "/opt/hopper/bin/Hopper", }), ).toMatchObject({ status: "configured" }); expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); }); it("adds exact BYO Ghidra and Java paths without installing dependencies", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); const client = { name: "cursor", configPath, format: "json" } as const; const environment = { GHIDRA_INSTALL_DIR: "/opt/ghidra_12.1.4_PUBLIC", JAVA_HOME: "/usr/lib/jvm/jdk-21", }; expect( await configureClientConfiguration(client, environment), ).toMatchObject({ status: "configured", }); expect(JSON.parse(await readFile(configPath, "utf8"))).toMatchObject({ mcpServers: { rea: { env: { GHIDRA_INSTALL_DIR: environment.GHIDRA_INSTALL_DIR, JAVA_HOME: environment.JAVA_HOME, }, }, }, }); expect(await configureClientConfiguration(client, environment)).toEqual({ status: "unchanged", }); }); it("refuses malformed existing JSON without overwriting it", async () => { const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); await writeFile(configPath, "not-json"); expect( await configureClientConfiguration({ name: "cursor", configPath, format: "json", }), ).toEqual({ status: "failed", reason: "readback", }); expect(await readFile(configPath, "utf8")).toBe("not-json"); }); it("refuses a non-object JSON root without overwriting it", async () => { const original = "null"; const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); await writeFile(configPath, original); expect( await configureClientConfiguration({ name: "cursor", configPath, format: "json", }), ).toEqual({ status: "failed", reason: "readback", }); expect(await readFile(configPath, "utf8")).toBe(original); await expect( readFile(`${configPath}.rea.backup`, "utf8"), ).rejects.toThrow(); }); it("preserves a non-object mcpServers value", async () => { const servers = "[]"; const directory = await createTestTempDirectory("rea-setup-"); const configPath = join(directory, "mcp.json"); const original = `{"mcpServers":${servers}}`; await writeFile(configPath, original); expect( await configureClientConfiguration({ name: "cursor", configPath, format: "json", }), ).toEqual({ status: "failed", reason: "readback", }); expect(await readFile(configPath, "utf8")).toBe(original); await expect( readFile(`${configPath}.rea.backup`, "utf8"), ).rejects.toThrow(); }); }); describe("JSON client configuration shape diagnostics", () => { it.each([ { format: "json" as const, original: '{"__proto__":{"mcpServers":{"rea":{"command":"rea","args":["mcp"]}}}}', serversKey: "mcpServers", }, { format: "opencode" as const, original: '{"mcp":{"__proto__":{"servers":{"rea":{"type":"local","command":["rea","mcp"]}}}}}', serversKey: "mcp", }, ])( "registers an own server instead of trusting inherited $format settings", async ({ format, original, serversKey }) => { const directory = await createTestTempDirectory("rea-setup-prototype-"); const configPath = join(directory, "mcp.json"); const client = { name: format === "json" ? "cursor" : "opencode", configPath, format, }; await writeFile(configPath, original); expect( await inspectClientConfiguration(client, {}, ["rea", "mcp"]), ).toMatchObject({ status: "update" }); expect( await configureClientConfiguration(client, {}, ["rea", "mcp"]), ).toMatchObject({ status: "configured" }); const document = JSON.parse(await readFile(configPath, "utf8")); expect(Object.hasOwn(document, serversKey)).toBe(true); expect(Object.hasOwn(document[serversKey], "rea")).toBe(true); expect(document[serversKey].rea.command).toEqual( format === "json" ? "rea" : ["rea", "mcp"], ); const originalDocument = JSON.parse(original); expect( format === "json" ? document.__proto__ : document[serversKey].__proto__, ).toEqual( format === "json" ? originalDocument.__proto__ : originalDocument[serversKey].__proto__, ); expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original); expect( await inspectClientConfiguration(client, {}, ["rea", "mcp"]), ).toMatchObject({ status: "already_current" }); }, ); it.each([ ['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'], ["[]", "The configuration root must be an object"], ['{"mcpServers":"x"}', '"mcpServers" must be an object'], ] as const)( "names structural shape problems in setup remediation: %s", async (original, problem) => { const directory = await createTestTempDirectory("rea-setup-shape-"); const configPath = join(directory, "mcp.json"); const client = { name: "cursor", configPath, format: "json" as const, }; await writeFile(configPath, original); const read = await readConfigurationFile(configPath, "json"); expect(read.ok).toBe(false); if (read.ok) throw new Error("expected malformed configuration"); expect(read.error.detail).toBe( `Configuration ${configPath} is malformed: ${problem}`, ); expect( await inspectClientConfiguration(client, {}, ["rea", "mcp"]), ).toEqual({ status: "invalid", remediation: `Configuration ${configPath} is malformed: ${problem}`, }); expect(await readFile(configPath, "utf8")).toBe(original); }, ); });