mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
fix(setup): register a stable launcher when run through bunx (#1731)
Setup run through bunx or bun x registered the bin inside bun's temporary package cache. Detect bun's package-runner launch from npm_command=exec with a bun user agent and register the pinned bunx launcher, wrapped in cmd.exe on Windows like the npx launcher. Doctor, update maintenance and uninstall recognize it as REA-owned, and setup's printed commands name the runner that launched it. Read the runner variables case-insensitively on Windows, where the CLI environment snapshot upper-cases variable names and setup run through npx also fell back to the cache path. Co-authored-by: morluto <[email protected]>
This commit is contained in:
@@ -130,7 +130,8 @@ npx rea-agents@latest setup
|
||||
```
|
||||
|
||||
Review the setup changes and restart your agent. For one-off CLI commands,
|
||||
use `npx rea-agents@latest` followed by the command.
|
||||
use `npx rea-agents@latest` followed by the command. With Bun, use
|
||||
`bunx rea-agents@latest` in place of `npx rea-agents@latest`.
|
||||
|
||||
## How REA works
|
||||
|
||||
|
||||
+13
-5
@@ -36,6 +36,13 @@ Setup continues to pin persistent MCP registrations to the exact version that
|
||||
performed setup. Running current setup later migrates unversioned or older
|
||||
managed registrations through the normal reviewed setup transaction.
|
||||
|
||||
With Bun, `bunx rea-agents setup` (or `bunx rea-agents@latest setup`) is the
|
||||
equivalent entry point. Setup run through `npx` registers
|
||||
`npx -y rea-agents@<version> mcp`; setup run through `bunx` or `bun x` registers
|
||||
`bunx rea-agents@<version> mcp`. On Windows, setup wraps either command in
|
||||
`cmd.exe /d /c` so clients can start the runner's command shim. Neither
|
||||
registration points into the runner's temporary package cache.
|
||||
|
||||
REA supports Node.js 22.x (>=22.19), 24.x (>=24.11), and 26+. Node.js 23, 25, and prereleases are unsupported. It uses the npm already paired with that runtime and never upgrades Node.js, npm, or Homebrew.
|
||||
|
||||
Running `npm install rea-agents` without `--global` installs the executable only
|
||||
@@ -669,7 +676,7 @@ rea providers --json
|
||||
|
||||
`rea setup` can configure supported Windows agents and install the bundled
|
||||
REA skill after approval. Direct registrations use Node to launch REA's entry
|
||||
script; package-runner registrations use the pinned `npx` command. Hopper
|
||||
script; package-runner registrations use the pinned `npx` or `bunx` command. Hopper
|
||||
installation remains unavailable on Windows. Setup never installs Ghidra,
|
||||
Java, or Python. It preserves valid detected Ghidra/JDK settings in agent
|
||||
registrations. See [Windows Ghidra P0](windows-ghidra-p0.md) for provider diagnostics.
|
||||
@@ -836,7 +843,7 @@ Ghidra/JDK installation; neither is inferred from startup alone.
|
||||
|
||||
## Diagnose, update, and remove
|
||||
|
||||
`rea doctor --json` is strictly read-only. `rea update` updates only the npm or Bun global installation that owns the running CLI. Source checkouts and package-runner copies must be updated through the mechanism that owns them; a fresh package-runner invocation can use `npx rea-agents@latest`. `rea uninstall` removes only REA-owned agent registrations and skill files; `--purge-data` additionally removes REA cache and state paths.
|
||||
`rea doctor --json` is strictly read-only. `rea update` updates only the npm or Bun global installation that owns the running CLI. Source checkouts and package-runner copies must be updated through the mechanism that owns them; a fresh package-runner invocation can use `npx rea-agents@latest` or `bunx rea-agents@latest`. `rea uninstall` removes only REA-owned agent registrations and skill files; `--purge-data` additionally removes REA cache and state paths.
|
||||
|
||||
```bash
|
||||
rea update
|
||||
@@ -881,6 +888,7 @@ For a client that requires manual configuration, use:
|
||||
<!-- x-release-please-end -->
|
||||
|
||||
Persistent registrations should use one exact package version. `rea setup`
|
||||
writes the same package-runner shape and pins it to the exact version that
|
||||
performed setup. Run current setup to refresh an older registration, then
|
||||
restart the client.
|
||||
writes the same package-runner shape, or `bunx rea-agents@<version> mcp` when
|
||||
setup runs through `bunx`, and pins it to the exact version that performed
|
||||
setup. Run current setup to refresh an older registration, then restart the
|
||||
client.
|
||||
|
||||
@@ -3,20 +3,71 @@ import { valid } from "semver";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
|
||||
/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */
|
||||
export const npxRegistrationCommand = (
|
||||
/** Package runners whose pinned launcher setup can register. */
|
||||
export type PackageRunner = "npx" | "bunx";
|
||||
|
||||
/** Read one variable; Windows variable names are case-insensitive. */
|
||||
const environmentValue = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
name: string,
|
||||
platform: NodeJS.Platform,
|
||||
): string | undefined => {
|
||||
if (platform !== "win32") return environment[name];
|
||||
const key = Object.keys(environment).find(
|
||||
(candidate) => candidate.toLowerCase() === name,
|
||||
);
|
||||
return key === undefined ? undefined : environment[key];
|
||||
};
|
||||
|
||||
/** Identify the package runner that launched this process, if any. */
|
||||
export const invokingPackageRunner = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): PackageRunner | undefined => {
|
||||
if (environmentValue(environment, "npm_command", platform) !== "exec")
|
||||
return undefined;
|
||||
// `bunx` and `bun x` also report npm_command=exec, with a bun user agent.
|
||||
return environmentValue(
|
||||
environment,
|
||||
"npm_config_user_agent",
|
||||
platform,
|
||||
)?.startsWith("bun/") === true
|
||||
? "bunx"
|
||||
: "npx";
|
||||
};
|
||||
|
||||
/** Copy an environment without the variable that marks a package-runner launch. */
|
||||
export const withoutPackageRunnerIdentity = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): NodeJS.ProcessEnv =>
|
||||
Object.fromEntries(
|
||||
Object.entries(environment).filter(([key]) =>
|
||||
platform === "win32"
|
||||
? key.toLowerCase() !== "npm_command"
|
||||
: key !== "npm_command",
|
||||
),
|
||||
);
|
||||
|
||||
/** Resolve a pinned package-runner launcher that stdio clients can spawn on the host. */
|
||||
export const packageRunnerRegistrationCommand = (
|
||||
runner: PackageRunner,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): readonly string[] => {
|
||||
const command = [
|
||||
"npx",
|
||||
"-y",
|
||||
PRODUCT_IDENTITY.registrationPackageSpecifier,
|
||||
"mcp",
|
||||
];
|
||||
const command =
|
||||
runner === "npx"
|
||||
? ["npx", "-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]
|
||||
: ["bunx", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"];
|
||||
// Both runners may be .cmd shims on Windows, which clients cannot spawn directly.
|
||||
return platform === "win32" ? ["cmd.exe", "/d", "/c", ...command] : command;
|
||||
};
|
||||
|
||||
const unwrapNpxCommand = (command: readonly string[]): readonly string[] => {
|
||||
/** Resolve a pinned npx launcher that stdio clients can spawn on the host. */
|
||||
export const npxRegistrationCommand = (
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): readonly string[] => packageRunnerRegistrationCommand("npx", platform);
|
||||
|
||||
const unwrapCmdCommand = (command: readonly string[]): readonly string[] => {
|
||||
const executable = command[0]?.toLowerCase();
|
||||
if (executable !== "cmd" && executable !== "cmd.exe") return command;
|
||||
if (command[1]?.toLowerCase() === "/c") return command.slice(2);
|
||||
@@ -25,6 +76,35 @@ const unwrapNpxCommand = (command: readonly string[]): readonly string[] => {
|
||||
return [];
|
||||
};
|
||||
|
||||
const packageRunnerReference = (
|
||||
command: readonly string[],
|
||||
): string | undefined => {
|
||||
if (
|
||||
command.length === 4 &&
|
||||
command[0] === "npx" &&
|
||||
command[1] === "-y" &&
|
||||
command[3] === "mcp"
|
||||
)
|
||||
return command[2];
|
||||
if (command.length === 3 && command[0] === "bunx" && command[2] === "mcp")
|
||||
return command[1];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isOwnedPackageReference = (packageReference: string): boolean => {
|
||||
if (
|
||||
packageReference === PRODUCT_IDENTITY.packageName ||
|
||||
packageReference === PRODUCT_IDENTITY.packageSpecifier ||
|
||||
packageReference === PRODUCT_IDENTITY.registrationPackageSpecifier
|
||||
)
|
||||
return true;
|
||||
const versionPrefix = `${PRODUCT_IDENTITY.packageName}@`;
|
||||
return (
|
||||
packageReference.startsWith(versionPrefix) &&
|
||||
valid(packageReference.slice(versionPrefix.length)) !== null
|
||||
);
|
||||
};
|
||||
|
||||
/** Check that one parsed command points to REA's MCP entry point. */
|
||||
export const isOwnedClientRegistrationCommand = (
|
||||
command: readonly string[],
|
||||
@@ -49,25 +129,8 @@ export const isOwnedClientRegistrationCommand = (
|
||||
return true;
|
||||
}
|
||||
|
||||
const npxCommand = unwrapNpxCommand(command);
|
||||
if (
|
||||
npxCommand.length !== 4 ||
|
||||
npxCommand[0] !== "npx" ||
|
||||
npxCommand[1] !== "-y" ||
|
||||
npxCommand[3] !== "mcp"
|
||||
)
|
||||
return false;
|
||||
|
||||
const packageReference = npxCommand[2] ?? "";
|
||||
if (
|
||||
packageReference === PRODUCT_IDENTITY.packageName ||
|
||||
packageReference === PRODUCT_IDENTITY.packageSpecifier ||
|
||||
packageReference === PRODUCT_IDENTITY.registrationPackageSpecifier
|
||||
)
|
||||
return true;
|
||||
const versionPrefix = `${PRODUCT_IDENTITY.packageName}@`;
|
||||
const packageReference = packageRunnerReference(unwrapCmdCommand(command));
|
||||
return (
|
||||
packageReference.startsWith(versionPrefix) &&
|
||||
valid(packageReference.slice(versionPrefix.length)) !== null
|
||||
packageReference !== undefined && isOwnedPackageReference(packageReference)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import {
|
||||
isOwnedClientRegistrationCommand,
|
||||
npxRegistrationCommand,
|
||||
packageRunnerRegistrationCommand,
|
||||
} from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
clientEvidencePaths,
|
||||
@@ -232,10 +232,14 @@ const registrationAligned = (
|
||||
resolve(command[1] ?? "") === currentCommandPath
|
||||
)
|
||||
return true;
|
||||
const npxCommand = npxRegistrationCommand(platform);
|
||||
if (
|
||||
command.length === npxCommand.length &&
|
||||
command.every((argument, index) => argument === npxCommand[index])
|
||||
(["npx", "bunx"] as const).some((runner) => {
|
||||
const runnerCommand = packageRunnerRegistrationCommand(runner, platform);
|
||||
return (
|
||||
command.length === runnerCommand.length &&
|
||||
command.every((argument, index) => argument === runnerCommand[index])
|
||||
);
|
||||
})
|
||||
)
|
||||
return true;
|
||||
return (
|
||||
|
||||
@@ -253,9 +253,9 @@ describe("setup workflow action selection", () => {
|
||||
|
||||
expect(result.status).toBe("ready");
|
||||
expect(host.configuredCommands).toEqual([
|
||||
setupRegistrationCommand("win32", false),
|
||||
setupRegistrationCommand("win32", undefined),
|
||||
]);
|
||||
expect(setupRegistrationCommand("win32", false)).toEqual([
|
||||
expect(setupRegistrationCommand("win32", undefined)).toEqual([
|
||||
process.execPath,
|
||||
resolve(process.argv[1] ?? "rea"),
|
||||
"mcp",
|
||||
|
||||
@@ -52,7 +52,7 @@ export class FakeSetupHost implements SetupHost {
|
||||
|
||||
constructor(platform: NodeJS.Platform = "darwin") {
|
||||
this.platform = platform;
|
||||
this.registrationCommand = setupRegistrationCommand(platform, false);
|
||||
this.registrationCommand = setupRegistrationCommand(platform, undefined);
|
||||
}
|
||||
|
||||
macosVersion = (): Promise<string | undefined> =>
|
||||
|
||||
@@ -5,7 +5,11 @@ import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { npxRegistrationCommand } from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
invokingPackageRunner,
|
||||
packageRunnerRegistrationCommand,
|
||||
type PackageRunner,
|
||||
} from "./ClientRegistrationIdentity.js";
|
||||
import {
|
||||
SUPPORTED_NODE_VERSION_PROSE,
|
||||
supportsNodeVersion,
|
||||
@@ -48,10 +52,10 @@ import type { DoctorScope } from "./Doctor.js";
|
||||
/** Resolve the executable and arguments used in a managed MCP registration. */
|
||||
export const setupRegistrationCommand = (
|
||||
platform: NodeJS.Platform,
|
||||
useNpmRunner: boolean,
|
||||
packageRunner: PackageRunner | undefined,
|
||||
): readonly string[] =>
|
||||
useNpmRunner
|
||||
? npxRegistrationCommand(platform)
|
||||
packageRunner !== undefined
|
||||
? packageRunnerRegistrationCommand(packageRunner, platform)
|
||||
: platform === "win32"
|
||||
? [
|
||||
process.execPath,
|
||||
@@ -113,7 +117,7 @@ export const systemSetupHost = (
|
||||
skillDestinations(homeDirectory, clientIds, environment, platform),
|
||||
registrationCommand: setupRegistrationCommand(
|
||||
platform,
|
||||
environment.npm_command === "exec",
|
||||
invokingPackageRunner(environment, platform),
|
||||
),
|
||||
nodeVersion: process.versions.node,
|
||||
macosVersion: () => doctorHost.macosVersion(),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type UpdateOutput,
|
||||
} from "./Update.js";
|
||||
import { planIntegrationMaintenance } from "./UpdateMaintenance.js";
|
||||
import { withoutPackageRunnerIdentity } from "./ClientRegistrationIdentity.js";
|
||||
|
||||
/** npm filesystem queries used to identify the installation being updated. */
|
||||
export interface NpmInstallationHost {
|
||||
@@ -45,9 +46,8 @@ export const runUpdateCommand = (
|
||||
resolveResult(err("No executable supplied."));
|
||||
return;
|
||||
}
|
||||
const environment = { ...selectedEnvironment };
|
||||
// A fresh installed entry point must not inherit npx's invocation identity.
|
||||
delete environment.npm_command;
|
||||
// A fresh installed entry point must not inherit a package runner's invocation identity.
|
||||
const environment = withoutPackageRunnerIdentity(selectedEnvironment);
|
||||
const child = spawn(executable, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: environment,
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
SetupResult,
|
||||
} from "../application/SetupTypes.js";
|
||||
import { SUPPORTED_CLIENT_DEFINITIONS } from "../application/SupportedClients.js";
|
||||
import { invokingPackageRunner } from "../application/ClientRegistrationIdentity.js";
|
||||
|
||||
const clientDisplayNames: ReadonlyMap<string, string> = new Map(
|
||||
SUPPORTED_CLIENT_DEFINITIONS.map(({ name, displayName }) => [
|
||||
@@ -224,8 +225,10 @@ const renderKeyboardHelp = (accessible: boolean): void => {
|
||||
);
|
||||
};
|
||||
|
||||
const cliInvocation = (environment: Readonly<NodeJS.ProcessEnv>): string =>
|
||||
environment.npm_command === "exec" ? "npx rea-agents" : "rea";
|
||||
const cliInvocation = (environment: Readonly<NodeJS.ProcessEnv>): string => {
|
||||
const runner = invokingPackageRunner(environment);
|
||||
return runner === undefined ? "rea" : `${runner} rea-agents`;
|
||||
};
|
||||
|
||||
const renderReadyCapabilities = (
|
||||
result: SetupResult,
|
||||
|
||||
@@ -12,6 +12,12 @@ import { systemUninstallHost } from "./application/Uninstall.js";
|
||||
import { runUpdateCommand } from "./application/UpdateRuntime.js";
|
||||
import { createSystemDoctorHost } from "./doctorRuntime.js";
|
||||
import { PRODUCT_IDENTITY } from "./identity.js";
|
||||
import {
|
||||
invokingPackageRunner,
|
||||
packageRunnerRegistrationCommand,
|
||||
withoutPackageRunnerIdentity,
|
||||
} from "./application/ClientRegistrationIdentity.js";
|
||||
import { snapshotEnvironment } from "./process/snapshotEnvironment.js";
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
@@ -98,3 +104,27 @@ it("passes only selected environment to updater subprocesses and drops npm invoc
|
||||
);
|
||||
expect(result).toEqual({ ok: true, value: '{"marker":"selected"}' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ userAgent: "npm/10.9.0 node/v24.11.0 win32 x64", runner: "npx" },
|
||||
{ userAgent: "bun/1.4.2 npm/? node/v26.3.0 win32 x64", runner: "bunx" },
|
||||
] as const)(
|
||||
"registers the $runner launcher from a Windows CLI environment snapshot",
|
||||
({ userAgent, runner }) => {
|
||||
// The CLI snapshot canonicalizes Windows variable names to upper case.
|
||||
const environment = snapshotEnvironment(
|
||||
{ npm_command: "exec", npm_config_user_agent: userAgent },
|
||||
"win32",
|
||||
);
|
||||
expect(invokingPackageRunner(environment, "win32")).toBe(runner);
|
||||
expect(
|
||||
systemSetupHost(
|
||||
createDoctorHostFixture({ platform: "win32" }),
|
||||
environment,
|
||||
).registrationCommand,
|
||||
).toEqual(packageRunnerRegistrationCommand(runner, "win32"));
|
||||
expect(withoutPackageRunnerIdentity(environment, "win32")).toEqual({
|
||||
NPM_CONFIG_USER_AGENT: userAgent,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -216,11 +216,20 @@ describe("interactive setup journey", () => {
|
||||
|
||||
describe("interactive setup completion", () => {
|
||||
it.each([
|
||||
{ npmCommand: "", launcher: "rea" },
|
||||
{ npmCommand: "exec", launcher: "npx rea-agents" },
|
||||
{ npmCommand: "", userAgent: "", launcher: "rea" },
|
||||
{
|
||||
npmCommand: "exec",
|
||||
userAgent: "npm/10.9.0 node/v24.11.0 linux x64",
|
||||
launcher: "npx rea-agents",
|
||||
},
|
||||
{
|
||||
npmCommand: "exec",
|
||||
userAgent: "bun/1.4.2 npm/? node/v26.3.0 linux x64",
|
||||
launcher: "bunx rea-agents",
|
||||
},
|
||||
])(
|
||||
"prints an available next command for $launcher invocation",
|
||||
async ({ npmCommand, launcher }) => {
|
||||
async ({ npmCommand, userAgent, launcher }) => {
|
||||
const { stderr } = await execute(
|
||||
process.execPath,
|
||||
[
|
||||
@@ -246,7 +255,12 @@ describe("interactive setup completion", () => {
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, npm_command: npmCommand, NO_COLOR: "1" },
|
||||
env: {
|
||||
...process.env,
|
||||
npm_command: npmCommand,
|
||||
npm_config_user_agent: userAgent,
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { packageRunnerRegistrationCommand } from "../../../src/application/ClientRegistrationIdentity.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
const ompConfigurationSchema = z.object({
|
||||
mcpServers: z.object({
|
||||
rea: z.object({ command: z.string(), args: z.array(z.string()) }),
|
||||
}),
|
||||
});
|
||||
|
||||
// Package runners set these variables for the bin they launch from a temporary cache.
|
||||
for (const { runner, userAgent } of [
|
||||
{ runner: "npx", userAgent: "npm/10.9.0 node/v24.11.0 linux x64" },
|
||||
{ runner: "bunx", userAgent: "bun/1.4.2 npm/? node/v26.3.0 linux x64" },
|
||||
] as const) {
|
||||
cliTest(
|
||||
`${runner} setup registers the pinned ${runner} launcher instead of its cache`,
|
||||
async ({ cli }) => {
|
||||
const home = await createTestTempDirectory("rea-package-runner-");
|
||||
const run = (arguments_: readonly string[]) =>
|
||||
cli.run({
|
||||
arguments: arguments_,
|
||||
cwd: home,
|
||||
environment: {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
npm_command: "exec",
|
||||
npm_config_user_agent: userAgent,
|
||||
},
|
||||
timeoutMs: 20_000,
|
||||
});
|
||||
const setup = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
"omp",
|
||||
"--no-skill",
|
||||
"--yes",
|
||||
"--json",
|
||||
]);
|
||||
expect(setup.exitCode).toBe(0);
|
||||
expect(setup.json).toMatchObject({ status: "ready" });
|
||||
const configuration = ompConfigurationSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(join(home, ".omp", "agent", "mcp.json"), "utf8"),
|
||||
),
|
||||
);
|
||||
const { command, args } = configuration.mcpServers.rea;
|
||||
expect([command, ...args]).toEqual(
|
||||
packageRunnerRegistrationCommand(runner, process.platform),
|
||||
);
|
||||
const doctor = await run(["doctor", "--client", "omp", "--json"]);
|
||||
expect(doctor.json).toMatchObject({
|
||||
identity: { registrations: [{ client: "omp", state: "aligned" }] },
|
||||
});
|
||||
const repeat = await run([
|
||||
"setup",
|
||||
"--client",
|
||||
"omp",
|
||||
"--no-skill",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]);
|
||||
expect(repeat.json).toMatchObject({ plannedActions: [] });
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -486,6 +486,16 @@ describe("platform-aware client config paths", () => {
|
||||
expect(
|
||||
isOwnedClientRegistrationCommand(["npx", "-y", "another-package", "mcp"]),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isOwnedClientRegistrationCommand([
|
||||
"bunx",
|
||||
`${PRODUCT_IDENTITY.packageName}@3.2.0`,
|
||||
"mcp",
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOwnedClientRegistrationCommand(["bunx", "another-package", "mcp"]),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isOwnedClientRegistrationCommand(
|
||||
[process.execPath, "/packaged/rea.mjs", "mcp"],
|
||||
|
||||
@@ -56,7 +56,7 @@ it.each([
|
||||
expect.objectContaining({ client: "claude_code", state: "stale" }),
|
||||
);
|
||||
const client = { name: "claude_code", configPath, format: "json" } as const;
|
||||
const command = setupRegistrationCommand("win32", true);
|
||||
const command = setupRegistrationCommand("win32", "npx");
|
||||
expect(
|
||||
await configureClientConfiguration(client, {}, command),
|
||||
).toMatchObject({ status: "configured" });
|
||||
@@ -83,35 +83,38 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the Windows launcher aligned through Codex TOML setup and removal", async () => {
|
||||
const home = await createTestTempDirectory("rea-windows-codex-");
|
||||
await mkdir(join(home, ".codex"));
|
||||
const configPath = join(home, ".codex", "config.toml");
|
||||
const original =
|
||||
'\uFEFF# Keep this explanation.\nnotify = ["a", "b"]\nliteral = \'C:\\demo\\path\'\ndisabled_mcp_servers = ["rea"]\n\n[mcp_servers.other]\ncommand = "another-server"\n';
|
||||
await writeFile(configPath, original);
|
||||
const client = { name: "codex", configPath, format: "toml" } as const;
|
||||
const command = setupRegistrationCommand("win32", true);
|
||||
expect(await configureClientConfiguration(client, {}, command)).toMatchObject(
|
||||
{ status: "configured" },
|
||||
);
|
||||
expect(
|
||||
await readClientRegistrationStatuses(home, undefined, {
|
||||
platform: "win32",
|
||||
environment: {},
|
||||
}),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({ client: "codex", state: "aligned" }),
|
||||
);
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
|
||||
expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({
|
||||
status: "removed",
|
||||
});
|
||||
expect(await readFile(configPath, "utf8")).toBe(original);
|
||||
});
|
||||
it.each(["npx", "bunx"] as const)(
|
||||
"keeps the Windows %s launcher aligned through Codex TOML setup and removal",
|
||||
async (runner) => {
|
||||
const home = await createTestTempDirectory("rea-windows-codex-");
|
||||
await mkdir(join(home, ".codex"));
|
||||
const configPath = join(home, ".codex", "config.toml");
|
||||
const original =
|
||||
'\uFEFF# Keep this explanation.\nnotify = ["a", "b"]\nliteral = \'C:\\demo\\path\'\ndisabled_mcp_servers = ["rea"]\n\n[mcp_servers.other]\ncommand = "another-server"\n';
|
||||
await writeFile(configPath, original);
|
||||
const client = { name: "codex", configPath, format: "toml" } as const;
|
||||
const command = setupRegistrationCommand("win32", runner);
|
||||
expect(
|
||||
await configureClientConfiguration(client, {}, command),
|
||||
).toMatchObject({ status: "configured" });
|
||||
expect(
|
||||
await readClientRegistrationStatuses(home, undefined, {
|
||||
platform: "win32",
|
||||
environment: {},
|
||||
}),
|
||||
).toContainEqual(
|
||||
expect.objectContaining({ client: "codex", state: "aligned" }),
|
||||
);
|
||||
expect(await configureClientConfiguration(client, {}, command)).toEqual({
|
||||
status: "unchanged",
|
||||
});
|
||||
expect(await readFile(`${configPath}.rea.backup`, "utf8")).toBe(original);
|
||||
expect(await systemUninstallHost(home).removeClient(client)).toMatchObject({
|
||||
status: "removed",
|
||||
});
|
||||
expect(await readFile(configPath, "utf8")).toBe(original);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains rea-named custom shell registrations without writing a backup", async () => {
|
||||
const home = await createTestTempDirectory("rea-custom-shell-registration-");
|
||||
@@ -124,6 +127,9 @@ it("retains rea-named custom shell registrations without writing a backup", asyn
|
||||
["cmd.exe", "/c", "npx", "-y", "another-package", "mcp"],
|
||||
["cmd.exe", "/c", "npx", "-y", "[email protected]", "mcp", "&", "other"],
|
||||
["cmd.exe", "/c", "npx", "-y", "[email protected] & other", "mcp"],
|
||||
["cmd.exe", "/c", "bunx", "another-package", "mcp"],
|
||||
["cmd.exe", "/c", "bunx", "-y", "[email protected]", "mcp"],
|
||||
["cmd.exe", "/c", "bunx", "[email protected]", "mcp", "&", "other"],
|
||||
]) {
|
||||
const original = JSON.stringify({
|
||||
mcpServers: { rea: { command: command[0], args: command.slice(1) } },
|
||||
@@ -139,9 +145,12 @@ it("retains rea-named custom shell registrations without writing a backup", asyn
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "win32")(
|
||||
"starts the written Windows registration through a native npx.cmd shim",
|
||||
async () => {
|
||||
it.skipIf(process.platform !== "win32").each([
|
||||
["npx", ["-y", PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]],
|
||||
["bunx", [PRODUCT_IDENTITY.registrationPackageSpecifier, "mcp"]],
|
||||
] as const)(
|
||||
"starts the written Windows registration through a native %s.cmd shim",
|
||||
async (runner, shimArguments) => {
|
||||
const root = await createTestTempDirectory("rea-windows-mcp-");
|
||||
const home = join(root, "home with spaces");
|
||||
await mkdir(home);
|
||||
@@ -151,26 +160,29 @@ it.skipIf(process.platform !== "win32")(
|
||||
configPath,
|
||||
format: "json",
|
||||
} as const;
|
||||
const command = setupRegistrationCommand("win32", runner);
|
||||
// The default writer and npm-run setup must produce the same native launcher.
|
||||
expect(await configureClientConfiguration(setupClient)).toMatchObject({
|
||||
status: "configured",
|
||||
});
|
||||
expect(
|
||||
await (runner === "npx"
|
||||
? configureClientConfiguration(setupClient)
|
||||
: configureClientConfiguration(setupClient, {}, command)),
|
||||
).toMatchObject({ status: "configured" });
|
||||
const decoded: unknown = JSON.parse(await readFile(configPath, "utf8"));
|
||||
const registration = configurationSchema.parse(decoded).mcpServers.rea;
|
||||
expect([registration.command, ...registration.args]).toEqual(
|
||||
setupRegistrationCommand("win32", true),
|
||||
);
|
||||
expect([registration.command, ...registration.args]).toEqual(command);
|
||||
const entry = fileURLToPath(
|
||||
new URL("../../../scripts/rea.mjs", import.meta.url),
|
||||
);
|
||||
// Exercise the actual batch-shim boundary without registry access or package installation.
|
||||
await writeFile(
|
||||
join(home, "npx.cmd"),
|
||||
join(home, `${runner}.cmd`),
|
||||
[
|
||||
"@echo off",
|
||||
'if not "%~1"=="-y" exit /b 11',
|
||||
`if not "%~2"=="${PRODUCT_IDENTITY.registrationPackageSpecifier}" exit /b 12`,
|
||||
'if not "%~3"=="mcp" exit /b 13',
|
||||
...shimArguments.map(
|
||||
(argument, index) =>
|
||||
`if not "%~${String(index + 1)}"=="${argument}" exit /b ${String(11 + index)}`,
|
||||
),
|
||||
`if not "%~${String(shimArguments.length + 1)}"=="" exit /b 19`,
|
||||
`"${process.execPath}" "${entry}" mcp`,
|
||||
"",
|
||||
].join("\r\n"),
|
||||
|
||||
Reference in New Issue
Block a user