mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
Merge pull request #440 from morluto/fix/npx-setup-shadowing
fix(setup): refresh stale local npx bootstrap
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
@@ -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`,
|
||||
);
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user