mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
* fix(update): validate lifecycle runtimes * fix(update): verify Bun lifecycle path * fix(update): reject drive-relative Node paths * test(update): align lifecycle argv expectations
352 lines
11 KiB
JavaScript
352 lines
11 KiB
JavaScript
// Enforces the package runtime contract, then warns for non-pnpm lifecycle installs.
|
|
import { spawnSync } from "node:child_process";
|
|
import { readFileSync, rmSync } from "node:fs";
|
|
import { posix, win32 } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const allowedLifecyclePackageManagers = new Set(["pnpm", "npm", "yarn", "bun"]);
|
|
const lifecyclePackageManagerLauncherAliases = new Map([
|
|
["yarnpkg", "yarn"],
|
|
["yarn-berry", "yarn"],
|
|
]);
|
|
const NODE_ENGINE_CLAUSE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)(?:\s+<\s*v?(\d+(?:\.\d+\.\d+)?))?\s*$/iu;
|
|
const NODE_VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u;
|
|
const NODE_RUNTIME_PROBE_SOURCE =
|
|
"process.stdout.write(JSON.stringify({version:process.versions.node??null,bunVersion:process.versions.bun??null,execPath:process.execPath??null}))";
|
|
const PACKAGE_CLI_NODE_PROBE_TIMEOUT_MS = 10_000;
|
|
export const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard";
|
|
|
|
function normalizeEnvValue(value) {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function parseNodeVersion(value) {
|
|
const match = NODE_VERSION_RE.exec(normalizeEnvValue(value));
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
return {
|
|
major: Number.parseInt(match[1] ?? "", 10),
|
|
minor: Number.parseInt(match[2] ?? "", 10),
|
|
patch: Number.parseInt(match[3] ?? "", 10),
|
|
};
|
|
}
|
|
|
|
function isNodeVersionAtLeast(version, minimum) {
|
|
if (version.major !== minimum.major) {
|
|
return version.major > minimum.major;
|
|
}
|
|
if (version.minor !== minimum.minor) {
|
|
return version.minor > minimum.minor;
|
|
}
|
|
return version.patch >= minimum.patch;
|
|
}
|
|
|
|
/** Checks a Node version against the standalone package engine-range subset. */
|
|
export function nodeVersionSatisfiesPackageEngine(version, engine) {
|
|
const parsedVersion = parseNodeVersion(version);
|
|
const normalizedEngine = normalizeEnvValue(engine);
|
|
if (!parsedVersion || !normalizedEngine) {
|
|
return false;
|
|
}
|
|
|
|
let satisfied = false;
|
|
for (const clause of normalizedEngine.split("||")) {
|
|
const match = NODE_ENGINE_CLAUSE_RE.exec(clause);
|
|
if (!match) {
|
|
return false;
|
|
}
|
|
const minimum = parseNodeVersion(match[1]);
|
|
const upperRaw = match[2];
|
|
const upper = upperRaw
|
|
? parseNodeVersion(upperRaw.includes(".") ? upperRaw : `${upperRaw}.0.0`)
|
|
: null;
|
|
if (!minimum || (upperRaw && !upper)) {
|
|
return false;
|
|
}
|
|
if (
|
|
isNodeVersionAtLeast(parsedVersion, minimum) &&
|
|
(!upper || !isNodeVersionAtLeast(parsedVersion, upper))
|
|
) {
|
|
satisfied = true;
|
|
}
|
|
}
|
|
return satisfied;
|
|
}
|
|
|
|
/** Reads the Node runtime contract from the package being installed. */
|
|
export function readPackageNodeEngine(
|
|
packageJsonUrl = new URL("../package.json", import.meta.url),
|
|
) {
|
|
try {
|
|
const manifest = JSON.parse(readFileSync(packageJsonUrl, "utf8"));
|
|
return normalizeEnvValue(manifest?.engines?.node) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseNodeRuntimeProbeOutput(value) {
|
|
try {
|
|
const parsed = JSON.parse(normalizeEnvValue(value));
|
|
if (!parsed || typeof parsed !== "object") {
|
|
return null;
|
|
}
|
|
return {
|
|
version: normalizeEnvValue(parsed.version) || null,
|
|
bunVersion: normalizeEnvValue(parsed.bunVersion) || null,
|
|
execPath: normalizeEnvValue(parsed.execPath) || null,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizePathForComparison(value, pathApi, platform) {
|
|
const normalized = pathApi.normalize(value);
|
|
return platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
function isStableAbsolutePath(value, pathApi, platform) {
|
|
if (!pathApi.isAbsolute(value)) {
|
|
return false;
|
|
}
|
|
if (platform !== "win32") {
|
|
return true;
|
|
}
|
|
const root = pathApi.parse(value).root;
|
|
return root !== "\\" && root !== "/";
|
|
}
|
|
|
|
function stripBunLifecyclePathPrefix(pathEntries, cwd, pathApi, platform) {
|
|
const expectedPrefix = [];
|
|
let directory = pathApi.resolve(cwd);
|
|
while (true) {
|
|
expectedPrefix.push(pathApi.join(directory, "node_modules", ".bin"));
|
|
const parent = pathApi.dirname(directory);
|
|
if (parent === directory) {
|
|
break;
|
|
}
|
|
directory = parent;
|
|
}
|
|
|
|
if (pathEntries.length < expectedPrefix.length) {
|
|
return null;
|
|
}
|
|
for (const [index, expected] of expectedPrefix.entries()) {
|
|
if (
|
|
normalizePathForComparison(pathEntries[index], pathApi, platform) !==
|
|
normalizePathForComparison(expected, pathApi, platform)
|
|
) {
|
|
return null;
|
|
}
|
|
}
|
|
return pathEntries.slice(expectedPrefix.length);
|
|
}
|
|
|
|
/** Finds the real Node that will launch the installed CLI after Bun removes its lifecycle PATH. */
|
|
export function probePackageCliNodeRuntime(options = {}) {
|
|
const {
|
|
env = process.env,
|
|
pathEnv = env.PATH ?? "",
|
|
platform = process.platform,
|
|
cwd = process.cwd(),
|
|
run = spawnSync,
|
|
} = options;
|
|
const pathApi = platform === "win32" ? win32 : posix;
|
|
const delimiter = platform === "win32" ? ";" : ":";
|
|
const executableName = platform === "win32" ? "node.exe" : "node";
|
|
const seen = new Set();
|
|
// Bun prepends one cwd-to-root node_modules/.bin path per ancestor before
|
|
// the original PATH. Strip only that exact prefix; anything else persists.
|
|
const pathEntries = stripBunLifecyclePathPrefix(pathEnv.split(delimiter), cwd, pathApi, platform);
|
|
if (!pathEntries) {
|
|
return null;
|
|
}
|
|
|
|
for (const entry of pathEntries) {
|
|
if (!entry || !isStableAbsolutePath(entry, pathApi, platform)) {
|
|
// Relative paths, including Windows root-relative paths, resolve against
|
|
// each future CLI invocation's cwd or drive.
|
|
// No preinstall probe can safely approve the Node they may select later.
|
|
return null;
|
|
}
|
|
const candidate = pathApi.join(entry, executableName);
|
|
if (seen.has(candidate)) {
|
|
continue;
|
|
}
|
|
seen.add(candidate);
|
|
|
|
const childEnv = { ...env };
|
|
for (const key of Object.keys(childEnv)) {
|
|
if (key.toUpperCase() === "NODE_OPTIONS") {
|
|
delete childEnv[key];
|
|
}
|
|
}
|
|
const result = run(candidate, ["-e", NODE_RUNTIME_PROBE_SOURCE], {
|
|
cwd,
|
|
encoding: "utf8",
|
|
env: childEnv,
|
|
timeout: PACKAGE_CLI_NODE_PROBE_TIMEOUT_MS,
|
|
windowsHide: true,
|
|
});
|
|
if (
|
|
result?.error?.code === "EACCES" ||
|
|
result?.error?.code === "ENOENT" ||
|
|
result?.error?.code === "ENOTDIR"
|
|
) {
|
|
continue;
|
|
}
|
|
if (result?.status !== 0) {
|
|
return null;
|
|
}
|
|
|
|
const runtime = parseNodeRuntimeProbeOutput(result.stdout);
|
|
if (!runtime) {
|
|
return null;
|
|
}
|
|
// A Bun-backed candidate from the original PATH remains first after install.
|
|
// It cannot satisfy the package's Node engine contract, so fail closed.
|
|
if (runtime.bunVersion) {
|
|
return null;
|
|
}
|
|
return runtime;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Rejects installation before an unsupported runtime can replace a working release. */
|
|
export function enforceSupportedNodeRuntime(
|
|
{
|
|
version = process.versions.node ?? null,
|
|
bunVersion = process.versions.bun ?? null,
|
|
engine = readPackageNodeEngine(),
|
|
execPath = process.execPath,
|
|
probeNodeRuntime = probePackageCliNodeRuntime,
|
|
} = {},
|
|
reportError = console.error,
|
|
) {
|
|
const detectedRuntime = normalizeEnvValue(bunVersion)
|
|
? probeNodeRuntime()
|
|
: { version, execPath };
|
|
if (nodeVersionSatisfiesPackageEngine(detectedRuntime?.version ?? null, engine)) {
|
|
return true;
|
|
}
|
|
|
|
const requirement = engine
|
|
? `this OpenClaw release requires Node ${engine}.`
|
|
: "could not read this OpenClaw release's Node requirement.";
|
|
reportError(
|
|
[
|
|
`[openclaw] error: ${requirement}`,
|
|
`[openclaw] detected Node ${detectedRuntime?.version ?? "missing"} (exec: ${detectedRuntime?.execPath || "unknown"}).`,
|
|
"[openclaw] install Node: https://nodejs.org/en/download",
|
|
"[openclaw] upgrade Node, then retry the OpenClaw update.",
|
|
].join("\n"),
|
|
);
|
|
return false;
|
|
}
|
|
|
|
/** Removes the packed sentinel only after the runtime check succeeds. */
|
|
export function completePackageInstallGuard(
|
|
{
|
|
markerUrl = new URL(`../${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`, import.meta.url),
|
|
remove = rmSync,
|
|
} = {},
|
|
reportError = console.error,
|
|
) {
|
|
try {
|
|
remove(markerUrl, { force: true });
|
|
return true;
|
|
} catch (error) {
|
|
reportError(
|
|
`[openclaw] error: could not complete package preinstall: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizeLifecyclePackageManagerName(value) {
|
|
const normalized = normalizeEnvValue(value).toLowerCase();
|
|
if (!/^[a-z0-9][a-z0-9._-]*$/u.test(normalized)) {
|
|
return null;
|
|
}
|
|
return allowedLifecyclePackageManagers.has(normalized) ? normalized : null;
|
|
}
|
|
|
|
function detectLifecyclePackageManagerFromExecPath(value) {
|
|
const execPath = normalizeEnvValue(value).toLowerCase();
|
|
const executableName = execPath.split(/[\\/]/u).findLast((segment) => segment.length > 0) ?? "";
|
|
const launcherName = executableName.replace(/\.(?:c?js|mjs|cmd|ps1|exe)$/u, "");
|
|
const candidates = [launcherName, launcherName.replace(/-cli$/u, "")];
|
|
|
|
for (const candidate of candidates) {
|
|
if (/^yarn(?:pkg)?-\d/u.test(candidate)) {
|
|
return "yarn";
|
|
}
|
|
|
|
const aliasedPackageManager = lifecyclePackageManagerLauncherAliases.get(candidate);
|
|
if (aliasedPackageManager) {
|
|
return aliasedPackageManager;
|
|
}
|
|
|
|
const packageManager = normalizeLifecyclePackageManagerName(candidate);
|
|
if (packageManager) {
|
|
return packageManager;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Detects the package manager running the current lifecycle script.
|
|
*/
|
|
export function detectLifecyclePackageManager(env = process.env) {
|
|
const userAgent = normalizeEnvValue(env.npm_config_user_agent);
|
|
const userAgentMatch = /^([A-Za-z0-9._-]+)\//u.exec(userAgent);
|
|
if (userAgentMatch) {
|
|
return normalizeLifecyclePackageManagerName(userAgentMatch[1]);
|
|
}
|
|
|
|
return detectLifecyclePackageManagerFromExecPath(env.npm_execpath);
|
|
}
|
|
|
|
/**
|
|
* Builds the warning shown for non-pnpm lifecycle installs.
|
|
*/
|
|
export function createPackageManagerWarningMessage(packageManager) {
|
|
if (!packageManager || packageManager === "pnpm") {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
`[openclaw] warning: detected ${packageManager} for install lifecycle.`,
|
|
"[openclaw] this repo works best with pnpm; npm-compatible installs are slower and much larger here.",
|
|
"[openclaw] prefer: corepack pnpm install",
|
|
].join("\n");
|
|
}
|
|
|
|
/**
|
|
* Emits the non-pnpm lifecycle warning when needed.
|
|
*/
|
|
export function warnIfNonPnpmLifecycle(env = process.env, warn = console.warn) {
|
|
const message = createPackageManagerWarningMessage(detectLifecyclePackageManager(env));
|
|
if (!message) {
|
|
return false;
|
|
}
|
|
warn(message);
|
|
return true;
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
if (enforceSupportedNodeRuntime() && completePackageInstallGuard()) {
|
|
warnIfNonPnpmLifecycle();
|
|
} else {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|