mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
fix(setup): recognize shared CLI launcher aliases (#4803)
This commit is contained in:
1 parent
d38f099692
commit
c1fe0d653d
4 files changed
+135
-25
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { valid } from "semver";
|
||||
|
||||
@@ -115,6 +116,19 @@ const isOwnedPackageReference = (packageReference: string): boolean => {
|
||||
/** Check that one parsed command points to REA's MCP entry point. */
|
||||
export type ClientRegistrationCommandIdentity = "owned" | "foreign" | "unknown";
|
||||
|
||||
/** POSIX bin links can name the same dispatcher without changing stored commands. */
|
||||
const sameEntrypointPath = (candidate: string, current: string): boolean => {
|
||||
if (!isAbsolute(candidate) || !isAbsolute(current)) return false;
|
||||
if (resolve(candidate) === current) return true;
|
||||
// Windows package bins are command shims, not links to the dispatcher.
|
||||
if (process.platform === "win32") return false;
|
||||
try {
|
||||
return realpathSync(candidate) === realpathSync(current);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Classify only what the command and current executable identity establish. */
|
||||
export const classifyClientRegistrationCommand = (
|
||||
command: readonly string[],
|
||||
@@ -164,7 +178,7 @@ export const isOwnedClientRegistrationCommand = (
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
isAbsolute(command[1] ?? "") &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
sameEntrypointPath(command[1] ?? "", currentCommandPath)
|
||||
)
|
||||
return true;
|
||||
|
||||
@@ -175,7 +189,7 @@ export const isOwnedClientRegistrationCommand = (
|
||||
(isAbsolute(executable) &&
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
resolve(executable) === currentCommandPath)
|
||||
sameEntrypointPath(executable, currentCommandPath))
|
||||
)
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type ClientConfigurationDocument,
|
||||
} from "./ClientConfigurationDocument.js";
|
||||
import { access } from "node:fs/promises";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
import { valid } from "semver";
|
||||
@@ -403,27 +403,11 @@ const registrationAligned = ({
|
||||
})
|
||||
)
|
||||
return "aligned";
|
||||
if (
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
command.length === 3 &&
|
||||
command[2] === "mcp" &&
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
isAbsolute(command[1] ?? "") &&
|
||||
resolve(command[0] ?? "") === resolve(process.execPath) &&
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
return "aligned";
|
||||
if (
|
||||
currentCommandPath !== undefined &&
|
||||
currentCommandPath.length > 0 &&
|
||||
command.length === 2 &&
|
||||
command[1] === "mcp" &&
|
||||
isAbsolute(command[0] ?? "") &&
|
||||
resolve(command[0] ?? "") === currentCommandPath
|
||||
)
|
||||
return "aligned";
|
||||
return "stale";
|
||||
// Other owned package references remain stale until pinned to this version.
|
||||
if (clientRegistrationPackageReference(command) !== undefined) return "stale";
|
||||
// The identity owner already established an exact direct launcher match.
|
||||
// A bare command still depends on the client's PATH, so cannot prove alignment.
|
||||
return isAbsolute(command[0] ?? "") ? "aligned" : "stale";
|
||||
};
|
||||
|
||||
const parseRegistration = (
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.skipIf(process.platform === "win32").for([
|
||||
["rea", "rea-agents"],
|
||||
["rea-agents", "rea"],
|
||||
] as const)(
|
||||
"recognizes setup through %s when doctor and uninstall use %s",
|
||||
async ([registered, alternate], { processes }) => {
|
||||
const root = await createTestTempDirectory("rea-launcher-registration-");
|
||||
const home = join(root, "home");
|
||||
const config = join(home, ".cursor/mcp.json");
|
||||
await mkdir(join(home, ".cursor"), { recursive: true });
|
||||
const unrelated = { command: "other-server", args: [] };
|
||||
await writeFile(config, JSON.stringify({ mcpServers: { unrelated } }));
|
||||
for (const name of [registered, alternate])
|
||||
await symlink(resolve("scripts/rea.mjs"), join(root, name));
|
||||
const run = (alias: string, arguments_: readonly string[]) =>
|
||||
processes.run(process.execPath, [join(root, alias), ...arguments_], {
|
||||
cwd: home,
|
||||
env: {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
XDG_CONFIG_HOME: join(home, ".config"),
|
||||
PATH: process.env.PATH ?? "",
|
||||
},
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const setup = await run(registered, [
|
||||
"setup",
|
||||
"--client",
|
||||
"cursor",
|
||||
"--skill=false",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(setup.exitCode, setup.stderr).toBe(0);
|
||||
for (const alias of [registered, alternate]) {
|
||||
const doctor = await run(alias, [
|
||||
"doctor",
|
||||
"--client",
|
||||
"cursor",
|
||||
"--skill=false",
|
||||
"--json",
|
||||
]);
|
||||
expect(doctor.exitCode, doctor.stdout).toBe(0);
|
||||
expect(JSON.parse(doctor.stdout)).toMatchObject({
|
||||
scope_checks: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "registration:cursor", ok: true }),
|
||||
]),
|
||||
});
|
||||
}
|
||||
const uninstall = await run(alternate, ["uninstall", "--json"]);
|
||||
expect(uninstall.exitCode, uninstall.stderr).toBe(0);
|
||||
expect(JSON.parse(await readFile(config, "utf8"))).toEqual({
|
||||
mcpServers: { unrelated },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -706,6 +706,54 @@ describe("platform-aware client config paths", () => {
|
||||
});
|
||||
|
||||
describe("client registration command ownership", () => {
|
||||
it.skipIf(process.platform === "win32").each([
|
||||
["rea", "rea-agents"],
|
||||
["rea-agents", "rea"],
|
||||
])(
|
||||
"recognizes %s through its %s launcher alias",
|
||||
async (registered, current) => {
|
||||
const root = await createTestTempDirectory("rea-launcher-alias-");
|
||||
const script = join(root, "entry.mjs");
|
||||
await writeFile(script, "// owned dispatcher\n");
|
||||
await symlink(script, join(root, registered));
|
||||
await symlink(script, join(root, current));
|
||||
for (const command of [
|
||||
[join(root, registered), "mcp"],
|
||||
[process.execPath, join(root, registered), "mcp"],
|
||||
]) {
|
||||
expect(
|
||||
classifyClientRegistrationCommand(command, join(root, current)),
|
||||
).toBe("owned");
|
||||
expect(
|
||||
isOwnedClientRegistrationCommand(command, join(root, current)),
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not own a same-named launcher for another script or a dangling link",
|
||||
async () => {
|
||||
const root = await createTestTempDirectory("rea-launcher-foreign-");
|
||||
const owned = join(root, "entry.mjs");
|
||||
const foreign = join(root, "other.mjs");
|
||||
await writeFile(owned, "// owned\n");
|
||||
await writeFile(foreign, "// foreign\n");
|
||||
await symlink(owned, join(root, "rea"));
|
||||
await symlink(foreign, join(root, "rea-agents"));
|
||||
await symlink(join(root, "missing.mjs"), join(root, "missing"));
|
||||
for (const candidate of ["rea-agents", "missing"]) {
|
||||
const command = [join(root, candidate), "mcp"];
|
||||
expect(
|
||||
classifyClientRegistrationCommand(command, join(root, "rea")),
|
||||
).toBe("foreign");
|
||||
expect(
|
||||
isOwnedClientRegistrationCommand(command, join(root, "rea")),
|
||||
).toBe(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("recognizes prior exact REA releases while rejecting foreign package commands", () => {
|
||||
expect(isOwnedClientRegistrationCommand(["rea", "mcp"], undefined)).toBe(
|
||||
true,
|
||||
|
||||
Reference in new issue
Block a user