Merge pull request #440 from morluto/fix/npx-setup-shadowing

fix(setup): refresh stale local npx bootstrap
This commit is contained in:
morluto
2026-07-26 15:00:38 +08:00
committed by GitHub
9 changed files with 303 additions and 1 deletions
+7
View File
@@ -103,6 +103,13 @@ detected agents, then asks which capabilities to set up: agent integration
Nothing is preselected. Choosing agent integration opens a second empty
checklist for the specific detected agents that should receive a registration.
If bare `npx` finds a project-local REA package, its dispatcher restarts setup
through `rea-agents@latest` before planning any changes. This prevents an older
local dependency from silently writing stale integration state. An already
published older release cannot contain that dispatcher protection; run
`npx -y rea-agents@latest setup` once to repair registrations created by one.
Intentional rollbacks remain available through an exact package request.
REA keeps the journey inline so its history remains in the terminal. Selecting
a capability does not select every detected target or authorize a change.
Before anything changes, REA validates existing configuration, prints exact
+18
View File
@@ -15,6 +15,24 @@ current package-runner invocation. REA still prints its own setup plan and asks
for separate approval before changing agent configuration or installing a
product-owned component.
When bare `npx` resolves `rea-agents` from the current project's
`node_modules`, the selected dispatcher restarts setup through
`rea-agents@latest` before REA plans or writes configuration. This protects
current and future releases from silently persisting stale integration state.
Code in an already-published older release cannot acquire that check
retroactively; invoke `npx -y rea-agents@latest setup` once to repair any
registration it created.
For an intentional rollback, make the package request explicit:
```bash
npm exec --yes --package=rea-agents@2.4.0 -- rea setup
```
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.
REA supports Node.js 22.19+ and 24.11+ (including newer releases). 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
+1
View File
@@ -43,6 +43,7 @@
"bridge/hopper_bridge.py",
"bridge/ghidra/ReaGhidraBridge.java",
"scripts/rea.mjs",
"scripts/package-runner-bootstrap.mjs",
"scripts/hopper-demo-x11.py",
"scripts/prepack.mjs",
"scripts/prepare.mjs",
+19
View File
@@ -0,0 +1,19 @@
export interface PackageRunnerSetupInput {
readonly args: readonly string[];
readonly environment: Readonly<Record<string, string | undefined>>;
readonly packageRoot: string;
readonly packageName: string;
}
export interface PackageRunnerSetupPlan {
readonly command: "npm";
readonly args: readonly string[];
}
export function packageRunnerSetupPlan(
input: PackageRunnerSetupInput,
): PackageRunnerSetupPlan | undefined;
export function runPackageRunnerSetupBootstrap(
input: PackageRunnerSetupInput,
): Promise<number | undefined>;
+72
View File
@@ -0,0 +1,72 @@
import { spawn } from "node:child_process";
import { resolve } from "node:path";
const BOOTSTRAP_MARKER = "REA_PACKAGE_RUNNER_BOOTSTRAPPED";
/**
* Plan a current-release setup bootstrap when bare npx selected a project-local
* package. Explicit `npm exec --package=<name>@<version>` rollbacks bypass it.
*/
export function packageRunnerSetupPlan(input) {
if (input.args[0] !== "setup") return undefined;
if (input.environment[BOOTSTRAP_MARKER] === "1") return undefined;
if (input.environment.npm_lifecycle_event !== "npx") return undefined;
const localPrefix = input.environment.npm_config_local_prefix;
if (localPrefix === undefined || localPrefix.length === 0) return undefined;
if (
!isPackageBelowLocalPrefix(
input.packageRoot,
localPrefix,
input.packageName,
)
)
return undefined;
const requestedPackage = input.environment.npm_config_package;
if (
requestedPackage !== undefined &&
isExactPackageVersion(requestedPackage, input.packageName)
)
return undefined;
return {
command: "npm",
args: [
"exec",
"--yes",
`--package=${input.packageName}@latest`,
"--",
"rea",
...input.args,
],
};
}
/** Execute a planned package-runner bootstrap and return its exit status. */
export async function runPackageRunnerSetupBootstrap(input) {
const plan = packageRunnerSetupPlan(input);
if (plan === undefined) return undefined;
return await new Promise((resolveExitCode) => {
const child = spawn(plan.command, plan.args, {
stdio: "inherit",
env: { ...input.environment, [BOOTSTRAP_MARKER]: "1" },
});
child.once("error", () => resolveExitCode(1));
child.once("exit", (code) => resolveExitCode(code ?? 1));
});
}
function isPackageBelowLocalPrefix(packageRoot, localPrefix, packageName) {
const expectedRoot = resolve(localPrefix, "node_modules", packageName);
return resolve(packageRoot) === expectedRoot;
}
function isExactPackageVersion(packageSpecifier, packageName) {
const version = packageSpecifier.slice(`${packageName}@`.length);
return (
packageSpecifier.startsWith(`${packageName}@`) &&
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(version)
);
}
+11 -1
View File
@@ -4,6 +4,8 @@ import { access } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { runPackageRunnerSetupBootstrap } from "./package-runner-bootstrap.mjs";
// Route production MCP before importing Incur. Incur owns registration helpers
// such as `mcp add`, while only dist/main.js may serve the stdio tool catalog.
const args = process.argv.slice(2);
@@ -15,13 +17,21 @@ const isMcpMode =
args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp");
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const bootstrapExitCode = await runPackageRunnerSetupBootstrap({
args,
environment: process.env,
packageRoot,
packageName: packageJson.name,
});
const runtimeFiles = isMcpMode
? ["dist/main.js"]
: isMcpDoctorMode
? ["dist/main.js", "dist/mcpDoctor.js"]
: ["dist/cli.js", "dist/cliOutput.js"];
if (!(await compiledRuntimeExists(runtimeFiles))) {
if (bootstrapExitCode !== undefined) {
process.exitCode = bootstrapExitCode;
} else if (!(await compiledRuntimeExists(runtimeFiles))) {
process.stderr.write(
`REA's compiled runtime is missing. Run \`npm ci\` in ${packageRoot} to install dependencies and build REA, then restart it. If this is an installed package, reinstall rea-agents.\n`,
);
+8
View File
@@ -25,6 +25,10 @@ describe("executable dispatcher", () => {
await Promise.all([mkdir(scripts), mkdir(dist)]);
await Promise.all([
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
copyFile(
"scripts/package-runner-bootstrap.mjs",
join(scripts, "package-runner-bootstrap.mjs"),
),
writeFile(
join(fixtureRoot, "package.json"),
JSON.stringify({ type: "module", version: "1.0.0" }),
@@ -77,6 +81,10 @@ describe("executable dispatcher", () => {
await mkdir(scripts);
await Promise.all([
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
copyFile(
"scripts/package-runner-bootstrap.mjs",
join(scripts, "package-runner-bootstrap.mjs"),
),
writeFile(
join(fixtureRoot, "package.json"),
JSON.stringify({ type: "module", version: "1.0.0" }),
+153
View File
@@ -0,0 +1,153 @@
import { chmod, readFile, writeFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { describe, expect, it } from "vitest";
import {
packageRunnerSetupPlan,
runPackageRunnerSetupBootstrap,
} from "../scripts/package-runner-bootstrap.mjs";
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
describe("package-runner setup bootstrap", () => {
it("reruns bare setup through the current release when npm selected a local package", () => {
expect(
packageRunnerSetupPlan({
args: ["setup", "--client", "codex"],
environment: {
npm_lifecycle_event: "npx",
npm_config_local_prefix: "/work/project",
},
packageRoot: "/work/project/node_modules/rea-agents",
packageName: "rea-agents",
}),
).toEqual({
command: "npm",
args: [
"exec",
"--yes",
"--package=rea-agents@latest",
"--",
"rea",
"setup",
"--client",
"codex",
],
});
});
it("leaves explicit rollback and non-local invocations untouched", () => {
const base = {
args: ["setup"],
packageRoot: "/work/project/node_modules/rea-agents",
packageName: "rea-agents",
} as const;
expect(
packageRunnerSetupPlan({
...base,
environment: {
npm_lifecycle_event: "npx",
npm_config_local_prefix: "/work/project",
npm_config_package: "rea-agents@2.4.0",
},
}),
).toBeUndefined();
expect(
packageRunnerSetupPlan({
...base,
environment: {
npm_lifecycle_event: "npx",
npm_config_local_prefix: "/work/project",
npm_config_package: "rea-agents@^2.4.0",
},
}),
).toEqual(
expect.objectContaining({
args: expect.arrayContaining(["--package=rea-agents@latest"]),
}),
);
expect(
packageRunnerSetupPlan({
...base,
packageRoot: "/root/.npm/_npx/cache/node_modules/rea-agents",
environment: {
npm_lifecycle_event: "npx",
npm_config_local_prefix: "/work/project",
},
}),
).toBeUndefined();
});
it("does not redirect recursively or affect commands other than setup", () => {
const environment = {
npm_lifecycle_event: "npx",
npm_config_local_prefix: "/work/project",
REA_PACKAGE_RUNNER_BOOTSTRAPPED: "1",
};
expect(
packageRunnerSetupPlan({
args: ["setup"],
environment,
packageRoot: "/work/project/node_modules/rea-agents",
packageName: "rea-agents",
}),
).toBeUndefined();
expect(
packageRunnerSetupPlan({
args: ["doctor"],
environment: {
npm_lifecycle_event: "npx",
npm_config_local_prefix: "/work/project",
},
packageRoot: "/work/project/node_modules/rea-agents",
packageName: "rea-agents",
}),
).toBeUndefined();
});
it("forwards setup arguments to npm with a recursion marker", async () => {
const directory = await createTestTempDirectory("rea-bootstrap-");
const npm = join(directory, "npm");
const log = join(directory, "invocation.json");
await writeFile(
npm,
`#!/usr/bin/env node
import { writeFileSync } from "node:fs";
writeFileSync(process.env.REA_BOOTSTRAP_TEST_LOG, JSON.stringify({
args: process.argv.slice(2),
marker: process.env.REA_PACKAGE_RUNNER_BOOTSTRAPPED,
}));
`,
);
await chmod(npm, 0o755);
await expect(
runPackageRunnerSetupBootstrap({
args: ["setup", "--dry-run"],
environment: {
...process.env,
PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`,
REA_BOOTSTRAP_TEST_LOG: log,
npm_lifecycle_event: "npx",
npm_config_local_prefix: "/work/project",
},
packageRoot: "/work/project/node_modules/rea-agents",
packageName: "rea-agents",
}),
).resolves.toBe(0);
await expect(readFile(log, "utf8").then(JSON.parse)).resolves.toEqual({
args: [
"exec",
"--yes",
"--package=rea-agents@latest",
"--",
"rea",
"setup",
"--dry-run",
],
marker: "1",
});
});
});
+14
View File
@@ -92,5 +92,19 @@ describe("localized README product facts", () => {
expect(content).toContain("--install-hopper");
expect(content).toContain("docs/installation.md");
expect(content).toContain("You do not need both");
expect(content).toContain(
"npx -y rea-agents@latest setup` once to repair registrations",
);
});
it("documents stale bootstrap recovery and explicit rollback", async () => {
const content = await readFile(resolve("docs/installation.md"), "utf8");
expect(content).toContain("before REA plans or writes configuration");
expect(content).toContain(
"npm exec --yes --package=rea-agents@2.4.0 -- rea setup",
);
expect(content).toContain(
"pin persistent MCP registrations to the exact version",
);
});
});