Files
openclaw/scripts/preinstall-package-manager-warning.mjs
Peter Steinberger 23c0a7b612 fix: preserve working installs on unsupported Node (#106994)
* fix(update): preserve installs on unsupported Node

* fix(update): verify skipped install scripts

* refactor(update): share global install preflight

* fix(update): validate Bun-installed Node runtime

* fix(update): classify install preflight failures

* refactor(update): normalize install failure reasons

* style(update): format updater policy test

* fix(update): preserve pnpm script policy safety

* fix(update): classify hosted git sources

* chore: leave release notes to release flow

* fix(release): defer package inventory helper loading

* style(release): keep inventory loader LOC-neutral

* fix(update): validate staged package lifecycle

* fix(update): match packed lifecycle contract

* fix(update): harden Bun package activation

* test(update): model packed Bun lifecycle

* style(update): avoid lifecycle name shadowing

* fix(update): remove unsafe Bun staging experiment

* fix(update): satisfy updater type checks

* fix(update): preserve packed npm package name

* fix(update): preserve checkout failure status

* fix(update): disable scripts while packing candidates

* fix(update): preflight source package engines safely

* fix(update): preserve legacy npm downgrades

* fix(update): pin npm packing to selected Node

* fix(update): stage npm activation before replacing live package

* fix(update): guard non-npm source activation

* refactor(update): isolate npm staging helpers

* fix(update): harden staged package lifecycle

* test(update): satisfy merged type gates

* fix(update): privatize runtime npm helper

* fix(ci): satisfy merged lint and type gates

* docs(changelog): defer updater note to release

* fix(update): guard package activation runtime

* fix(update): preserve installs on unsupported Node

* test(update): reject prerelease Node runtimes

* test(update): use shared temp cleanup

* fix(update): fail closed when install scripts are skipped

* fix(update): pack install guard in docker artifacts

* fix(ci): keep install guard export tooling-local
2026-07-14 23:19:48 -07:00

217 lines
6.6 KiB
JavaScript

// Enforces the package runtime contract, then warns for non-pnpm lifecycle installs.
import { readFileSync, rmSync } from "node:fs";
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+)$/u;
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;
}
}
/** 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,
} = {},
reportError = console.error,
) {
// Bun itself remains supported for dependency installation and package scripts.
if (normalizeEnvValue(bunVersion)) {
return true;
}
if (nodeVersionSatisfiesPackageEngine(version, 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 ${version ?? "unknown"} (exec: ${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;
}
}