fix(cli,release): repair nightly updates and snapshots

This commit is contained in:
i-trytoohard 2026-05-20 20:56:56 +05:30
parent 19d2a34f6a
commit 035b07109b
7 changed files with 129 additions and 31 deletions

View File

@ -37,7 +37,7 @@
]
],
"snapshot": {
"useCalculatedVersionForSnapshots": true,
"useCalculatedVersion": true,
"prereleaseTemplate": "{tag}-{commit}"
},
"access": "public",

View File

@ -84,6 +84,7 @@ import {
maybeShowUpdateNotice,
scheduleBackgroundRefresh,
isVersionOutdated,
isOutdatedForChannel,
resolveUpdateChannel,
resolveInstallMethodOverride,
isManualOnlyInstall,
@ -115,6 +116,42 @@ describe("update-check", () => {
mockGlobalConfig.value = null;
});
// -----------------------------------------------------------------------
// isOutdatedForChannel
// -----------------------------------------------------------------------
describe("isOutdatedForChannel", () => {
it("treats any nightly dist-tag identity change as an update in both lexical directions", () => {
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-def", "nightly")).toBe(true);
expect(isOutdatedForChannel("0.0.0-nightly-def", "0.0.0-nightly-abc", "nightly")).toBe(true);
expect(
isOutdatedForChannel("0.0.0-nightly-f00d123", "0.0.0-nightly-0dead01", "nightly"),
).toBe(true);
});
it("does not update nightly when the exact dist-tag version is already installed", () => {
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-abc", "nightly")).toBe(false);
});
it("uses semver for stable-to-nightly channel switches", () => {
expect(isOutdatedForChannel("0.7.0", "0.0.0-nightly-abc", "nightly")).toBe(false);
expect(isOutdatedForChannel("0.7.0", "0.8.0-nightly-abc", "nightly")).toBe(true);
expect(isOutdatedForChannel("0.8.0", "0.8.0-nightly-abc", "nightly")).toBe(false);
});
it("treats nightly-to-stable fallback on the nightly channel as an update when the dist-tag differs", () => {
// `fetchLatestVersion("nightly")` can fall back to `latest` if the
// nightly dist-tag is absent; prerelease-to-stable is a normal
// semver upgrade.
expect(isOutdatedForChannel("0.0.0-nightly-abc", "0.8.0", "nightly")).toBe(true);
});
it("keeps stable comparisons numeric instead of lexical", () => {
expect(isOutdatedForChannel("0.9.0", "0.10.0", "stable")).toBe(true);
expect(isOutdatedForChannel("0.7.0", "0.8.0", "stable")).toBe(true);
});
});
// -----------------------------------------------------------------------
// isVersionOutdated
// -----------------------------------------------------------------------
@ -1186,24 +1223,14 @@ describe("update-check", () => {
describe("getUpdateCommand — channel-aware (Section B)", () => {
it("uses @nightly tag for nightly channel", () => {
expect(getUpdateCommand("npm-global", "nightly")).toBe(
"npm install -g @aoagents/ao@nightly",
);
expect(getUpdateCommand("pnpm-global", "nightly")).toBe(
"pnpm add -g @aoagents/ao@nightly",
);
expect(getUpdateCommand("bun-global", "nightly")).toBe(
"bun add -g @aoagents/ao@nightly",
);
expect(getUpdateCommand("npm-global", "nightly")).toBe("npm install -g @aoagents/ao@nightly");
expect(getUpdateCommand("pnpm-global", "nightly")).toBe("pnpm add -g @aoagents/ao@nightly");
expect(getUpdateCommand("bun-global", "nightly")).toBe("bun add -g @aoagents/ao@nightly");
});
it("uses @latest tag for stable + manual channels", () => {
expect(getUpdateCommand("npm-global", "stable")).toBe(
"npm install -g @aoagents/ao@latest",
);
expect(getUpdateCommand("npm-global", "manual")).toBe(
"npm install -g @aoagents/ao@latest",
);
expect(getUpdateCommand("npm-global", "stable")).toBe("npm install -g @aoagents/ao@latest");
expect(getUpdateCommand("npm-global", "manual")).toBe("npm install -g @aoagents/ao@latest");
});
it("returns the brew upgrade notice for homebrew installs", () => {

View File

@ -16,6 +16,7 @@ import { promisify } from "node:util";
import {
getInstalledAoVersion,
getUpdateCheckCachePath,
isVersionOutdatedForChannel as coreIsVersionOutdatedForChannel,
isVersionOutdated as coreIsVersionOutdated,
loadGlobalConfig,
type UpdateChannel,
@ -181,8 +182,7 @@ export function classifyInstallPath(resolvedPath: string): InstallMethod {
if (isPnpmGlobal) return "pnpm-global";
const isNpmGlobal =
resolvedPath.includes("/lib/node_modules/") ||
resolvedPath.includes("\\lib\\node_modules\\");
resolvedPath.includes("/lib/node_modules/") || resolvedPath.includes("\\lib\\node_modules\\");
if (isNpmGlobal) return "npm-global";
return "unknown";
@ -256,10 +256,7 @@ export function getGitUpdateRef(): string {
* a notice so the user runs it themselves auto-running `npm install -g`
* inside a brew prefix overwrites brew's symlinks.
*/
export function getUpdateCommand(
method: InstallMethod,
channel: UpdateChannel = "stable",
): string {
export function getUpdateCommand(method: InstallMethod, channel: UpdateChannel = "stable"): string {
// "manual" channel maps to "stable" for the install command — the channel
// affects when we check, not which tag manual installers should pick.
const tag = channel === "nightly" ? "nightly" : "latest";
@ -284,6 +281,14 @@ export function isManualOnlyInstall(method: InstallMethod): boolean {
return method === "homebrew";
}
export function isOutdatedForChannel(
currentVersion: string,
latestVersion: string,
channel: UpdateChannel,
): boolean {
return coreIsVersionOutdatedForChannel(currentVersion, latestVersion, channel);
}
// ---------------------------------------------------------------------------
// Cache
// ---------------------------------------------------------------------------
@ -493,7 +498,7 @@ export async function checkForUpdate(opts?: {
isOutdated:
cached.installMethod === "git"
? cached.isOutdated === true
: isVersionOutdated(currentVersion, cached.latestVersion),
: isOutdatedForChannel(currentVersion, cached.latestVersion, channel),
installMethod,
recommendedCommand,
checkedAt: cached.checkedAt,
@ -540,14 +545,16 @@ export async function checkForUpdate(opts?: {
currentVersionAtCheck: currentVersion,
installMethod,
channel,
isOutdated: isVersionOutdated(currentVersion, latestVersion),
isOutdated: isOutdatedForChannel(currentVersion, latestVersion, channel),
});
}
return {
currentVersion,
latestVersion,
isOutdated: latestVersion ? isVersionOutdated(currentVersion, latestVersion) : false,
isOutdated: latestVersion
? isOutdatedForChannel(currentVersion, latestVersion, channel)
: false,
installMethod,
recommendedCommand,
checkedAt: latestVersion ? now : null,
@ -585,7 +592,7 @@ export function maybeShowUpdateNotice(): void {
const isOutdated =
installMethod === "git"
? cached.isOutdated === true
: isVersionOutdated(currentVersion, cached.latestVersion);
: isOutdatedForChannel(currentVersion, cached.latestVersion, channel);
if (!isOutdated) return;
const channelSuffix = channel === "nightly" ? " (nightly)" : "";

View File

@ -1,12 +1,47 @@
import { describe, it, expect } from "vitest";
import { isVersionOutdated } from "../version-compare.js";
import { isVersionOutdated, isVersionOutdatedForChannel } from "../version-compare.js";
// Both `packages/cli/src/lib/update-check.ts` and
// `packages/web/src/app/api/version/route.ts` import this implementation
// from core. These tests are the single source of truth for the comparison
// rules — if either consumer's behavior diverges, fix the consumer, not this.
describe("isVersionOutdatedForChannel", () => {
it("treats nightly dist-tag changes as updates by identity in both lexical directions", () => {
expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-def", "nightly")).toBe(
true,
);
expect(isVersionOutdatedForChannel("0.0.0-nightly-def", "0.0.0-nightly-abc", "nightly")).toBe(
true,
);
expect(
isVersionOutdatedForChannel("0.0.0-nightly-f00d123", "0.0.0-nightly-0dead01", "nightly"),
).toBe(true);
});
it("does not update nightly when the exact dist-tag version is already installed", () => {
expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.0.0-nightly-abc", "nightly")).toBe(
false,
);
});
it("uses semver for stable-to-nightly channel switches", () => {
expect(isVersionOutdatedForChannel("0.7.0", "0.0.0-nightly-abc", "nightly")).toBe(false);
expect(isVersionOutdatedForChannel("0.7.0", "0.8.0-nightly-abc", "nightly")).toBe(true);
expect(isVersionOutdatedForChannel("0.8.0", "0.8.0-nightly-abc", "nightly")).toBe(false);
});
it("treats nightly-to-stable fallback on the nightly channel as an update when the dist-tag differs", () => {
expect(isVersionOutdatedForChannel("0.0.0-nightly-abc", "0.8.0", "nightly")).toBe(true);
});
it("keeps stable comparisons numeric instead of lexical", () => {
expect(isVersionOutdatedForChannel("0.9.0", "0.10.0", "stable")).toBe(true);
expect(isVersionOutdatedForChannel("0.7.0", "0.8.0", "stable")).toBe(true);
});
});
describe("isVersionOutdated (shared core implementation)", () => {
describe("numeric major/minor/patch", () => {
it("treats lower major as older", () => {

View File

@ -425,7 +425,7 @@ export { UpdateChannelSchema, InstallMethodOverrideSchema } from "./global-confi
// Channel-aware semver comparison shared by the CLI's update-check and the
// dashboard's /api/version route.
export { isVersionOutdated } from "./version-compare.js";
export { isVersionOutdated, isVersionOutdatedForChannel } from "./version-compare.js";
// Cache-layer primitives for the update pipeline. Both the CLI and the
// dashboard's /api/version route read the same cache file; centralising the

View File

@ -8,6 +8,34 @@
* Spec: release-process.html §07 (Auto-update mechanics).
*/
import type { UpdateChannel } from "./global-config.js";
export function isVersionOutdatedForChannel(
current: string,
latest: string,
channel: UpdateChannel,
): boolean {
if (channel === "nightly") {
const currentParsed = parseVersion(current);
const latestParsed = parseVersion(latest);
if (
isNightlyPrerelease(currentParsed.prerelease) &&
isNightlyPrerelease(latestParsed.prerelease)
) {
return current !== latest;
}
}
return isVersionOutdated(current, latest);
}
function isNightlyPrerelease(prerelease: string | undefined): boolean {
return (
prerelease === "nightly" ||
prerelease?.startsWith("nightly-") === true ||
prerelease?.startsWith("nightly.") === true
);
}
/**
* Returns true if `current` is an older version than `latest`.
*

View File

@ -13,7 +13,7 @@
import { NextResponse } from "next/server";
import {
getInstalledAoVersion,
isVersionOutdated,
isVersionOutdatedForChannel,
loadGlobalConfig,
readUpdateCheckCacheRaw,
type UpdateChannel,
@ -53,7 +53,8 @@ export async function GET() {
const latest = cache?.latestVersion && cacheMatchesChannel ? cache.latestVersion : null;
// Git installs cache `latestVersion: "origin/main"` (a ref, not a semver),
// so `isVersionOutdated(current, "origin/main")` would always return false.
// so `isVersionOutdatedForChannel(current, "origin/main", channel)` would
// always return false.
// The CLI works around this by trusting the precomputed `cached.isOutdated`
// for git installs — mirror that here so the dashboard banner actually
// appears when a git-installed user is behind origin/main.
@ -62,7 +63,7 @@ export async function GET() {
isOutdated =
cache?.installMethod === "git"
? cache.isOutdated === true
: isVersionOutdated(current, latest);
: isVersionOutdatedForChannel(current, latest, channel);
}
const body: VersionResponse = {