diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index d3e4eb7b3191..9001103214a3 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -3728,6 +3728,12 @@ describe("update-cli", () => { "i", ), ); + expect(vi.mocked(resolveNpmChannelTag)).toHaveBeenCalledWith( + expect.objectContaining({ command: installCommand }), + ); + expect(vi.mocked(fetchNpmPackageTargetStatus)).toHaveBeenCalledWith( + expect.objectContaining({ command: installCommand }), + ); const installOptions = requiredInstallCall[1] as { timeoutMs?: number }; expect(typeof installOptions.timeoutMs).toBe("number"); }); @@ -6397,7 +6403,9 @@ describe("update-cli", () => { expect( vi .mocked(runCommandWithTimeout) - .mock.calls.some((call) => Array.isArray(call[0]) && call[0][0] === "npm"), + .mock.calls.some( + (call) => Array.isArray(call[0]) && call[0][0] === "npm" && call[0][1] === "i", + ), ).toBe(shouldRunPackageUpdate); }); diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 55676f0c2a5f..7e5886c84c1f 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -98,7 +98,7 @@ export { readPackageName, readPackageVersion }; export async function resolveTargetVersion( tag: string, timeoutMs?: number, - options: { spec?: string; cwd?: string; env?: NodeJS.ProcessEnv } = {}, + options: { spec?: string; command?: string; cwd?: string; env?: NodeJS.ProcessEnv } = {}, ): Promise { if (!canResolveRegistryVersionForPackageTarget(tag)) { return null; @@ -111,6 +111,7 @@ export async function resolveTargetVersion( tag, timeoutMs, spec: options.spec, + command: options.command, cwd: options.cwd, env: options.env, }); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index f53b949cb6a7..56b5955a5f0c 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -91,6 +91,7 @@ import { resolveGlobalInstallTarget, resolveGlobalInstallSpec, resolvePnpmGlobalDirFromGlobalRoot, + type ResolvedGlobalInstallTarget, } from "../../infra/update-global.js"; import { cleanupStaleManagedServiceUpdateHandoffs } from "../../infra/update-managed-service-handoff-cleanup.js"; import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; @@ -1046,6 +1047,7 @@ async function resolvePackageRuntimePreflightError(params: { timeoutMs?: number; nodeRunner?: string; spec?: string; + command?: string; cwd?: string; env?: NodeJS.ProcessEnv; }): Promise { @@ -1063,6 +1065,7 @@ async function resolvePackageRuntimePreflightError(params: { target, spec: params.spec, timeoutMs: params.timeoutMs, + command: params.command, cwd: params.cwd, env: params.env, }); @@ -1513,21 +1516,25 @@ async function runPackageInstallUpdate(params: { honorPackageRoot?: boolean; nodeRunner?: string; installEnv?: NodeJS.ProcessEnv; + installTarget?: ResolvedGlobalInstallTarget; }): Promise { - const manager = await resolveGlobalManager({ - root: params.root, - installKind: params.installKind, - timeoutMs: params.timeoutMs, - }); const installEnv = params.installEnv ?? (await createGlobalInstallEnv()); const runCommand = createGlobalCommandRunner(); - const installTarget = await resolveGlobalInstallTarget({ - manager, - runCommand, - timeoutMs: params.timeoutMs, - pkgRoot: params.root, - honorPackageRoot: params.honorPackageRoot === true, - }); + let installTarget = params.installTarget; + if (!installTarget) { + const manager = await resolveGlobalManager({ + root: params.root, + installKind: params.installKind, + timeoutMs: params.timeoutMs, + }); + installTarget = await resolveGlobalInstallTarget({ + manager, + runCommand, + timeoutMs: params.timeoutMs, + pkgRoot: params.root, + honorPackageRoot: params.honorPackageRoot === true, + }); + } const pkgRoot = installTarget.packageRoot; const packageName = (pkgRoot ? await readPackageName(pkgRoot) : await readPackageName(params.root)) ?? @@ -1630,7 +1637,7 @@ async function runPackageInstallUpdate(params: { return { status: packageUpdate.failedStep ? "error" : "ok", - mode: manager, + mode: installTarget.manager, root: packageUpdate.verifiedPackageRoot ?? params.root, reason: packageUpdate.failedStep ? packageUpdate.failedStep.name : undefined, before: { version: beforeVersion }, @@ -3298,6 +3305,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise let packageInstallSpec: string | null = null; let packageInstallEnv: NodeJS.ProcessEnv | undefined; let packageInstallCwd: string | undefined; + let packageInstallTarget: ResolvedGlobalInstallTarget | undefined; let packageAlreadyCurrent = false; let managedServiceRootRedirect: ManagedServiceRootRedirect | null = null; // Resolved independently of the root redirect so it covers the common case @@ -3354,6 +3362,23 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise if (updateInstallKind !== "git") { packageInstallEnv = await createGlobalInstallEnv(); packageInstallCwd = tryResolveInvocationCwd(); + if (updateInstallKind === "package") { + const manager = await resolveGlobalManager({ + root, + installKind, + timeoutMs: updateStepTimeoutMs, + }); + packageInstallTarget = await resolveGlobalInstallTarget({ + manager, + runCommand: createGlobalCommandRunner(), + timeoutMs: updateStepTimeoutMs, + pkgRoot: root, + honorPackageRoot: + managedServiceRootRedirect !== null || managedServiceNodeRunner !== undefined, + }); + } + const npmMetadataCommand = + packageInstallTarget?.manager === "npm" ? packageInstallTarget.command : undefined; currentVersion = switchToPackage ? null : await readPackageVersion(root); if (explicitTag) { const explicitSpec = resolveGlobalInstallSpec({ @@ -3363,6 +3388,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise }); targetVersion = await resolveTargetVersion(tag, timeoutMs, { spec: explicitSpec, + command: npmMetadataCommand, cwd: packageInstallCwd, env: packageInstallEnv, }); @@ -3370,6 +3396,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise targetVersion = await resolveNpmChannelTag({ channel, timeoutMs, + command: npmMetadataCommand, cwd: packageInstallCwd, env: packageInstallEnv, }).then((resolved) => { @@ -3516,6 +3543,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise spec: packageInstallSpec ?? undefined, timeoutMs, nodeRunner: managedServiceNodeRunner, + command: packageInstallTarget?.manager === "npm" ? packageInstallTarget.command : undefined, cwd: packageInstallCwd, env: packageInstallEnv, }); @@ -3623,6 +3651,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise managedServiceRootRedirect !== null || managedServiceNodeRunner !== undefined, nodeRunner: managedServiceNodeRunner, installEnv: packageInstallEnv, + installTarget: packageInstallTarget, }) : await runGitUpdate({ root, diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index 23150747a483..113b732af9a4 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import http from "node:http"; import type { AddressInfo } from "node:net"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runCommandWithTimeout } from "../process/exec.js"; import { withTempDir } from "../test-helpers/temp-dir.js"; import { @@ -74,6 +74,10 @@ describe("resolveNpmChannelTag", () => { runCommand = runCommandMock as unknown as NpmMetadataCommandRunner; }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("delegates package target metadata to npm view with global config scope", async () => { versionByTag.latest = "1.0.4"; const env = { ...process.env, NPM_CONFIG_USERCONFIG: "/tmp/openclaw-user-npmrc" }; @@ -82,6 +86,7 @@ describe("resolveNpmChannelTag", () => { fetchNpmPackageTargetStatus({ target: "latest", spec: "openclaw@latest", + command: "/opt/openclaw/node/bin/npm", timeoutMs: 1000, cwd: "/tmp/openclaw-project", env, @@ -94,7 +99,15 @@ describe("resolveNpmChannelTag", () => { }); expect(runCommandMock).toHaveBeenCalledWith( - ["npm", "view", "openclaw@latest", "version", "engines.node", "--json", "--global"], + [ + "/opt/openclaw/node/bin/npm", + "view", + "openclaw@latest", + "version", + "engines.node", + "--json", + "--global", + ], expect.objectContaining({ timeoutMs: 1000, cwd: "/tmp/openclaw-project", @@ -153,6 +166,7 @@ describe("resolveNpmChannelTag", () => { await expect( fetchNpmPackageTargetStatus({ target: "latest", + command: "npm", timeoutMs: 10_000, cwd: project, env: { @@ -180,6 +194,31 @@ describe("resolveNpmChannelTag", () => { }); }); + it("uses the public registry when no npm command is available", async () => { + const fetch = vi.fn(async () => { + return { + ok: true, + json: async () => ({ + version: "2026.6.8", + engines: { node: ">=22.19.0" }, + }), + } as Response; + }); + vi.stubGlobal("fetch", fetch); + + await expect( + fetchNpmPackageTargetStatus({ target: "latest", timeoutMs: 1000 }), + ).resolves.toEqual({ + target: "latest", + version: "2026.6.8", + nodeEngine: ">=22.19.0", + }); + expect(fetch).toHaveBeenCalledWith( + "https://registry.npmjs.org/openclaw/latest", + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + it("falls back to latest when beta is older", async () => { versionByTag.beta = "1.0.0-beta.1"; versionByTag.latest = "1.0.1-1"; diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 22601ea772d2..66d9fa714b00 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; +import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { detectPackageManager as detectPackageManagerImpl } from "./detect-package-manager.js"; import { compareOpenClawReleaseVersions } from "./npm-registry-spec.js"; import { compareComparableSemver, parseComparableSemver } from "./semver-compare.js"; @@ -105,6 +106,38 @@ function packageTargetSpec(params: { target: string; spec?: string }): string { return spec || `openclaw@${params.target.trim() || "latest"}`; } +async function fetchPublicNpmPackageTargetStatus(params: { + target: string; + timeoutMs: number; +}): Promise { + try { + const res = await fetchWithTimeout( + `https://registry.npmjs.org/openclaw/${encodeURIComponent(params.target)}`, + {}, + Math.max(250, params.timeoutMs), + ); + if (!res.ok) { + return { + target: params.target, + version: null, + nodeEngine: null, + error: `HTTP ${res.status}`, + }; + } + const json = (await res.json()) as { + version?: unknown; + engines?: { node?: unknown }; + }; + return { + target: params.target, + version: toOptionalTrimmedString(json.version), + nodeEngine: toOptionalTrimmedString(json.engines?.node), + }; + } catch (err) { + return { target: params.target, version: null, nodeEngine: null, error: String(err) }; + } +} + export function formatGitInstallLabel(update: UpdateCheckResult): string | null { if (update.installKind !== "git") { return null; @@ -387,17 +420,21 @@ export async function fetchNpmPackageTargetStatus(params: { target: string; timeoutMs?: number; spec?: string; + command?: string; cwd?: string; env?: NodeJS.ProcessEnv; runCommand?: NpmMetadataCommandRunner; }): Promise { const timeoutMs = params.timeoutMs ?? 3500; const target = params.target; + if (!params.command && !params.runCommand) { + return await fetchPublicNpmPackageTargetStatus({ target, timeoutMs }); + } const runCommand = params.runCommand ?? runCommandWithTimeout; try { const res = await runCommand( [ - "npm", + params.command ?? "npm", "view", packageTargetSpec({ target, spec: params.spec }), "version", @@ -431,6 +468,7 @@ export async function fetchNpmTagVersion(params: { tag: string; timeoutMs?: number; spec?: string; + command?: string; cwd?: string; env?: NodeJS.ProcessEnv; runCommand?: NpmMetadataCommandRunner; @@ -439,6 +477,7 @@ export async function fetchNpmTagVersion(params: { target: params.tag, timeoutMs: params.timeoutMs, spec: params.spec, + command: params.command, cwd: params.cwd, env: params.env, runCommand: params.runCommand, @@ -453,6 +492,7 @@ export async function fetchNpmTagVersion(params: { export async function resolveNpmChannelTag(params: { channel: UpdateChannel; timeoutMs?: number; + command?: string; cwd?: string; env?: NodeJS.ProcessEnv; runCommand?: NpmMetadataCommandRunner; @@ -461,6 +501,7 @@ export async function resolveNpmChannelTag(params: { const channelStatus = await fetchNpmTagVersion({ tag: channelTag, timeoutMs: params.timeoutMs, + command: params.command, cwd: params.cwd, env: params.env, runCommand: params.runCommand, @@ -472,6 +513,7 @@ export async function resolveNpmChannelTag(params: { const latestStatus = await fetchNpmTagVersion({ tag: "latest", timeoutMs: params.timeoutMs, + command: params.command, cwd: params.cwd, env: params.env, runCommand: params.runCommand,