diff --git a/.changeset/config.json b/.changeset/config.json index 0f83c3b95..c8468bd06 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -37,7 +37,7 @@ ] ], "snapshot": { - "useCalculatedVersionForSnapshots": true, + "useCalculatedVersion": true, "prereleaseTemplate": "{tag}-{commit}" }, "access": "public", diff --git a/packages/cli/__tests__/lib/update-check.test.ts b/packages/cli/__tests__/lib/update-check.test.ts index 4b160d426..889712372 100644 --- a/packages/cli/__tests__/lib/update-check.test.ts +++ b/packages/cli/__tests__/lib/update-check.test.ts @@ -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", () => { diff --git a/packages/cli/src/lib/update-check.ts b/packages/cli/src/lib/update-check.ts index 80ec8f586..7dc72019f 100644 --- a/packages/cli/src/lib/update-check.ts +++ b/packages/cli/src/lib/update-check.ts @@ -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)" : ""; diff --git a/packages/core/src/__tests__/version-compare.test.ts b/packages/core/src/__tests__/version-compare.test.ts index 7eeedd9e4..859a49f32 100644 --- a/packages/core/src/__tests__/version-compare.test.ts +++ b/packages/core/src/__tests__/version-compare.test.ts @@ -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", () => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a8a7f228e..5c5936c82 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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 diff --git a/packages/core/src/version-compare.ts b/packages/core/src/version-compare.ts index 224678656..a6145e16c 100644 --- a/packages/core/src/version-compare.ts +++ b/packages/core/src/version-compare.ts @@ -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`. * diff --git a/packages/web/src/app/api/version/route.ts b/packages/web/src/app/api/version/route.ts index 768820ed8..2613a7d0a 100644 --- a/packages/web/src/app/api/version/route.ts +++ b/packages/web/src/app/api/version/route.ts @@ -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 = {