From d68ca425bd983e1d28fb5427bb77b8796290196a Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 14:30:06 -0700 Subject: [PATCH] fix(status): report beta registry channel --- src/cli/update-cli/status.ts | 7 ++++ src/commands/status-all/format.ts | 2 + src/commands/status-json-payload.test.ts | 1 + src/commands/status.scan.bootstrap-shared.ts | 2 + src/commands/status.update.test.ts | 18 +++++++++ src/commands/status.update.ts | 23 +++++++++-- src/infra/update-channels.test.ts | 40 ++++++++++++++++++++ src/infra/update-channels.ts | 37 +++++++++++++++++- src/infra/update-check.test.ts | 13 +++++++ src/infra/update-check.ts | 27 ++++++++++++- 10 files changed, 163 insertions(+), 7 deletions(-) diff --git a/src/cli/update-cli/status.ts b/src/cli/update-cli/status.ts index d3d9365f5c5..21725d22012 100644 --- a/src/cli/update-cli/status.ts +++ b/src/cli/update-cli/status.ts @@ -6,12 +6,14 @@ import { import { readConfigFileSnapshot } from "../../config/config.js"; import { normalizeUpdateChannel, + resolveRegistryUpdateChannel, resolveUpdateChannelDisplay, } from "../../infra/update-channels.js"; import { checkUpdateStatus } from "../../infra/update-check.js"; import { defaultRuntime } from "../../runtime.js"; import { getTerminalTableWidth, renderTable } from "../../terminal/table.js"; import { theme } from "../../terminal/theme.js"; +import { VERSION } from "../../version.js"; import { parseTimeoutMsOrExit, resolveUpdateRoot, type UpdateStatusOptions } from "./shared.js"; function formatGitStatusLine(params: { @@ -47,10 +49,15 @@ export async function updateStatusCommand(opts: UpdateStatusOptions): Promise { expect(mocks.normalizeUpdateChannel).toHaveBeenCalledWith("beta"); expect(mocks.resolveUpdateChannelDisplay).toHaveBeenCalledWith({ configChannel: "beta", + currentVersion: expect.any(String), installKind: "package", gitTag: "v1.2.3", gitBranch: "main", diff --git a/src/commands/status.scan.bootstrap-shared.ts b/src/commands/status.scan.bootstrap-shared.ts index cae926589db..e7ae52e373a 100644 --- a/src/commands/status.scan.bootstrap-shared.ts +++ b/src/commands/status.scan.bootstrap-shared.ts @@ -67,6 +67,7 @@ type StatusScanCoreBootstrapParams = { timeoutMs: number; fetchGit: boolean; includeRegistry: boolean; + updateConfigChannel?: string | null; }) => Promise; getAgentLocalStatuses: (cfg: OpenClawConfig) => Promise; }; @@ -95,6 +96,7 @@ export async function createStatusScanCoreBootstrap( timeoutMs: updateTimeoutMs, fetchGit: true, includeRegistry: true, + updateConfigChannel: params.cfg.update?.channel ?? null, }); const agentStatusPromise = skipColdStartNetworkChecks ? Promise.resolve(buildColdStartAgentLocalStatuses() as TAgentStatus) diff --git a/src/commands/status.update.test.ts b/src/commands/status.update.test.ts index beaebe57993..a78f2448fcd 100644 --- a/src/commands/status.update.test.ts +++ b/src/commands/status.update.test.ts @@ -140,6 +140,24 @@ describe("formatUpdateOneLiner", () => { ); }); + it("renders beta registry tags instead of calling them npm latest", () => { + const update = buildUpdate({ + installKind: "package", + packageManager: "npm", + registry: { latestVersion: VERSION, tag: "beta" }, + deps: { + manager: "npm", + status: "ok", + lockfilePath: "package-lock.json", + markerPath: "node_modules", + }, + }); + + expect(formatUpdateOneLiner(update)).toBe( + `Update: npm · up to date · npm beta ${VERSION} · deps ok`, + ); + }); + it("renders package-manager mode with registry error", () => { const update = buildUpdate({ installKind: "package", diff --git a/src/commands/status.update.ts b/src/commands/status.update.ts index 6840c275b6a..a12130bf0c1 100644 --- a/src/commands/status.update.ts +++ b/src/commands/status.update.ts @@ -1,5 +1,6 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; +import { normalizeUpdateChannel, resolveRegistryUpdateChannel } from "../infra/update-channels.js"; import { checkUpdateStatus, compareSemverStrings, @@ -11,7 +12,9 @@ export async function getUpdateCheckResult(params: { timeoutMs: number; fetchGit: boolean; includeRegistry: boolean; + updateConfigChannel?: string | null; }): Promise { + const configChannel = normalizeUpdateChannel(params.updateConfigChannel); const root = await resolveOpenClawPackageRoot({ moduleUrl: import.meta.url, argv1: process.argv[1], @@ -22,6 +25,10 @@ export async function getUpdateCheckResult(params: { timeoutMs: params.timeoutMs, fetchGit: params.fetchGit, includeRegistry: params.includeRegistry, + registryChannel: resolveRegistryUpdateChannel({ + configChannel, + currentVersion: VERSION, + }), }); } @@ -73,22 +80,30 @@ export function formatUpdateOneLiner(update: UpdateCheckResult): string { const parts: string[] = []; const appendRegistryUpdateSummary = () => { + const registryLabel = + update.registry?.tag && update.registry.tag !== "latest" + ? `npm ${update.registry.tag}` + : "npm latest"; if (update.registry?.latestVersion) { const cmp = compareSemverStrings(VERSION, update.registry.latestVersion); if (cmp === 0) { if (update.installKind !== "git") { parts.push("up to date"); } - parts.push(`npm latest ${update.registry.latestVersion}`); + parts.push(`${registryLabel} ${update.registry.latestVersion}`); } else if (cmp != null && cmp < 0) { - parts.push(`npm update ${update.registry.latestVersion}`); + parts.push( + update.registry.tag && update.registry.tag !== "latest" + ? `${registryLabel} update ${update.registry.latestVersion}` + : `npm update ${update.registry.latestVersion}`, + ); } else { - parts.push(`npm latest ${update.registry.latestVersion} (local newer)`); + parts.push(`${registryLabel} ${update.registry.latestVersion} (local newer)`); } return; } if (update.registry?.error) { - parts.push("npm latest unknown"); + parts.push(`${registryLabel} unknown`); } }; diff --git a/src/infra/update-channels.test.ts b/src/infra/update-channels.test.ts index 02c15a506c1..bb0d0c621e9 100644 --- a/src/infra/update-channels.test.ts +++ b/src/infra/update-channels.test.ts @@ -6,6 +6,7 @@ import { isStableTag, normalizeUpdateChannel, resolveEffectiveUpdateChannel, + resolveRegistryUpdateChannel, resolveUpdateChannelDisplay, type UpdateChannel, type UpdateChannelSource, @@ -66,6 +67,15 @@ describe("resolveEffectiveUpdateChannel", () => { }, expected: { channel: "beta", source: "config" }, }, + { + name: "uses installed beta version over stale stable config", + params: { + configChannel: "stable", + currentVersion: "2026.5.2-beta.1", + installKind: "package" as const, + }, + expected: { channel: "beta", source: "installed-version" }, + }, { name: "uses beta git tag", params: { @@ -152,6 +162,11 @@ describe("formatUpdateChannelLabel", () => { params: { channel: "dev", source: "git-branch" as const }, expected: "dev (branch)", }, + { + name: "formats installed-version labels", + params: { channel: "beta", source: "installed-version" as const }, + expected: "beta (installed version)", + }, { name: "formats default labels", params: { channel: "stable", source: "default" as const }, @@ -167,6 +182,20 @@ describe("formatUpdateChannelLabel", () => { }); describe("resolveUpdateChannelDisplay", () => { + it("labels stale stable config on a beta install from the installed version", () => { + expect( + resolveUpdateChannelDisplay({ + configChannel: "stable", + currentVersion: "2026.5.2-beta.1", + installKind: "package", + }), + ).toEqual({ + channel: "beta", + source: "installed-version", + label: "beta (installed version)", + }); + }); + it("includes the derived label for git branches", () => { expect( resolveUpdateChannelDisplay({ @@ -206,3 +235,14 @@ describe("resolveUpdateChannelDisplay", () => { }); }); }); + +describe("resolveRegistryUpdateChannel", () => { + it("queries beta when the installed version is beta even if config is stale stable", () => { + expect( + resolveRegistryUpdateChannel({ + configChannel: "stable", + currentVersion: "2026.5.2-beta.1", + }), + ).toBe("beta"); + }); +}); diff --git a/src/infra/update-channels.ts b/src/infra/update-channels.ts index 3d0316257fb..fbb0a4322ff 100644 --- a/src/infra/update-channels.ts +++ b/src/infra/update-channels.ts @@ -1,7 +1,12 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; export type UpdateChannel = "stable" | "beta" | "dev"; -export type UpdateChannelSource = "config" | "git-tag" | "git-branch" | "default"; +export type UpdateChannelSource = + | "config" + | "git-tag" + | "git-branch" + | "installed-version" + | "default"; export const DEFAULT_PACKAGE_CHANNEL: UpdateChannel = "stable"; export const DEFAULT_GIT_CHANNEL: UpdateChannel = "dev"; @@ -36,11 +41,36 @@ export function isStableTag(tag: string): boolean { return !isBetaTag(tag); } +export function resolveRegistryUpdateChannel(params: { + configChannel?: UpdateChannel | null; + currentVersion?: string | null; +}): UpdateChannel { + if ( + params.currentVersion && + isBetaTag(params.currentVersion) && + params.configChannel !== "beta" && + params.configChannel !== "dev" + ) { + return "beta"; + } + return params.configChannel ?? DEFAULT_PACKAGE_CHANNEL; +} + export function resolveEffectiveUpdateChannel(params: { configChannel?: UpdateChannel | null; + currentVersion?: string | null; installKind: "git" | "package" | "unknown"; git?: { tag?: string | null; branch?: string | null }; }): { channel: UpdateChannel; source: UpdateChannelSource } { + if ( + params.currentVersion && + isBetaTag(params.currentVersion) && + params.configChannel !== "beta" && + params.configChannel !== "dev" + ) { + return { channel: "beta", source: "installed-version" }; + } + if (params.configChannel) { return { channel: params.configChannel, source: "config" }; } @@ -81,17 +111,22 @@ export function formatUpdateChannelLabel(params: { ? `${params.channel} (${params.gitBranch})` : `${params.channel} (branch)`; } + if (params.source === "installed-version") { + return "beta (installed version)"; + } return `${params.channel} (default)`; } export function resolveUpdateChannelDisplay(params: { configChannel?: UpdateChannel | null; + currentVersion?: string | null; installKind: "git" | "package" | "unknown"; gitTag?: string | null; gitBranch?: string | null; }): { channel: UpdateChannel; source: UpdateChannelSource; label: string } { const channelInfo = resolveEffectiveUpdateChannel({ configChannel: params.configChannel, + currentVersion: params.currentVersion, installKind: params.installKind, git: params.gitTag || params.gitBranch diff --git a/src/infra/update-check.test.ts b/src/infra/update-check.test.ts index 52500cc7350..ebc5bd3de3c 100644 --- a/src/infra/update-check.test.ts +++ b/src/infra/update-check.test.ts @@ -9,6 +9,7 @@ import { compareSemverStrings, fetchNpmLatestVersion, fetchNpmPackageTargetStatus, + fetchNpmRegistryVersionForChannel, fetchNpmTagVersion, formatGitInstallLabel, resolveNpmChannelTag, @@ -116,8 +117,20 @@ describe("resolveNpmChannelTag", () => { latestVersion: "1.0.4", error: undefined, }); + versionByTag.beta = "1.0.5-beta.1"; + await expect( + fetchNpmRegistryVersionForChannel({ channel: "beta", timeoutMs: 1000 }), + ).resolves.toEqual({ + latestVersion: "1.0.5-beta.1", + tag: "beta", + }); await expect(fetchNpmTagVersion({ tag: "beta", timeoutMs: 1000 })).resolves.toEqual({ tag: "beta", + version: "1.0.5-beta.1", + error: undefined, + }); + await expect(fetchNpmTagVersion({ tag: "missing", timeoutMs: 1000 })).resolves.toEqual({ + tag: "missing", version: null, error: "HTTP 404", }); diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts index 83aa3f21340..806c349023d 100644 --- a/src/infra/update-check.ts +++ b/src/infra/update-check.ts @@ -31,6 +31,7 @@ export type DepsStatus = { export type RegistryStatus = { latestVersion: string | null; + tag?: string; error?: string; }; @@ -302,6 +303,20 @@ export async function fetchNpmLatestVersion(params?: { }; } +export async function fetchNpmRegistryVersionForChannel(params: { + channel: UpdateChannel; + timeoutMs?: number; +}): Promise { + const res = await resolveNpmChannelTag({ + channel: params.channel, + timeoutMs: params.timeoutMs, + }); + return { + latestVersion: res.version, + tag: res.tag, + }; +} + export async function fetchNpmPackageTargetStatus(params: { target: string; timeoutMs?: number; @@ -380,15 +395,23 @@ export async function checkUpdateStatus(params: { timeoutMs?: number; fetchGit?: boolean; includeRegistry?: boolean; + registryChannel?: UpdateChannel; }): Promise { const timeoutMs = params.timeoutMs ?? 6000; + const fetchRegistry = () => + params.registryChannel + ? fetchNpmRegistryVersionForChannel({ + channel: params.registryChannel, + timeoutMs, + }) + : fetchNpmLatestVersion({ timeoutMs }); const root = params.root ? path.resolve(params.root) : null; if (!root) { return { root: null, installKind: "unknown", packageManager: "unknown", - registry: params.includeRegistry ? await fetchNpmLatestVersion({ timeoutMs }) : undefined, + registry: params.includeRegistry ? await fetchRegistry() : undefined, }; } @@ -396,7 +419,7 @@ export async function checkUpdateStatus(params: { const [pm, gitRoot, registry] = await Promise.all([ detectPackageManager(root), detectGitRoot(root), - params.includeRegistry ? fetchNpmLatestVersion({ timeoutMs }) : Promise.resolve(undefined), + params.includeRegistry ? fetchRegistry() : Promise.resolve(undefined), ]); const isGit = gitRoot && path.resolve(gitRoot) === path.resolve(rootRealpath);