Files
openclaw/scripts/preinstall-package-manager-warning.mjs
Peter SteinbergerandGitHub 666a907316 fix: preserve updates across pnpm and Bun lifecycle checks (#108090)
* fix(update): validate lifecycle runtimes

* fix(update): verify Bun lifecycle path

* fix(update): reject drive-relative Node paths

* test(update): align lifecycle argv expectations
2026-07-15 00:26:41 -07:00

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;
}
}