fix(status): report beta registry channel

This commit is contained in:
Vincent Koc
2026-05-02 14:30:06 -07:00
parent cb9a97f211
commit d68ca425bd
10 changed files with 163 additions and 7 deletions

View File

@@ -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<vo
timeoutMs: timeoutMs ?? 3500,
fetchGit: true,
includeRegistry: true,
registryChannel: resolveRegistryUpdateChannel({
configChannel,
currentVersion: VERSION,
}),
});
const channelInfo = resolveUpdateChannelDisplay({
configChannel,
currentVersion: VERSION,
installKind: update.installKind,
gitTag: update.git?.tag ?? null,
gitBranch: update.git?.branch ?? null,

View File

@@ -8,6 +8,7 @@ import {
} from "../../infra/update-channels.js";
import { formatGitInstallLabel, type UpdateCheckResult } from "../../infra/update-check.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { VERSION } from "../../version.js";
import { formatUpdateOneLiner, resolveUpdateAvailability } from "../status.update.js";
export { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
@@ -68,6 +69,7 @@ export function resolveStatusUpdateChannelInfo(params: {
}) {
return resolveUpdateChannelDisplay({
configChannel: normalizeUpdateChannel(params.updateConfigChannel),
currentVersion: VERSION,
installKind: params.update.installKind ?? "unknown",
gitTag: params.update.git?.tag ?? null,
gitBranch: params.update.git?.branch ?? null,

View File

@@ -41,6 +41,7 @@ describe("status-json-payload", () => {
expect(mocks.normalizeUpdateChannel).toHaveBeenCalledWith("beta");
expect(mocks.resolveUpdateChannelDisplay).toHaveBeenCalledWith({
configChannel: "beta",
currentVersion: expect.any(String),
installKind: "package",
gitTag: "v1.2.3",
gitBranch: "main",

View File

@@ -67,6 +67,7 @@ type StatusScanCoreBootstrapParams<TAgentStatus> = {
timeoutMs: number;
fetchGit: boolean;
includeRegistry: boolean;
updateConfigChannel?: string | null;
}) => Promise<UpdateCheckResult>;
getAgentLocalStatuses: (cfg: OpenClawConfig) => Promise<TAgentStatus>;
};
@@ -95,6 +96,7 @@ export async function createStatusScanCoreBootstrap<TAgentStatus>(
timeoutMs: updateTimeoutMs,
fetchGit: true,
includeRegistry: true,
updateConfigChannel: params.cfg.update?.channel ?? null,
});
const agentStatusPromise = skipColdStartNetworkChecks
? Promise.resolve(buildColdStartAgentLocalStatuses() as TAgentStatus)

View File

@@ -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",

View File

@@ -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<UpdateCheckResult> {
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`);
}
};

View File

@@ -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");
});
});

View File

@@ -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

View File

@@ -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",
});

View File

@@ -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<RegistryStatus> {
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<UpdateCheckResult> {
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);