mirror of
https://github.com/morluto/rea.git
synced 2026-11-13 10:12:50 +00:00
fix(cli): refuse an unsupported Node.js release before linking (#9297)
* fix(cli): refuse an unsupported Node.js release before linking The dispatcher imported the compiled runtime before checking the runtime range, so a release outside `engines.node` failed while linking a module that imports `createZstdDecompress`. Callers saw a module-linking stack trace from every command, including `--version` and `doctor`, instead of the refusal the installer and `engines` already promise. Check `engines.node` from the package read the dispatcher already performs, before the first compiled import. The helper has no imports of its own: the dispatcher must admit the runtime before any dependency can be resolved. Fixes #9296 * fix(cli): ship and align runtime admission * fix(ci): cover packaged runtime admission helper * test(cli): assert Node admission with structured range results Export satisfiesNodeRange and cover caret, minimum, union, and non-release versions. The dispatcher proves the compiled runtime never loads by the absence of its marker file, and compares stderr to the helper's own message. --------- Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
11 files changed
+224
-6
No files matched your search
@@ -76,6 +76,7 @@
|
||||
"bridge/agentenv/solve_cleanup.py",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/lib/cli-process-environment.mjs",
|
||||
"scripts/lib/runtime-admission.mjs",
|
||||
"scripts/rea-android-workspace-guardian.mjs",
|
||||
"scripts/electron-active-hook.cjs",
|
||||
"scripts/electron-active-hook-boundaries.cjs",
|
||||
|
||||
@@ -247,6 +247,8 @@ const PATH_SCOPES = {
|
||||
"tests/boundary/filesystem/releaseProposal.test.ts",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/lib/cli-process-environment.mjs",
|
||||
"scripts/lib/runtime-admission.mjs",
|
||||
"scripts/lib/runtime-admission.d.mts",
|
||||
"install.sh",
|
||||
"src/config/**",
|
||||
"src/application/Setup*.ts",
|
||||
@@ -493,6 +495,8 @@ const PATH_SCOPES = {
|
||||
"src/contracts/toolEffects.ts",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/lib/cli-process-environment.mjs",
|
||||
"scripts/lib/runtime-admission.mjs",
|
||||
"scripts/lib/runtime-admission.d.mts",
|
||||
"src/process/**",
|
||||
"src/artifacts/SafeOutputTree.ts",
|
||||
"scripts/verify/javascript/recovery.mjs",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** True when `version` is a release admitted by an `engines.node` range. */
|
||||
export function satisfiesNodeRange(range: string, version: string): boolean;
|
||||
|
||||
/** Refusal text for a release outside `range`, or undefined when it is admitted. */
|
||||
export function unsupportedRuntimeMessage(
|
||||
range: string,
|
||||
version: string,
|
||||
): string | undefined;
|
||||
|
||||
/** Human-readable release families for an `engines.node` range. */
|
||||
export function supportedReleaseProse(range: string): string;
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Reject a Node.js release the compiled runtime cannot link against.
|
||||
*
|
||||
* The dispatcher imports compiled modules before it can report anything, and
|
||||
* those modules import Node APIs that some releases do not export. Checking the
|
||||
* published range here keeps that failure a readable refusal instead of a
|
||||
* module-linking stack trace.
|
||||
*
|
||||
* `range` is the package's own `engines.node`, so the dispatcher and the
|
||||
* installer cannot drift apart. This module deliberately has no imports: the
|
||||
* dispatcher must admit the runtime before any dependency, including `semver`,
|
||||
* can be resolved.
|
||||
*/
|
||||
export const unsupportedRuntimeMessage = (range, version) => {
|
||||
if (typeof range !== "string" || range.length === 0) return undefined;
|
||||
if (satisfiesNodeRange(range, version)) return undefined;
|
||||
return (
|
||||
`REA requires ${supportedReleaseProse(range)}; this process runs Node.js ${version}. ` +
|
||||
"Install a supported release, then retry."
|
||||
);
|
||||
};
|
||||
|
||||
/** Human-readable release families for an `engines.node` range. */
|
||||
export const supportedReleaseProse = (range) =>
|
||||
`Node.js ${range
|
||||
.split("||")
|
||||
.map((clause) => {
|
||||
const floor = /^(\^|>=)\s*(\d+\.\d+\.\d+)$/.exec(clause.trim());
|
||||
if (floor === null) return clause.trim();
|
||||
return floor[1] === ">="
|
||||
? `${floor[2]}+`
|
||||
: `${floor[2].split(".")[0]}.x (>=${floor[2]})`;
|
||||
})
|
||||
.join(", ")}`;
|
||||
|
||||
const VERSION = /^(\d+)\.(\d+)\.(\d+)$/;
|
||||
const FLOOR = /^(>=|\^)\s*(\d+)\.(\d+)\.(\d+)$/;
|
||||
|
||||
/** Every clause of the published range is `^major.minor.patch` or `>=`. */
|
||||
export const satisfiesNodeRange = (range, version) => {
|
||||
const found = VERSION.exec(version);
|
||||
// A prerelease or a non-release spelling is never an admitted release.
|
||||
if (found === null) return false;
|
||||
const [, major, minor, patch] = found;
|
||||
return range.split("||").some((clause) => {
|
||||
const floor = FLOOR.exec(clause.trim());
|
||||
if (floor === null) return false;
|
||||
const [, operator, floorMajor, floorMinor, floorPatch] = floor;
|
||||
if (Number(major) !== Number(floorMajor))
|
||||
return operator === ">=" && Number(major) > Number(floorMajor);
|
||||
if (minor !== floorMinor) return Number(minor) > Number(floorMinor);
|
||||
return Number(patch) >= Number(floorPatch);
|
||||
});
|
||||
};
|
||||
@@ -46,6 +46,7 @@ if (await pathExists(join(root, "src"))) {
|
||||
} else {
|
||||
for (const path of [
|
||||
"scripts/lib/cli-process-environment.mjs",
|
||||
"scripts/lib/runtime-admission.mjs",
|
||||
"dist/main.js",
|
||||
"dist/mcp/main.js",
|
||||
"dist/cli-runtime/cli.js",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { prepareCliProcessEnvironment } from "./lib/cli-process-environment.mjs";
|
||||
import { unsupportedRuntimeMessage } from "./lib/runtime-admission.mjs";
|
||||
|
||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
||||
// such as `mcp add`, while the MCP runtime serves the tool catalog over stdio
|
||||
@@ -13,6 +14,19 @@ const args = process.argv.slice(2);
|
||||
const { default: packageJson } = await import("../package.json", {
|
||||
with: { type: "json" },
|
||||
});
|
||||
// Admit the runtime before the first compiled import. Compiled modules import
|
||||
// Node APIs that absent releases only reject while linking, so an unsupported
|
||||
// release previously surfaced as a module error from every command, including
|
||||
// `--version` and `doctor`. `process.exit` is required here: setting exitCode
|
||||
// would still reach the imports below.
|
||||
const unsupportedRuntime = unsupportedRuntimeMessage(
|
||||
packageJson.engines?.node,
|
||||
process.versions.node,
|
||||
);
|
||||
if (unsupportedRuntime !== undefined) {
|
||||
process.stderr.write(`${unsupportedRuntime}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
prepareCliProcessEnvironment(
|
||||
process.env,
|
||||
process.platform,
|
||||
|
||||
@@ -191,6 +191,8 @@ export async function verifyPackagePack({ root, workspace, commands }) {
|
||||
throw new Error("package did not expose both rea command entry points");
|
||||
if (!packedFiles.includes("package/scripts/lib/cli-process-environment.mjs"))
|
||||
throw new Error("package omitted the CLI process environment owner");
|
||||
if (!packedFiles.includes("package/scripts/lib/runtime-admission.mjs"))
|
||||
throw new Error("package omitted the CLI runtime admission owner");
|
||||
if (packedFiles.some((path) => path.startsWith("package/.agents/")))
|
||||
throw new Error(
|
||||
"package included authored skill sources instead of only the generated bundle",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { copyFile, cp } from "node:fs/promises";
|
||||
import { access, copyFile, cp } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { unsupportedRuntimeMessage } from "../../../scripts/lib/runtime-admission.mjs";
|
||||
import { workspaceCliTest } from "../../support/cli/workspaceCliFixture.js";
|
||||
import type { TestWorkspace } from "../../support/workspace/workspaceFixture.js";
|
||||
|
||||
@@ -27,6 +28,10 @@ const installCliRuntime = async (
|
||||
"scripts/lib/cli-process-environment.mjs",
|
||||
workspace.path("scripts/lib/cli-process-environment.mjs"),
|
||||
),
|
||||
copyFile(
|
||||
"scripts/lib/runtime-admission.mjs",
|
||||
workspace.path("scripts/lib/runtime-admission.mjs"),
|
||||
),
|
||||
workspace.write(
|
||||
"package.json",
|
||||
JSON.stringify({ type: "module", version: "1.0.0" }),
|
||||
@@ -188,6 +193,67 @@ describe("doctor configuration dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("runtime admission dispatch", () => {
|
||||
workspaceCliTest(
|
||||
"refuses an unsupported runtime before loading the compiled runtime",
|
||||
async ({ processes, workspace }) => {
|
||||
const marker = workspace.path("compiled-runtime-loaded");
|
||||
const loaded = `import { writeFileSync } from "node:fs";\nwriteFileSync(${JSON.stringify(marker)}, "loaded");\n`;
|
||||
const range = ">=999.0.0";
|
||||
await installCliRuntime(workspace, loaded, loaded);
|
||||
await workspace.write(
|
||||
"package.json",
|
||||
JSON.stringify({
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
engines: { node: range },
|
||||
}),
|
||||
);
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
[workspace.path("scripts/rea.mjs"), "--version"],
|
||||
{ cwd: workspace.root },
|
||||
);
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toBe(
|
||||
`${unsupportedRuntimeMessage(range, process.versions.node)}\n`,
|
||||
);
|
||||
await expect(access(marker)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
workspaceCliTest(
|
||||
"admits a runtime the package engines field allows",
|
||||
async ({ processes, workspace }) => {
|
||||
await installCliRuntime(
|
||||
workspace,
|
||||
"export const closeCliResources = () => {}; export const createCli = () => ({ serve: () => {} });\n",
|
||||
);
|
||||
const previousMajor = Number(process.versions.node.split(".")[0]) - 1;
|
||||
for (const range of [
|
||||
`^${process.versions.node}`,
|
||||
`>=${previousMajor}.0.0`,
|
||||
]) {
|
||||
await workspace.write(
|
||||
"package.json",
|
||||
JSON.stringify({
|
||||
type: "module",
|
||||
version: "1.0.0",
|
||||
engines: { node: range },
|
||||
}),
|
||||
);
|
||||
const result = await processes.run(
|
||||
process.execPath,
|
||||
[workspace.path("scripts/rea.mjs"), "fixture"],
|
||||
{ cwd: workspace.root },
|
||||
);
|
||||
expect(result.stderr).toBe("");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("executable dispatcher", () => {
|
||||
workspaceCliTest(
|
||||
"does not append recovery output after delegated stdout has begun",
|
||||
@@ -300,6 +366,10 @@ describe("executable dispatcher", () => {
|
||||
"scripts/lib/cli-process-environment.mjs",
|
||||
workspace.path("scripts/lib/cli-process-environment.mjs"),
|
||||
),
|
||||
copyFile(
|
||||
"scripts/lib/runtime-admission.mjs",
|
||||
workspace.path("scripts/lib/runtime-admission.mjs"),
|
||||
),
|
||||
workspace.write(
|
||||
"package.json",
|
||||
JSON.stringify({ type: "module", version: "1.0.0" }),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { satisfiesNodeRange } from "../../../scripts/lib/runtime-admission.mjs";
|
||||
|
||||
describe("satisfiesNodeRange", () => {
|
||||
it("admits a caret floor and later patches or minors in that major", () => {
|
||||
expect(satisfiesNodeRange("^22.23.4", "22.23.4")).toBe(true);
|
||||
expect(satisfiesNodeRange("^22.23.4", "22.23.5")).toBe(true);
|
||||
expect(satisfiesNodeRange("^22.23.4", "22.24.0")).toBe(true);
|
||||
expect(satisfiesNodeRange("^22.23.4", "22.23.3")).toBe(false);
|
||||
expect(satisfiesNodeRange("^22.23.4", "22.22.9")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a higher major under a caret and admits it under a minimum", () => {
|
||||
expect(satisfiesNodeRange("^22.23.4", "23.0.0")).toBe(false);
|
||||
expect(satisfiesNodeRange(">=22.23.4", "23.0.0")).toBe(true);
|
||||
expect(satisfiesNodeRange(">=22.23.4", "22.23.4")).toBe(true);
|
||||
expect(satisfiesNodeRange(">=22.23.4", "22.23.3")).toBe(false);
|
||||
expect(satisfiesNodeRange(">=22.23.4", "22.22.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("admits either clause of a union", () => {
|
||||
const range = "^22.23.4 || >=26.12.1";
|
||||
expect(satisfiesNodeRange(range, "22.23.4")).toBe(true);
|
||||
expect(satisfiesNodeRange(range, "26.12.1")).toBe(true);
|
||||
expect(satisfiesNodeRange(range, "27.0.0")).toBe(true);
|
||||
expect(satisfiesNodeRange(range, "24.0.0")).toBe(false);
|
||||
expect(satisfiesNodeRange(range, "26.12.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects prerelease and non-release spellings", () => {
|
||||
expect(satisfiesNodeRange("^22.23.4", "22.23.4-rc.1")).toBe(false);
|
||||
expect(satisfiesNodeRange(">=22.23.4", "22.23.4-rc.1")).toBe(false);
|
||||
expect(satisfiesNodeRange("^22.23.4", "not-a-version")).toBe(false);
|
||||
expect(satisfiesNodeRange("^22.23.4", "22.23")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1492,6 +1492,26 @@ it.each([
|
||||
readiness: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"scripts/lib/runtime-admission.mjs",
|
||||
{
|
||||
code: true,
|
||||
package: true,
|
||||
runtime: true,
|
||||
javascript_recovery: true,
|
||||
readiness: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"scripts/lib/runtime-admission.d.mts",
|
||||
{
|
||||
code: true,
|
||||
package: true,
|
||||
runtime: true,
|
||||
javascript_recovery: true,
|
||||
readiness: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"tests/conformance/readiness/report/reconstructionReadiness.ts",
|
||||
{
|
||||
|
||||
@@ -780,11 +780,15 @@ const copyCompiledPackage = async (prefix: string): Promise<string> => {
|
||||
await cp(resolve("dist"), join(directory, "dist"), { recursive: true });
|
||||
await mkdir(join(directory, "scripts/lib"), { recursive: true });
|
||||
await cp(resolve("scripts/rea.mjs"), join(directory, "scripts/rea.mjs"));
|
||||
// The launcher imports its process-environment owner, as the package ships it.
|
||||
await cp(
|
||||
resolve("scripts/lib/cli-process-environment.mjs"),
|
||||
join(directory, "scripts/lib/cli-process-environment.mjs"),
|
||||
);
|
||||
// Copy the helpers selected by the package, so this fixture exercises the
|
||||
// same launcher files that an installation receives.
|
||||
const manifest = JSON.parse(await readFile("package.json", "utf8")) as {
|
||||
files: string[];
|
||||
};
|
||||
for (const path of manifest.files.filter((file) =>
|
||||
file.startsWith("scripts/lib/"),
|
||||
))
|
||||
await cp(resolve(path), join(directory, path));
|
||||
await cp(resolve("package.json"), join(directory, "package.json"));
|
||||
await symlink(
|
||||
resolve("node_modules"),
|
||||
|
||||
Reference in new issue
Block a user