diff --git a/docs/cli/update.md b/docs/cli/update.md index 58c1eaa9d6c5..bfc9b4ef6993 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -294,8 +294,11 @@ third-party packages, and non-npm sources keep their existing intent. For package-manager installs, `openclaw update` resolves the target package version before invoking the package manager. npm global installs use a staged install: OpenClaw installs the new package into a temporary npm prefix, -verifies the packaged `dist` inventory there, then swaps that clean package -tree into the real global prefix. If verification fails, post-update doctor, +lets the candidate package validate the host Node version during `preinstall`, +and verifies the packaged `dist` inventory there. A packed completion guard +stays outside that inventory until `preinstall` succeeds, so package managers +that skip lifecycle scripts also stop before activation. OpenClaw then swaps the +clean package tree into the real global prefix. If verification fails, post-update doctor, plugin sync, and restart work do not run from the suspect tree. Even when the installed version already matches the target, the command refreshes the global package install, then runs plugin sync, a core-command completion diff --git a/docs/install/updating.md b/docs/install/updating.md index c95c3433a009..d48b86c7569b 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -159,9 +159,12 @@ openclaw doctor --lint --json ``` When `openclaw update` manages a global npm install, it installs the target -into a temporary npm prefix first, verifies the packaged `dist` inventory, then -swaps the clean package tree into the real global prefix — avoiding npm -overlaying a new package onto stale files from the old one. If the install +into a temporary npm prefix first. The candidate package validates the host +Node version during `preinstall`; only then does OpenClaw verify the packaged +`dist` inventory and swap the clean package tree into the real global prefix. A +packed completion guard is omitted from the expected inventory and removed only +after `preinstall` succeeds, so skipped lifecycle scripts also fail before the +swap. This avoids npm overlaying a new package onto stale files from the old one. If the install command fails, OpenClaw retries once with `--omit=optional`, which helps hosts where native optional dependencies cannot compile. diff --git a/scripts/check-openclaw-package-tarball.mjs b/scripts/check-openclaw-package-tarball.mjs index f1eb091db0e4..16419e6af80c 100644 --- a/scripts/check-openclaw-package-tarball.mjs +++ b/scripts/check-openclaw-package-tarball.mjs @@ -306,10 +306,13 @@ const entrySet = new Set(normalized); const errors = []; const warnings = []; const REQUIRED_TARBALL_ENTRIES = ["dist/control-ui/index.html", ...WORKSPACE_TEMPLATE_PACK_PATHS]; +const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard"; const REQUIRED_TARBALL_ENTRY_PREFIXES = ["dist/control-ui/assets/"]; const LEGACY_PACKAGE_ACCEPTANCE_COMPAT_MAX = { year: 2026, month: 4, day: 25 }; const LEGACY_LOCAL_BUILD_METADATA_COMPAT_MAX = { year: 2026, month: 4, day: 26 }; const LEGACY_SHRINKWRAP_COMPAT_MAX = { year: 2026, month: 5, day: 20 }; +// 2026.7.1 shipped before the guard existed. Historical inspection may still check it. +const LEGACY_INSTALL_GUARD_COMPAT_MAX = { year: 2026, month: 7, day: 1 }; const FORBIDDEN_LOCAL_BUILD_METADATA_FILES = new Set(LOCAL_BUILD_METADATA_DIST_PATHS); const LEGACY_OMITTED_PRIVATE_QA_INVENTORY_PREFIXES = [ @@ -377,6 +380,11 @@ function isLegacyShrinkwrapCompatVersion(version) { return parsed ? compareCalver(parsed, LEGACY_SHRINKWRAP_COMPAT_MAX) <= 0 : false; } +function isLegacyInstallGuardCompatVersion(version) { + const parsed = parseCalver(version); + return parsed ? compareCalver(parsed, LEGACY_INSTALL_GUARD_COMPAT_MAX) <= 0 : false; +} + function readTarEntry(entryPath) { const candidates = [ path.join(extractDir, entryPath), @@ -442,6 +450,13 @@ if (entrySet.has("package.json")) { if (entrySet.has("package-lock.json")) { errors.push("package tarball must ship npm-shrinkwrap.json, not package-lock.json"); } +if (!entrySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) { + if (isLegacyInstallGuardCompatVersion(packageVersion)) { + warnings.push("legacy package omits the preinstall completion guard"); + } else { + errors.push(`missing required tar entry ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`); + } +} if (!entrySet.has("npm-shrinkwrap.json")) { if (isLegacyShrinkwrapCompatVersion(packageVersion)) { warnings.push("legacy package omits npm-shrinkwrap.json"); @@ -511,6 +526,11 @@ if (entrySet.has("dist/postinstall-inventory.json")) { } else { const normalizedInventory = inventory.map((entry) => entry.replace(/\\/gu, "/")); const normalizedInventorySet = new Set(normalizedInventory); + if (normalizedInventorySet.has(PACKAGE_INSTALL_GUARD_RELATIVE_PATH)) { + errors.push( + `package dist inventory must omit install guard ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`, + ); + } packageDistImports = runPhase("dist import graph", () => collectPackageDistImports({ files: normalized, diff --git a/scripts/lib/package-dist-inventory.ts b/scripts/lib/package-dist-inventory.ts index b30a4aa7a37a..8720c37d9ecf 100644 --- a/scripts/lib/package-dist-inventory.ts +++ b/scripts/lib/package-dist-inventory.ts @@ -10,6 +10,8 @@ import { export { LOCAL_BUILD_METADATA_DIST_PATHS } from "./local-build-metadata-paths.mjs"; export { PACKAGE_DIST_INVENTORY_RELATIVE_PATH }; +export const PACKAGE_INSTALL_GUARD_RELATIVE_PATH = "dist/openclaw-install-guard"; + const INSTALL_STAGE_DEBRIS_DIR_PATTERN = /^\.openclaw-install-stage(?:-[^/]+)?$/iu; function normalizeRelativePath(value: string): string { @@ -116,10 +118,38 @@ async function assertNoLegacyPluginDependencyStagingDebris(packageRoot: string): ); } -export async function writePackageDistInventory(packageRoot: string): Promise { - await assertNoLegacyPluginDependencyStagingDebris(packageRoot); - const inventory = sortUniqueStrings(await collectPackageDistInventory(packageRoot)); +async function writePackageDistInventoryFile( + packageRoot: string, + entries: string[], +): Promise { + // The packed guard intentionally stays outside the inventory until preinstall removes it. + // An updater that skips lifecycle scripts rejects the staged package before activation. + const inventory = sortUniqueStrings( + entries.filter((relativePath) => relativePath !== PACKAGE_INSTALL_GUARD_RELATIVE_PATH), + ); const inventoryPath = path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH); await writeJson(inventoryPath, inventory, { trailingNewline: true }); return inventory; } + +export async function writePackageDistInventory(packageRoot: string): Promise { + await assertNoLegacyPluginDependencyStagingDebris(packageRoot); + return writePackageDistInventoryFile(packageRoot, await collectPackageDistInventory(packageRoot)); +} + +async function writePackageInstallGuardMarker(packageRoot: string): Promise { + const markerPath = path.join(packageRoot, PACKAGE_INSTALL_GUARD_RELATIVE_PATH); + await fs.mkdir(path.dirname(markerPath), { recursive: true }); + await fs.writeFile(markerPath, "OpenClaw package preinstall has not completed.\n", "utf8"); +} + +export async function writePackageDistInventoryForPublish( + packageRoot: string, + inventory?: string[], +): Promise { + await assertNoLegacyPluginDependencyStagingDebris(packageRoot); + const entries = inventory ?? (await collectPackageDistInventory(packageRoot)); + const writtenInventory = await writePackageDistInventoryFile(packageRoot, entries); + await writePackageInstallGuardMarker(packageRoot); + return writtenInventory; +} diff --git a/scripts/openclaw-prepack.ts b/scripts/openclaw-prepack.ts index dd97e795f50e..885560164e86 100644 --- a/scripts/openclaw-prepack.ts +++ b/scripts/openclaw-prepack.ts @@ -6,7 +6,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; import { basename, delimiter, join } from "node:path"; import { pathToFileURL } from "node:url"; import { formatErrorMessage } from "../src/infra/errors.ts"; -import { writePackageDistInventory } from "./lib/package-dist-inventory.ts"; +import { writePackageDistInventoryForPublish } from "./lib/package-dist-inventory.ts"; import { preparePackageChangelog } from "./package-changelog.mjs"; import { createPnpmRunnerSpawnSpec } from "./pnpm-runner.mjs"; const FULL_GIT_COMMIT_RE = /^[0-9a-f]{40}$/iu; @@ -292,7 +292,7 @@ function runBuildSmoke(): void { } async function writeDistInventory(): Promise { - await writePackageDistInventory(process.cwd()); + await writePackageDistInventoryForPublish(process.cwd()); } export async function preparePrepackArtifacts(env: NodeJS.ProcessEnv = process.env): Promise { diff --git a/scripts/preinstall-package-manager-warning.d.mts b/scripts/preinstall-package-manager-warning.d.mts index a6c5a49bad15..a0846de5e977 100644 --- a/scripts/preinstall-package-manager-warning.d.mts +++ b/scripts/preinstall-package-manager-warning.d.mts @@ -1,3 +1,29 @@ +export const PACKAGE_INSTALL_GUARD_RELATIVE_PATH: string; +/** Checks a Node version against the standalone package engine-range subset. */ +export function nodeVersionSatisfiesPackageEngine( + version: string | null, + engine: string | null, +): boolean; +/** Reads the Node runtime contract from the package being installed. */ +export function readPackageNodeEngine(packageJsonUrl?: URL): string | null; +/** Rejects installation before an unsupported runtime can replace a working release. */ +export function enforceSupportedNodeRuntime( + options?: { + version?: string | null; + bunVersion?: string | null; + engine?: string | null; + execPath?: string | null; + }, + reportError?: (...data: unknown[]) => void, +): boolean; +/** Removes the packed sentinel only after the runtime check succeeds. */ +export function completePackageInstallGuard( + options?: { + markerUrl?: URL; + remove?: (path: URL, options: { force: boolean }) => void; + }, + reportError?: (...data: unknown[]) => void, +): boolean; /** * Detects the package manager running the current lifecycle script. */ diff --git a/scripts/preinstall-package-manager-warning.mjs b/scripts/preinstall-package-manager-warning.mjs index 1026a73c6981..3f9ca2f321f7 100644 --- a/scripts/preinstall-package-manager-warning.mjs +++ b/scripts/preinstall-package-manager-warning.mjs @@ -1,4 +1,5 @@ -// Warns during install lifecycle when a package manager other than pnpm is used. +// 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"]); @@ -6,11 +7,133 @@ 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)) { @@ -85,5 +208,9 @@ export function warnIfNonPnpmLifecycle(env = process.env, warn = console.warn) { } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - warnIfNonPnpmLifecycle(); + if (enforceSupportedNodeRuntime() && completePackageInstallGuard()) { + warnIfNonPnpmLifecycle(); + } else { + process.exitCode = 1; + } } diff --git a/scripts/write-package-dist-inventory.ts b/scripts/write-package-dist-inventory.ts index aca18908c024..b273f257f51f 100644 --- a/scripts/write-package-dist-inventory.ts +++ b/scripts/write-package-dist-inventory.ts @@ -2,10 +2,10 @@ // Write Package Dist Inventory script supports OpenClaw repository automation. import { pathToFileURL } from "node:url"; -import { writePackageDistInventory } from "./lib/package-dist-inventory.ts"; +import { writePackageDistInventoryForPublish } from "./lib/package-dist-inventory.ts"; async function writeCurrentPackageDistInventory(): Promise { - await writePackageDistInventory(process.cwd()); + await writePackageDistInventoryForPublish(process.cwd()); } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index ee2e613f70c0..1921723280d6 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -6,7 +6,9 @@ import { describe, expect, it } from "vitest"; import { isLegacyPluginDependencyInstallStagePath, LOCAL_BUILD_METADATA_DIST_PATHS, + PACKAGE_INSTALL_GUARD_RELATIVE_PATH, writePackageDistInventory, + writePackageDistInventoryForPublish, } from "../../scripts/lib/package-dist-inventory.ts"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { @@ -41,6 +43,28 @@ describe("package dist inventory", () => { }); }); + it("keeps the pending install guard outside the expected inventory", async () => { + await withTempDir({ prefix: "openclaw-dist-install-guard-" }, async (packageRoot) => { + const currentFile = path.join(packageRoot, "dist", "current.js"); + await fs.mkdir(path.dirname(currentFile), { recursive: true }); + await fs.writeFile(currentFile, "export {};\n", "utf8"); + + await expect(writePackageDistInventoryForPublish(packageRoot)).resolves.toEqual([ + "dist/current.js", + ]); + await expect(collectPackageDistInventory(packageRoot)).resolves.toEqual([ + "dist/current.js", + PACKAGE_INSTALL_GUARD_RELATIVE_PATH, + ]); + await expect(readPackageDistInventoryIfPresent(packageRoot)).resolves.toEqual([ + "dist/current.js", + ]); + await expect( + fs.readFile(path.join(packageRoot, PACKAGE_INSTALL_GUARD_RELATIVE_PATH), "utf8"), + ).resolves.toContain("preinstall has not completed"); + }); + }); + it("keeps npm-omitted dist artifacts out of the inventory", async () => { await withTempDir({ prefix: "openclaw-dist-inventory-pack-" }, async (packageRoot) => { const packagedQaChannelRuntime = path.join( diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index 23c1927463d4..5db454205c75 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -4,7 +4,11 @@ import fs from "node:fs/promises"; import path from "node:path"; import { bundledDistPluginFile } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts"; +import { + PACKAGE_INSTALL_GUARD_RELATIVE_PATH, + writePackageDistInventory, + writePackageDistInventoryForPublish, +} from "../../scripts/lib/package-dist-inventory.ts"; import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { captureEnv } from "../test-utils/env.js"; @@ -809,6 +813,22 @@ describe("update global helpers", () => { }); }); + it("rejects a staged package when lifecycle scripts leave the install guard", async () => { + await withTempDir({ prefix: "openclaw-update-global-guard-" }, async (packageRoot) => { + await writeGlobalPackageJson(packageRoot, "2026.7.2"); + for (const relativePath of BUNDLED_RUNTIME_SIDECAR_PATHS) { + const absolutePath = path.join(packageRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf8"); + } + await writePackageDistInventoryForPublish(packageRoot); + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( + `unexpected packaged dist file ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`, + ); + }); + }); + it("reports bundled plugin install stages during installed dist verification", async () => { await withTempDir({ prefix: "openclaw-update-global-plugin-stage-" }, async (packageRoot) => { await writeGlobalPackageJson(packageRoot); diff --git a/test/scripts/check-openclaw-package-tarball.test.ts b/test/scripts/check-openclaw-package-tarball.test.ts index 1ea0264001d2..9b98d7448f6a 100644 --- a/test/scripts/check-openclaw-package-tarball.test.ts +++ b/test/scripts/check-openclaw-package-tarball.test.ts @@ -13,6 +13,7 @@ import { tmpdir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import { describe, expect, it } from "vitest"; import { LOCAL_BUILD_METADATA_DIST_PATHS } from "../../scripts/lib/local-build-metadata-paths.mjs"; +import { PACKAGE_INSTALL_GUARD_RELATIVE_PATH } from "../../scripts/lib/package-dist-inventory.ts"; import { WORKSPACE_TEMPLATE_PACK_PATHS } from "../../scripts/lib/workspace-bootstrap-smoke.mjs"; const CHECK_SCRIPT = "scripts/check-openclaw-package-tarball.mjs"; @@ -35,6 +36,7 @@ function withTarball( version = "0.0.0", options: { includeControlUi?: boolean; + includeInstallGuard?: boolean; includeShrinkwrap?: boolean; includeWorkspaceTemplates?: boolean; packageJson?: Record; @@ -86,7 +88,14 @@ function withTarball( "dist/control-ui/index.html": "", "dist/control-ui/assets/app.js": "console.log('ok');\n", }; - const tarFiles = { ...workspaceTemplates, ...controlUiFiles, ...files }; + const installGuardFile = + options.includeInstallGuard === false + ? {} + : { + [PACKAGE_INSTALL_GUARD_RELATIVE_PATH]: + "OpenClaw package preinstall has not completed.\n", + }; + const tarFiles = { ...workspaceTemplates, ...controlUiFiles, ...installGuardFile, ...files }; for (const [relativePath, body] of Object.entries(tarFiles)) { const filePath = join(packageRoot, relativePath); mkdirSync(dirname(filePath), { recursive: true }); @@ -220,6 +229,49 @@ describe("check-openclaw-package-tarball", () => { ); }); + it("requires an install guard omitted from the dist inventory", () => { + withTarball( + ["dist/index.js"], + { "dist/index.js": "export {};\n" }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + `missing required tar entry ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`, + ); + }, + "0.0.0", + { includeInstallGuard: false }, + ); + + withTarball( + ["dist/index.js", PACKAGE_INSTALL_GUARD_RELATIVE_PATH], + { "dist/index.js": "export {};\n" }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + `package dist inventory must omit install guard ${PACKAGE_INSTALL_GUARD_RELATIVE_PATH}`, + ); + }, + ); + + withTarball( + ["dist/index.js"], + { "dist/index.js": "export {};\n" }, + (tarball) => { + const result = spawnSync("node", [CHECK_SCRIPT, tarball], { encoding: "utf8" }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toContain("legacy package omits the preinstall completion guard"); + }, + "2026.7.1", + { includeInstallGuard: false }, + ); + }); + it("rejects stale deep plugin SDK declaration inventory entries", () => { withTarball( [FLAT_PLUGIN_SDK_DECLARATION, DEEP_PLUGIN_SDK_DECLARATION], diff --git a/test/scripts/preinstall-package-manager-warning.test.ts b/test/scripts/preinstall-package-manager-warning.test.ts index 5f7e2bb2c2f1..240e8879b18a 100644 --- a/test/scripts/preinstall-package-manager-warning.test.ts +++ b/test/scripts/preinstall-package-manager-warning.test.ts @@ -1,10 +1,25 @@ // Preinstall Package Manager Warning tests cover preinstall package manager warning script behavior. -import { describe, expect, it, vi } from "vitest"; +import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PACKAGE_INSTALL_GUARD_RELATIVE_PATH } from "../../scripts/lib/package-dist-inventory.ts"; import { + completePackageInstallGuard, createPackageManagerWarningMessage, detectLifecyclePackageManager, + enforceSupportedNodeRuntime, + nodeVersionSatisfiesPackageEngine, + PACKAGE_INSTALL_GUARD_RELATIVE_PATH as PREINSTALL_GUARD_RELATIVE_PATH, + readPackageNodeEngine, warnIfNonPnpmLifecycle, } from "../../scripts/preinstall-package-manager-warning.mjs"; +import { isSupportedNodeVersion } from "../../src/infra/runtime-guard.js"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const EXPECTED_NODE_ENGINE_RANGE = ">=22.22.3 <23 || >=24.15.0 <25 || >=25.9.0"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); function requireFirstWarning(warn: ReturnType): unknown { const [call] = warn.mock.calls; @@ -18,6 +33,125 @@ function requireFirstWarning(warn: ReturnType): unknown { return message; } +describe("install runtime enforcement", () => { + it("shares the packaged install guard path", () => { + expect(PREINSTALL_GUARD_RELATIVE_PATH).toBe(PACKAGE_INSTALL_GUARD_RELATIVE_PATH); + }); + + it("reads the canonical package engine range", () => { + expect(readPackageNodeEngine()).toBe(EXPECTED_NODE_ENGINE_RANGE); + }); + + it.each(["22.22.2", "22.22.3", "23.11.0", "24.14.1", "24.15.0", "25.8.1", "25.9.0", "26.0.0"])( + "matches the CLI runtime guard for Node %s", + (version) => { + expect(nodeVersionSatisfiesPackageEngine(version, EXPECTED_NODE_ENGINE_RANGE)).toBe( + isSupportedNodeVersion(version), + ); + }, + ); + + it.each(["24.15.0-rc.1", "25.9.1-nightly.20260714", "24.15"])( + "rejects non-release Node version %s", + (version) => { + expect(nodeVersionSatisfiesPackageEngine(version, EXPECTED_NODE_ENGINE_RANGE)).toBe(false); + }, + ); + + it("blocks unsupported Node before package replacement", () => { + const reportError = vi.fn(); + expect( + enforceSupportedNodeRuntime( + { + version: "24.14.1", + engine: EXPECTED_NODE_ENGINE_RANGE, + execPath: "/opt/node/bin/node", + }, + reportError, + ), + ).toBe(false); + expect(reportError).toHaveBeenCalledWith( + expect.stringContaining("this OpenClaw release requires Node"), + ); + expect(reportError).toHaveBeenCalledWith(expect.stringContaining("detected Node 24.14.1")); + }); + + it("allows supported Node without an error", () => { + const reportError = vi.fn(); + expect( + enforceSupportedNodeRuntime( + { + version: "24.15.0", + engine: EXPECTED_NODE_ENGINE_RANGE, + execPath: "/opt/node/bin/node", + }, + reportError, + ), + ).toBe(true); + expect(reportError).not.toHaveBeenCalled(); + }); + + it("exits nonzero when the packed entrypoint sees an unsupported runtime", () => { + const root = tempDirs.make("openclaw-preinstall-", realpathSync(tmpdir())); + const scriptsDir = join(root, "scripts"); + mkdirSync(scriptsDir); + const scriptPath = join(scriptsDir, "preinstall-package-manager-warning.mjs"); + copyFileSync( + new URL("../../scripts/preinstall-package-manager-warning.mjs", import.meta.url), + scriptPath, + ); + writeFileSync(join(root, "package.json"), JSON.stringify({ engines: { node: ">=999.0.0" } })); + + const result = spawnSync(process.execPath, [scriptPath], { encoding: "utf8" }); + expect(result.status).toBe(1); + expect(result.stderr).toContain("requires Node >=999.0.0"); + expect(result.stderr).toContain(`detected Node ${process.versions.node}`); + }); + + it("allows Bun package lifecycle scripts", () => { + const reportError = vi.fn(); + expect( + enforceSupportedNodeRuntime( + { + version: "24.14.1", + bunVersion: "1.3.0", + engine: EXPECTED_NODE_ENGINE_RANGE, + execPath: "/opt/bun/bin/bun", + }, + reportError, + ), + ).toBe(true); + expect(reportError).not.toHaveBeenCalled(); + }); + + it("removes the install guard after runtime validation", () => { + const markerUrl = new URL("file:///tmp/openclaw-install-guard"); + const remove = vi.fn(); + const reportError = vi.fn(); + + expect(completePackageInstallGuard({ markerUrl, remove }, reportError)).toBe(true); + expect(remove).toHaveBeenCalledWith(markerUrl, { force: true }); + expect(reportError).not.toHaveBeenCalled(); + }); + + it("fails installation when the install guard cannot be removed", () => { + const reportError = vi.fn(); + expect( + completePackageInstallGuard( + { + remove: () => { + throw new Error("read-only package"); + }, + }, + reportError, + ), + ).toBe(false); + expect(reportError).toHaveBeenCalledWith( + expect.stringContaining("could not complete package preinstall: read-only package"), + ); + }); +}); + describe("detectLifecyclePackageManager", () => { it("prefers npm_config_user_agent when present", () => { expect(