fix(update): align metadata with install npm

This commit is contained in:
Vincent Koc
2026-06-17 09:06:28 +08:00
parent 8666e172cf
commit ae8bbb0303
5 changed files with 137 additions and 18 deletions
+9 -1
View File
@@ -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);
});
+2 -1
View File
@@ -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<string | null> {
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,
});
+42 -13
View File
@@ -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<string | null> {
@@ -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<UpdateRunResult> {
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<void>
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<void>
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<void>
});
targetVersion = await resolveTargetVersion(tag, timeoutMs, {
spec: explicitSpec,
command: npmMetadataCommand,
cwd: packageInstallCwd,
env: packageInstallEnv,
});
@@ -3370,6 +3396,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
targetVersion = await resolveNpmChannelTag({
channel,
timeoutMs,
command: npmMetadataCommand,
cwd: packageInstallCwd,
env: packageInstallEnv,
}).then((resolved) => {
@@ -3516,6 +3543,7 @@ async function updateCommandInternal(opts: UpdateCommandOptions): Promise<void>
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<void>
managedServiceRootRedirect !== null || managedServiceNodeRunner !== undefined,
nodeRunner: managedServiceNodeRunner,
installEnv: packageInstallEnv,
installTarget: packageInstallTarget,
})
: await runGitUpdate({
root,
+41 -2
View File
@@ -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";
+43 -1
View File
@@ -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<NpmPackageTargetStatus> {
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<NpmPackageTargetStatus> {
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,