From d412def7225a556e6d2140176dc9ab4bd34af0f3 Mon Sep 17 00:00:00 2001 From: xeropresence <3128949+xeropresence@users.noreply.github.com> Date: Sun, 11 Oct 2026 07:17:15 -0400 Subject: [PATCH] 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 <76467478+morluto@users.noreply.github.com> --- README.md | 3 +- docs/installation.md | 18 ++- src/application/ClientRegistrationIdentity.ts | 119 +++++++++++++----- src/application/ClientRegistrationStatus.ts | 12 +- src/application/Setup.configuration.test.ts | 4 +- src/application/Setup.fixture.ts | 2 +- src/application/SetupHost.ts | 14 ++- src/application/UpdateRuntime.ts | 6 +- src/cli/interactiveSetup.ts | 7 +- src/setupEnvironment.test.ts | 30 +++++ tests/boundary/cli/cliSetup.test.ts | 22 +++- .../cli/packageRunnerRegistration.test.ts | 70 +++++++++++ .../clientConfigurationDialects.test.ts | 10 ++ .../filesystem/windowsNpxRegistration.test.ts | 98 ++++++++------- 14 files changed, 317 insertions(+), 98 deletions(-) create mode 100644 tests/boundary/cli/packageRunnerRegistration.test.ts diff --git a/README.md b/README.md index a2e60e95f..7fe013f92 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/installation.md b/docs/installation.md index 2c4dff0fc..f71c2ddb0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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@ mcp`; setup run through `bunx` or `bun x` registers +`bunx rea-agents@ 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: 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@ 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. diff --git a/src/application/ClientRegistrationIdentity.ts b/src/application/ClientRegistrationIdentity.ts index 7a887d7c4..6e7744698 100644 --- a/src/application/ClientRegistrationIdentity.ts +++ b/src/application/ClientRegistrationIdentity.ts @@ -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, + 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, + 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, + 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) ); }; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 1b1bb6d6e..8c3e5035b 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -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 ( diff --git a/src/application/Setup.configuration.test.ts b/src/application/Setup.configuration.test.ts index a4e7568ad..4ba11f622 100644 --- a/src/application/Setup.configuration.test.ts +++ b/src/application/Setup.configuration.test.ts @@ -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", diff --git a/src/application/Setup.fixture.ts b/src/application/Setup.fixture.ts index 1fae2d931..f5460b78b 100644 --- a/src/application/Setup.fixture.ts +++ b/src/application/Setup.fixture.ts @@ -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 => diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index de13be596..9f44b8d63 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -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(), diff --git a/src/application/UpdateRuntime.ts b/src/application/UpdateRuntime.ts index 7325a4142..e2e919404 100644 --- a/src/application/UpdateRuntime.ts +++ b/src/application/UpdateRuntime.ts @@ -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, diff --git a/src/cli/interactiveSetup.ts b/src/cli/interactiveSetup.ts index b0ffefedb..9fcdf3d18 100644 --- a/src/cli/interactiveSetup.ts +++ b/src/cli/interactiveSetup.ts @@ -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 = new Map( SUPPORTED_CLIENT_DEFINITIONS.map(({ name, displayName }) => [ @@ -224,8 +225,10 @@ const renderKeyboardHelp = (accessible: boolean): void => { ); }; -const cliInvocation = (environment: Readonly): string => - environment.npm_command === "exec" ? "npx rea-agents" : "rea"; +const cliInvocation = (environment: Readonly): string => { + const runner = invokingPackageRunner(environment); + return runner === undefined ? "rea" : `${runner} rea-agents`; +}; const renderReadyCapabilities = ( result: SetupResult, diff --git a/src/setupEnvironment.test.ts b/src/setupEnvironment.test.ts index 4d72b5243..ce1c7d7d8 100644 --- a/src/setupEnvironment.test.ts +++ b/src/setupEnvironment.test.ts @@ -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, + }); + }, +); diff --git a/tests/boundary/cli/cliSetup.test.ts b/tests/boundary/cli/cliSetup.test.ts index c10aa66d8..742a3a6bc 100644 --- a/tests/boundary/cli/cliSetup.test.ts +++ b/tests/boundary/cli/cliSetup.test.ts @@ -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", + }, }, ); diff --git a/tests/boundary/cli/packageRunnerRegistration.test.ts b/tests/boundary/cli/packageRunnerRegistration.test.ts new file mode 100644 index 000000000..fa1f2796b --- /dev/null +++ b/tests/boundary/cli/packageRunnerRegistration.test.ts @@ -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: [] }); + }, + ); +} diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts index a378cc758..9dd850d8c 100644 --- a/tests/boundary/filesystem/clientConfigurationDialects.test.ts +++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts @@ -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"], diff --git a/tests/boundary/filesystem/windowsNpxRegistration.test.ts b/tests/boundary/filesystem/windowsNpxRegistration.test.ts index b32a62827..7aac2f58f 100644 --- a/tests/boundary/filesystem/windowsNpxRegistration.test.ts +++ b/tests/boundary/filesystem/windowsNpxRegistration.test.ts @@ -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", "rea-agents@5.0.0", "mcp", "&", "other"], ["cmd.exe", "/c", "npx", "-y", "rea-agents@5.0.0 & other", "mcp"], + ["cmd.exe", "/c", "bunx", "another-package", "mcp"], + ["cmd.exe", "/c", "bunx", "-y", "rea-agents@5.0.0", "mcp"], + ["cmd.exe", "/c", "bunx", "rea-agents@5.0.0", "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"),