From 66c88b4c77db43060aa5ca024a6443f24083aac8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Mar 2026 05:59:53 -0700 Subject: [PATCH] fix(update): preflight npm target node engine --- CHANGELOG.md | 2 ++ src/cli/update-cli.test.ts | 42 ++++++++++++++++++++++++- src/cli/update-cli/update-command.ts | 46 ++++++++++++++++++++++++++++ src/infra/runtime-guard.test.ts | 17 +++++++++- src/infra/runtime-guard.ts | 23 ++++++++++++++ src/infra/update-check.test.ts | 13 +++++++- src/infra/update-check.ts | 46 ++++++++++++++++++++++------ 7 files changed, 176 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5802b882b0..020e210e208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/update: preflight the target npm package `engines.node` before `openclaw update` runs a global package install, so outdated Node runtimes fail with a clear upgrade message instead of attempting an unsupported latest release. + ## 2026.3.24-beta.1 ### Breaking diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index a3079e36f2e..52f1d544b0c 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -28,6 +28,7 @@ const formatPortDiagnostics = vi.fn(); const pathExists = vi.fn(); const syncPluginsForUpdateChannel = vi.fn(); const updateNpmInstalledPlugins = vi.fn(); +const nodeVersionSatisfiesEngine = vi.fn(); const { defaultRuntime: runtimeCapture, resetRuntimeCapture } = createCliRuntimeCapture(); vi.mock("@clack/prompts", () => ({ @@ -57,11 +58,20 @@ vi.mock("../infra/update-check.js", async (importOriginal) => { return { ...actual, checkUpdateStatus: vi.fn(), + fetchNpmPackageTargetStatus: vi.fn(), fetchNpmTagVersion: vi.fn(), resolveNpmChannelTag: vi.fn(), }; }); +vi.mock("../infra/runtime-guard.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + nodeVersionSatisfiesEngine, + }; +}); + vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { @@ -140,7 +150,7 @@ vi.mock("../runtime.js", () => ({ const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); const { readConfigFileSnapshot, writeConfigFile } = await import("../config/config.js"); -const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } = +const { checkUpdateStatus, fetchNpmPackageTargetStatus, fetchNpmTagVersion, resolveNpmChannelTag } = await import("../infra/update-check.js"); const { runCommandWithTimeout } = await import("../process/exec.js"); const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js"); @@ -298,10 +308,16 @@ describe("update-cli", () => { tag: "latest", version: "9999.0.0", }); + vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ + target: "latest", + version: "9999.0.0", + nodeEngine: ">=22.16.0", + }); vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "9999.0.0", }); + nodeVersionSatisfiesEngine.mockReturnValue(true); vi.mocked(checkUpdateStatus).mockResolvedValue({ root: "/test/path", installKind: "git", @@ -567,6 +583,30 @@ describe("update-cli", () => { ); }); + it("blocks package updates when the target requires a newer Node runtime", async () => { + mockPackageInstallStatus(createCaseDir("openclaw-update")); + vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({ + target: "latest", + version: "2026.3.23-2", + nodeEngine: ">=22.16.0", + }); + nodeVersionSatisfiesEngine.mockReturnValue(false); + + await updateCommand({ yes: true }); + + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(runCommandWithTimeout).not.toHaveBeenCalledWith( + ["npm", "i", "-g", "openclaw@latest", "--no-fund", "--no-audit", "--loglevel=error"], + expect.any(Object), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + const errors = vi.mocked(defaultRuntime.error).mock.calls.map((call) => String(call[0])); + expect(errors.join("\n")).toContain("Node "); + expect(errors.join("\n")).toContain( + "Bare `npm i -g openclaw` can silently install an older compatible release.", + ); + }); + it("resolves package install specs from tags and env overrides", async () => { for (const scenario of [ { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index eff1fbb06f8..c51b60ac006 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -12,6 +12,7 @@ import { } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { resolveGatewayService } from "../../daemon/service.js"; +import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js"; import { channelToNpmTag, DEFAULT_GIT_CHANNEL, @@ -20,6 +21,7 @@ import { } from "../../infra/update-channels.js"; import { compareSemverStrings, + fetchNpmPackageTargetStatus, resolveNpmChannelTag, checkUpdateStatus, } from "../../infra/update-check.js"; @@ -133,6 +135,38 @@ function tryResolveInvocationCwd(): string | undefined { } } +async function resolvePackageRuntimePreflightError(params: { + tag: string; + timeoutMs?: number; +}): Promise { + if (!canResolveRegistryVersionForPackageTarget(params.tag)) { + return null; + } + const target = params.tag.trim(); + if (!target) { + return null; + } + const status = await fetchNpmPackageTargetStatus({ + target, + timeoutMs: params.timeoutMs, + }); + if (status.error) { + return null; + } + const satisfies = nodeVersionSatisfiesEngine(process.versions.node ?? null, status.nodeEngine); + if (satisfies !== false) { + return null; + } + const targetLabel = status.version ?? target; + return [ + `Node ${process.versions.node ?? "unknown"} is too old for openclaw@${targetLabel}.`, + `The requested package requires ${status.nodeEngine}.`, + "Upgrade Node to 22.16+ or Node 24, then rerun `openclaw update`.", + "Bare `npm i -g openclaw` can silently install an older compatible release.", + "After upgrading Node, use `npm i -g openclaw@latest`.", + ].join("\n"); +} + function resolveServiceRefreshEnv( env: NodeJS.ProcessEnv, invocationCwd?: string, @@ -881,6 +915,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { ); } + if (updateInstallKind === "package") { + const runtimePreflightError = await resolvePackageRuntimePreflightError({ + tag, + timeoutMs, + }); + if (runtimePreflightError) { + defaultRuntime.error(runtimePreflightError); + defaultRuntime.exit(1); + return; + } + } + const showProgress = !opts.json && process.stdout.isTTY; if (!opts.json) { defaultRuntime.log(theme.heading("Updating OpenClaw...")); diff --git a/src/infra/runtime-guard.test.ts b/src/infra/runtime-guard.test.ts index ca1080b84bc..99d4e938e81 100644 --- a/src/infra/runtime-guard.test.ts +++ b/src/infra/runtime-guard.test.ts @@ -3,8 +3,10 @@ import { assertSupportedRuntime, detectRuntime, isAtLeast, - parseSemver, isSupportedNodeVersion, + nodeVersionSatisfiesEngine, + parseMinimumNodeEngine, + parseSemver, type RuntimeDetails, runtimeSatisfies, } from "./runtime-guard.js"; @@ -56,6 +58,19 @@ describe("runtime-guard", () => { expect(isSupportedNodeVersion(null)).toBe(false); }); + it("parses simple minimum node engine ranges", () => { + expect(parseMinimumNodeEngine(">=22.16.0")).toEqual({ major: 22, minor: 16, patch: 0 }); + expect(parseMinimumNodeEngine(" >=v24.0.0 ")).toEqual({ major: 24, minor: 0, patch: 0 }); + expect(parseMinimumNodeEngine("^22.16.0")).toBeNull(); + }); + + it("checks node versions against simple engine ranges", () => { + expect(nodeVersionSatisfiesEngine("22.16.0", ">=22.16.0")).toBe(true); + expect(nodeVersionSatisfiesEngine("22.15.9", ">=22.16.0")).toBe(false); + expect(nodeVersionSatisfiesEngine("24.0.0", ">=22.16.0")).toBe(true); + expect(nodeVersionSatisfiesEngine("22.16.0", "^22.16.0")).toBeNull(); + }); + it("throws via exit when runtime is too old", () => { const runtime = { log: vi.fn(), diff --git a/src/infra/runtime-guard.ts b/src/infra/runtime-guard.ts index 51c187a9e31..8397cac95ed 100644 --- a/src/infra/runtime-guard.ts +++ b/src/infra/runtime-guard.ts @@ -10,6 +10,7 @@ type Semver = { }; const MIN_NODE: Semver = { major: 22, minor: 16, patch: 0 }; +const MINIMUM_ENGINE_RE = /^\s*>=\s*v?(\d+\.\d+\.\d+)\s*$/i; export type RuntimeDetails = { kind: RuntimeKind; @@ -73,6 +74,28 @@ export function isSupportedNodeVersion(version: string | null): boolean { return isAtLeast(parseSemver(version), MIN_NODE); } +export function parseMinimumNodeEngine(engine: string | null): Semver | null { + if (!engine) { + return null; + } + const match = engine.match(MINIMUM_ENGINE_RE); + if (!match) { + return null; + } + return parseSemver(match[1] ?? null); +} + +export function nodeVersionSatisfiesEngine( + version: string | null, + engine: string | null, +): boolean | null { + const minimum = parseMinimumNodeEngine(engine); + if (!minimum) { + return null; + } + return isAtLeast(parseSemver(version), minimum); +} + export function assertSupportedRuntime( runtime: RuntimeEnv = defaultRuntime, details: RuntimeDetails = detectRuntime(), diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index 610ca1957ec..db36c4d1dbb 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -7,6 +7,7 @@ import { checkUpdateStatus, compareSemverStrings, fetchNpmLatestVersion, + fetchNpmPackageTargetStatus, fetchNpmTagVersion, formatGitInstallLabel, resolveNpmChannelTag, @@ -47,7 +48,10 @@ describe("resolveNpmChannelTag", () => { return { ok: version != null, status: version != null ? 200 : 404, - json: async () => ({ version }), + json: async () => ({ + version, + engines: version != null ? { node: ">=22.16.0" } : undefined, + }), } as Response; }), ); @@ -96,6 +100,13 @@ describe("resolveNpmChannelTag", () => { it("exposes tag fetch helpers for success and http failures", async () => { versionByTag.latest = "1.0.4"; + await expect( + fetchNpmPackageTargetStatus({ target: "latest", timeoutMs: 1000 }), + ).resolves.toEqual({ + target: "latest", + version: "1.0.4", + nodeEngine: ">=22.16.0", + }); await expect(fetchNpmTagVersion({ tag: "latest", timeoutMs: 1000 })).resolves.toEqual({ tag: "latest", version: "1.0.4", diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 1ea07fd4c47..fd131aaa33e 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -40,6 +40,13 @@ export type NpmTagStatus = { error?: string; }; +export type NpmPackageTargetStatus = { + target: string; + version: string | null; + nodeEngine: string | null; + error?: string; +}; + export type UpdateCheckResult = { root: string | null; installKind: "git" | "package" | "unknown"; @@ -295,29 +302,48 @@ export async function fetchNpmLatestVersion(params?: { }; } -export async function fetchNpmTagVersion(params: { - tag: string; +export async function fetchNpmPackageTargetStatus(params: { + target: string; timeoutMs?: number; -}): Promise { - const timeoutMs = params?.timeoutMs ?? 3500; - const tag = params.tag; +}): Promise { + const timeoutMs = params.timeoutMs ?? 3500; + const target = params.target; try { const res = await fetchWithTimeout( - `https://registry.npmjs.org/openclaw/${encodeURIComponent(tag)}`, + `https://registry.npmjs.org/openclaw/${encodeURIComponent(target)}`, {}, Math.max(250, timeoutMs), ); if (!res.ok) { - return { tag, version: null, error: `HTTP ${res.status}` }; + return { target, version: null, nodeEngine: null, error: `HTTP ${res.status}` }; } - const json = (await res.json()) as { version?: unknown }; + const json = (await res.json()) as { + version?: unknown; + engines?: { node?: unknown }; + }; const version = typeof json?.version === "string" ? json.version : null; - return { tag, version }; + const nodeEngine = typeof json?.engines?.node === "string" ? json.engines.node : null; + return { target, version, nodeEngine }; } catch (err) { - return { tag, version: null, error: String(err) }; + return { target, version: null, nodeEngine: null, error: String(err) }; } } +export async function fetchNpmTagVersion(params: { + tag: string; + timeoutMs?: number; +}): Promise { + const res = await fetchNpmPackageTargetStatus({ + target: params.tag, + timeoutMs: params.timeoutMs, + }); + return { + tag: params.tag, + version: res.version, + error: res.error, + }; +} + export async function resolveNpmChannelTag(params: { channel: UpdateChannel; timeoutMs?: number;