421 lines
14 KiB
TypeScript
421 lines
14 KiB
TypeScript
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);
|
|
},
|
|
);
|
|
});
|