From af0edc8dc63e429ab96b4a7d2c4e8c3fbd5d3799 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 10 Aug 2026 14:09:29 -0600 Subject: [PATCH] refactor(version): simplify version handling - Export compile-time version and dev-build constants - Update version consumers to use the constants - Classify development builds during macro evaluation --- .changeset/dry-spoons-bake.md | 2 + .claude/rules/versioning.md | 25 ++++++ CLAUDE.md | 2 +- packages/cli-core/src/cli-program.ts | 7 +- .../cli-core/src/commands/doctor/checks.ts | 13 ++- packages/cli-core/src/commands/mcp/probe.ts | 4 +- .../cli-core/src/commands/update/index.ts | 17 ++-- .../cli-core/src/lib/credential-store.test.ts | 4 +- packages/cli-core/src/lib/credential-store.ts | 7 +- .../cli-core/src/lib/update-check.test.ts | 23 ++--- packages/cli-core/src/lib/update-check.ts | 15 ++-- packages/cli-core/src/lib/user-agent.ts | 5 +- packages/cli-core/src/lib/version.macro.ts | 34 ++++++-- packages/cli-core/src/lib/version.test.ts | 85 +++++++------------ packages/cli-core/src/lib/version.ts | 43 +++------- 15 files changed, 139 insertions(+), 147 deletions(-) create mode 100644 .changeset/dry-spoons-bake.md create mode 100644 .claude/rules/versioning.md diff --git a/.changeset/dry-spoons-bake.md b/.changeset/dry-spoons-bake.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/dry-spoons-bake.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/rules/versioning.md b/.claude/rules/versioning.md new file mode 100644 index 000000000..00c7e20fe --- /dev/null +++ b/.claude/rules/versioning.md @@ -0,0 +1,25 @@ +--- +description: Use compile-time version constants for CLI version consumers +paths: + - "packages/cli-core/src/**/*.ts" +alwaysApply: false +--- + +Use the exported constants from `packages/cli-core/src/lib/version.ts` for the +current CLI version rather than adding accessor functions: + +```ts +import { CURRENT_VERSION, IS_DEV_BUILD } from "./version.ts"; +``` + +- Read `CURRENT_VERSION` when displaying or sending the current version, + including CLI help, user-agent headers, and MCP client info. +- Read `IS_DEV_BUILD` when behavior depends on whether the binary is a + development build. +- Keep checkout-derived version generation and dev classification in + `version.macro.ts`; the compiled CLI must not execute Git or classify its + version at runtime. + +The constants are evaluated while Bun transpiles or compiles the module, so +release builds can use the injected `CLI_VERSION` while local builds retain +their checkout metadata. diff --git a/CLAUDE.md b/CLAUDE.md index b1130910f..3c1de3bef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,4 +60,4 @@ These flags require Bun >= 1.3.13 — older versions silently ignore them and lo The `CLI_VERSION` global is injected at compile time via `bun build --compile --define "CLI_VERSION=..."`. The CI release workflow injects the real version. -Builds without that define (`bun run dev`, a `bun link`ed checkout, or `packages/cli-core`'s own `build:compile`) use the Bun macro in `src/lib/version.macro.ts` to derive and inline a version from the checkout during transpilation: `-dev..`, plus `.dirty` when the working tree has uncommitted changes (e.g. `3.0.0-dev.20260803.f51f1e4.dirty`). The commit segment moves on every pull, so `clerk --version` tells you whether the linked binary is the code you just fetched. It degrades to `-dev` when git isn't available. The compiled CLI never runs Git to determine its version. Code that needs to know whether a build is versioned at all should use `resolveCliVersion()` / `isDevVersion()`, never an equality check against a literal. +Builds without that define (`bun run dev`, a `bun link`ed checkout, or `packages/cli-core`'s own `build:compile`) use the Bun macro in `src/lib/version.macro.ts` to derive and inline a version from the checkout during transpilation: `-dev..`, plus `.dirty` when the working tree has uncommitted changes (e.g. `3.0.0-dev.20260803.f51f1e4.dirty`). The commit segment moves on every pull, so `clerk --version` tells you whether the linked binary is the code you just fetched. It degrades to `-dev` when git isn't available. The compiled CLI never runs Git or classifies the version at runtime. Code that needs the current version should read `CURRENT_VERSION`; code that needs the dev-build distinction should read `IS_DEV_BUILD`. diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 9ea89cb54..e987c471b 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -43,7 +43,8 @@ import { import { clerkHelpConfig, formatExamplesBlock, type Example } from "./lib/help.ts"; import { isAgent } from "./mode.ts"; import { log } from "./lib/log.ts"; -import { maybeNotifyUpdate, getCurrentVersion } from "./lib/update-check.ts"; +import { maybeNotifyUpdate } from "./lib/update-check.ts"; +import { CURRENT_VERSION } from "./lib/version.ts"; import { registerExtras } from "@clerk/cli-extras"; /** @@ -87,7 +88,7 @@ export function createProgram(): Program { writeOut: (msg) => log.data(msg.replace(/\n$/, "")), writeErr: (msg) => log.ui(msg), }) - .version(getCurrentVersion(), "-v, --version", "Output the version number") + .version(CURRENT_VERSION, "-v, --version", "Output the version number") .helpOption("-h, --help", "Display help for command") .addHelpCommand("help [command]", "Display help for command") .option( @@ -142,7 +143,7 @@ export function createProgram(): Program { program.hook("postAction", async (_thisCommand, actionCommand) => { const cmdName = actionCommand.name(); if (cmdName === "doctor" || cmdName === "update") return; - await maybeNotifyUpdate(getCurrentVersion()); + await maybeNotifyUpdate(CURRENT_VERSION); }); for (const register of registrants) { diff --git a/packages/cli-core/src/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index 6fa63ec35..b55420a99 100644 --- a/packages/cli-core/src/commands/doctor/checks.ts +++ b/packages/cli-core/src/commands/doctor/checks.ts @@ -7,10 +7,9 @@ import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framewo import { parseEnvFile } from "../../lib/dotenv.ts"; import { hasAccountCredentials } from "../../lib/credential-store.ts"; import type { KeylessTarget } from "../../lib/keyless-target.ts"; +import { CURRENT_VERSION, IS_DEV_BUILD } from "../../lib/version.ts"; import { - getCurrentVersion, getUpdateChannel, - isDevVersion, compareSemver, fetchLatestVersion, writeUpdateCache, @@ -448,9 +447,7 @@ export async function checkConfigFile(ctx: DoctorContext): Promise export async function checkCliVersion(): Promise { const check = defineCheck("CLI version"); - const currentVersion = getCurrentVersion(); - - if (isDevVersion(currentVersion)) { + if (IS_DEV_BUILD) { return check.pass("Running development build"); } @@ -468,11 +465,11 @@ export async function checkCliVersion(): Promise { // Write to cache so the postAction notification fires from cache next time await writeUpdateCache({ checkedAt: Date.now(), latest, distTag: channel }); - if (compareSemver(latest, currentVersion) <= 0) { - return check.pass(`Up to date (${currentVersion}${channelLabel})`); + if (compareSemver(latest, CURRENT_VERSION) <= 0) { + return check.pass(`Up to date (${CURRENT_VERSION}${channelLabel})`); } - return check.warn(`Update available: ${currentVersion} → ${latest}${channelLabel}`, { + return check.warn(`Update available: ${CURRENT_VERSION} → ${latest}${channelLabel}`, { remedy: `Run \`clerk update${formatChannelFlag(channel)}\` to update`, }); } diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts index 95bb49623..e20b47804 100644 --- a/packages/cli-core/src/commands/mcp/probe.ts +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -11,7 +11,7 @@ import { isRecord } from "../../lib/objects.ts"; import { errorMessage } from "../../lib/errors.ts"; import { loggedFetch } from "../../lib/fetch.ts"; -import { getCurrentVersion } from "../../lib/version.ts"; +import { CURRENT_VERSION } from "../../lib/version.ts"; import { sseEventData } from "./sse.ts"; // Type-only: erased at compile, so the SDK stays a devDependency and is never // bundled — it exists purely as a TS gate keeping this request spec-valid. @@ -38,7 +38,7 @@ const INITIALIZE_REQUEST = { params: { protocolVersion: "2024-11-05", capabilities: {}, - clientInfo: { name: "clerk-cli", version: getCurrentVersion() }, + clientInfo: { name: "clerk-cli", version: CURRENT_VERSION }, }, } satisfies JSONRPCRequest & InitializeRequest; diff --git a/packages/cli-core/src/commands/update/index.ts b/packages/cli-core/src/commands/update/index.ts index c7657790b..d0b69d28f 100644 --- a/packages/cli-core/src/commands/update/index.ts +++ b/packages/cli-core/src/commands/update/index.ts @@ -17,12 +17,11 @@ import { import { log } from "../../lib/log.ts"; import { intro, outro, withSpinner } from "../../lib/spinner.ts"; import { UPDATE_PACKAGE_NAME } from "../../lib/constants.ts"; +import { CURRENT_VERSION, IS_DEV_BUILD } from "../../lib/version.ts"; import { - getCurrentVersion, getUpdateChannel, fetchLatestVersion, compareSemver, - isDevVersion, writeUpdateCache, formatChannelLabel, } from "../../lib/update-check.ts"; @@ -253,10 +252,8 @@ async function confirmUpdate(currentVersion: string, latestVersion: string): Pro // ── Main ───────────────────────────────────────────────────────────────────── export async function update(options: UpdateOptions): Promise { - const currentVersion = getCurrentVersion(); - - if (isDevVersion(currentVersion)) { - log.info(`Running development build (${currentVersion}); update not applicable.`); + if (IS_DEV_BUILD) { + log.info(`Running development build (${CURRENT_VERSION}); update not applicable.`); return; } @@ -273,14 +270,14 @@ export async function update(options: UpdateOptions): Promise { const { primary, others } = await resolveTargets(process.execPath, installDirs); - if (compareSemver(latest, currentVersion) <= 0) { - log.info(`${green("✓")} Already on latest (${currentVersion})`); + if (compareSemver(latest, CURRENT_VERSION) <= 0) { + log.info(`${green("✓")} Already on latest (${CURRENT_VERSION})`); reportOtherInstalls(others, channel); if (isHuman()) outro("Up to date"); return; } - log.info(` Current: ${currentVersion}`); + log.info(` Current: ${CURRENT_VERSION}`); log.info(` Latest: ${cyan(latest)}${formatChannelLabel(channel)}`); log.info(` Target: ${formatTarget(primary)}`); log.blank(); @@ -347,7 +344,7 @@ export async function update(options: UpdateOptions): Promise { return; } - const shouldInstall = options.yes || (await confirmUpdate(currentVersion, latest)); + const shouldInstall = options.yes || (await confirmUpdate(CURRENT_VERSION, latest)); if (!shouldInstall) { if (isHuman()) outro("Update cancelled"); diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index 4df9519da..fea9707cc 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -21,8 +21,8 @@ mock.module("@napi-rs/keyring", () => ({ })); mock.module("./version.ts", () => ({ - isDevVersion: (version: string) => version.includes("-dev"), - resolveCliVersion: () => undefined, + CURRENT_VERSION: "0.0.0-dev", + IS_DEV_BUILD: true, })); mock.module("./token-exchange.ts", () => ({ diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 61c85efd8..085179806 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -20,7 +20,7 @@ import { } from "./host-execution.ts"; import { log } from "./log.ts"; import { refreshAccessToken, type TokenResponse } from "./token-exchange.ts"; -import { resolveCliVersion } from "./version.ts"; +import { CURRENT_VERSION, IS_DEV_BUILD } from "./version.ts"; export const KEYCHAIN_SERVICE = "clerk-cli"; export const LOCAL_DEV_KEYCHAIN_SERVICE = "clerk-cli-dev"; @@ -82,8 +82,7 @@ async function resolveKeychainService(): Promise { if (keychainServicePromise) return keychainServicePromise; keychainServicePromise = (async () => { - const cliVersion = resolveCliVersion(); - if (!cliVersion) { + if (IS_DEV_BUILD) { log.debug( `credentials: using local macOS keychain namespace (service=${LOCAL_DEV_KEYCHAIN_SERVICE}, reason=unversioned-cli)`, ); @@ -95,7 +94,7 @@ async function resolveKeychainService(): Promise { }); const codesignOutput = `${proc.stdout.toString()}${proc.stderr.toString()}`; - if (proc.exitCode === 0 && isReleaseSignedMacosBinary(cliVersion, codesignOutput)) { + if (proc.exitCode === 0 && isReleaseSignedMacosBinary(CURRENT_VERSION, codesignOutput)) { return KEYCHAIN_SERVICE; } diff --git a/packages/cli-core/src/lib/update-check.test.ts b/packages/cli-core/src/lib/update-check.test.ts index 8c72ff474..54295bd32 100644 --- a/packages/cli-core/src/lib/update-check.test.ts +++ b/packages/cli-core/src/lib/update-check.test.ts @@ -73,7 +73,7 @@ describe("getUpdateChannel", () => { test("falls through to version inference when env var is empty string", () => { process.env.CLERK_UPDATE_CHANNEL = ""; - // CLI_VERSION is undefined in tests, so getCurrentVersion() returns the + // CLI_VERSION is undefined in tests, so CURRENT_VERSION is a // checkout-derived dev version ("-dev[..]"), whose first // prerelease identifier — and therefore inferred channel — is "dev" expect(getUpdateChannel()).toBe("dev"); @@ -160,40 +160,35 @@ describe("shouldCheckForUpdates", () => { // can override env vars, making env-based control unreliable in shared runs const spy = spyOn(mode, "isAgent").mockReturnValue(true); try { - expect(shouldCheckForUpdates("1.0.0")).toBe(false); + expect(shouldCheckForUpdates(false)).toBe(false); } finally { spy.mockRestore(); } }); - test("returns false for dev version", () => { - expect(shouldCheckForUpdates("0.0.0-dev")).toBe(false); - }); - - test("returns false for a dev version carrying a commit", () => { - expect(shouldCheckForUpdates("3.0.0-dev.20260803.f51f1e4")).toBe(false); - expect(shouldCheckForUpdates("3.0.0-dev.20260803.f51f1e4.dirty")).toBe(false); + test("returns false for a dev build", () => { + expect(shouldCheckForUpdates(true)).toBe(false); }); test("returns false when CI is set", () => { process.env.CI = "1"; - expect(shouldCheckForUpdates("1.0.0")).toBe(false); + expect(shouldCheckForUpdates(false)).toBe(false); }); test("returns false when NO_UPDATE_NOTIFIER is set", () => { process.env.NO_UPDATE_NOTIFIER = "1"; - expect(shouldCheckForUpdates("1.0.0")).toBe(false); + expect(shouldCheckForUpdates(false)).toBe(false); }); test("returns false when CLERK_NO_UPDATE_CHECK is set", () => { process.env.CLERK_NO_UPDATE_CHECK = "1"; - expect(shouldCheckForUpdates("1.0.0")).toBe(false); + expect(shouldCheckForUpdates(false)).toBe(false); }); test("returns true for stable version with no guards", () => { const spy = spyOn(mode, "isAgent").mockReturnValue(false); try { - expect(shouldCheckForUpdates("1.0.0")).toBe(true); + expect(shouldCheckForUpdates(false)).toBe(true); } finally { spy.mockRestore(); } @@ -202,7 +197,7 @@ describe("shouldCheckForUpdates", () => { test("returns true for canary version with no guards", () => { const spy = spyOn(mode, "isAgent").mockReturnValue(false); try { - expect(shouldCheckForUpdates("0.0.2-canary.v20260409211526")).toBe(true); + expect(shouldCheckForUpdates(false)).toBe(true); } finally { spy.mockRestore(); } diff --git a/packages/cli-core/src/lib/update-check.ts b/packages/cli-core/src/lib/update-check.ts index 18d06d7c3..8295d898c 100644 --- a/packages/cli-core/src/lib/update-check.ts +++ b/packages/cli-core/src/lib/update-check.ts @@ -10,7 +10,7 @@ import { } from "./constants.ts"; import { loggedFetch } from "./fetch.ts"; import { log } from "./log.ts"; -import { getCurrentVersion, isDevVersion } from "./version.ts"; +import { CURRENT_VERSION, IS_DEV_BUILD } from "./version.ts"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -32,23 +32,18 @@ export function inferChannelFromVersion(version: string): string { export function getUpdateChannel(): string { if (process.env.CLERK_UPDATE_CHANNEL) return process.env.CLERK_UPDATE_CHANNEL; - return inferChannelFromVersion(getCurrentVersion()); + return inferChannelFromVersion(CURRENT_VERSION); } -// ── Version helpers ─────────────────────────────────────────────────────────── - -// Re-exported so callers can pull the whole version/update surface from here. -export { getCurrentVersion, isDevVersion }; - export function compareSemver(a: string, b: string): number { return semver.compare(a, b); } // ── Guards ──────────────────────────────────────────────────────────────────── -export function shouldCheckForUpdates(version: string): boolean { +export function shouldCheckForUpdates(isDevBuild: boolean): boolean { if (isAgent()) return false; - if (isDevVersion(version)) return false; + if (isDevBuild) return false; if (process.env.CI) return false; if (process.env.NO_UPDATE_NOTIFIER) return false; if (process.env.CLERK_NO_UPDATE_CHECK) return false; @@ -144,7 +139,7 @@ function notifyIfNewer(currentVersion: string, latestVersion: string, distTag: s } export async function maybeNotifyUpdate(currentVersion: string): Promise { - if (!shouldCheckForUpdates(currentVersion)) return; + if (!shouldCheckForUpdates(IS_DEV_BUILD)) return; const distTag = getUpdateChannel(); const cache = await readUpdateCache(); diff --git a/packages/cli-core/src/lib/user-agent.ts b/packages/cli-core/src/lib/user-agent.ts index 01da7f42d..b2fe21dc0 100644 --- a/packages/cli-core/src/lib/user-agent.ts +++ b/packages/cli-core/src/lib/user-agent.ts @@ -10,11 +10,10 @@ * - `ci` segment is appended when running under a recognized CI environment. */ -import { getCurrentVersion } from "./version.ts"; +import { CURRENT_VERSION } from "./version.ts"; export function buildUserAgent(): string { - const version = getCurrentVersion(); const segments = [`Bun/${Bun.version}`, `${process.platform}-${process.arch}`]; if (process.env.CI) segments.push("ci"); - return `Clerk-CLI/${version} (${segments.join("; ")})`; + return `Clerk-CLI/${CURRENT_VERSION} (${segments.join("; ")})`; } diff --git a/packages/cli-core/src/lib/version.macro.ts b/packages/cli-core/src/lib/version.macro.ts index 0eb903fa2..55d721e5c 100644 --- a/packages/cli-core/src/lib/version.macro.ts +++ b/packages/cli-core/src/lib/version.macro.ts @@ -2,11 +2,23 @@ import cliPackage from "../../../cli/package.json"; const DEV_TAG = "dev"; +type VersionValues = { + currentVersion: string; + isDevBuild: boolean; +}; + type GitResult = { exitCode: number; stdout: string; }; +function isDevVersion(version: string): boolean { + const dash = version.indexOf("-"); + if (dash === -1) return false; + const prerelease = version.slice(dash + 1); + return prerelease === DEV_TAG || prerelease.startsWith(`${DEV_TAG}.`); +} + function git(args: string[]): GitResult | undefined { try { // Anchor the lookup on this source file rather than the user's current @@ -41,12 +53,22 @@ function describeCheckout(): string | undefined { } /** - * Resolve the checkout-derived development version during Bun transpilation. + * Resolve the current version and dev-build status during Bun transpilation. * - * Bun inlines the returned string at each macro call, so compiled binaries do - * not execute Git commands at runtime. + * Bun inlines the returned values at the macro call, so compiled binaries do + * not execute Git commands or classify versions at runtime. */ -export function resolveDevVersionAtBuildTime(): string { - const checkout = describeCheckout(); - return `${cliPackage.version}-${DEV_TAG}${checkout ? `.${checkout}` : ""}`; +export function resolveVersionAtBuildTime(): VersionValues { + let currentVersion: string; + if (typeof CLI_VERSION === "undefined") { + const checkout = describeCheckout(); + currentVersion = `${cliPackage.version}-${DEV_TAG}${checkout ? `.${checkout}` : ""}`; + } else { + currentVersion = CLI_VERSION; + } + + return { + currentVersion, + isDevBuild: isDevVersion(currentVersion), + }; } diff --git a/packages/cli-core/src/lib/version.test.ts b/packages/cli-core/src/lib/version.test.ts index ea794daeb..634378f4e 100644 --- a/packages/cli-core/src/lib/version.test.ts +++ b/packages/cli-core/src/lib/version.test.ts @@ -4,11 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import semver from "semver"; import cliPackage from "../../../cli/package.json"; -import { getCurrentVersion, isDevVersion, resolveCliVersion } from "./version.ts"; +import { CURRENT_VERSION, IS_DEV_BUILD } from "./version.ts"; type CompiledVersions = { current: string; - resolved: string | null; + isDev: boolean; }; async function compileVersionFixture(version: string | undefined): Promise { @@ -22,8 +22,8 @@ async function compileVersionFixture(version: string | undefined): Promise { - test("recognizes the bare dev version", () => { - expect(isDevVersion("0.0.0-dev")).toBe(true); - expect(isDevVersion("3.0.0-dev")).toBe(true); - }); - - test("recognizes a dev version carrying a commit", () => { - expect(isDevVersion("3.0.0-dev.20260803.f51f1e4")).toBe(true); - expect(isDevVersion("3.0.0-dev.20260803.f51f1e4.dirty")).toBe(true); - }); - - test("does not treat stable versions as dev", () => { - expect(isDevVersion("3.0.0")).toBe(false); - }); - - test("does not treat real prereleases as dev", () => { - expect(isDevVersion("0.0.2-canary.v20260409211526")).toBe(false); - expect(isDevVersion("3.1.0-snapshot.abc1234")).toBe(false); - // A channel that merely starts with the same letters is not the dev channel - expect(isDevVersion("3.1.0-development.1")).toBe(false); +describe("IS_DEV_BUILD", () => { + test("is true for the checkout-derived test version", () => { + expect(IS_DEV_BUILD).toBe(true); }); }); -// ── resolveCliVersion ───────────────────────────────────────────────────────── - -describe("resolveCliVersion", () => { - test("returns undefined for the checkout-derived test version", () => { - expect(resolveCliVersion()).toBeUndefined(); - }); -}); - -// ── getCurrentVersion ───────────────────────────────────────────────────────── - -describe("getCurrentVersion", () => { - const version = getCurrentVersion(); +// ── CURRENT_VERSION ──────────────────────────────────────────────────────────── +describe("CURRENT_VERSION", () => { test("is built on the version packages/cli publishes at", () => { - expect(version.startsWith(`${cliPackage.version}-dev`)).toBe(true); - }); - - test("classifies as a dev version", () => { - expect(isDevVersion(version)).toBe(true); + expect(CURRENT_VERSION.startsWith(`${cliPackage.version}-dev`)).toBe(true); }); test("is valid semver, so update-check comparisons can never throw on it", () => { - expect(semver.valid(version)).not.toBeNull(); + expect(semver.valid(CURRENT_VERSION)).not.toBeNull(); }); test("sorts below the release it is based on", () => { - expect(semver.lt(version, cliPackage.version)).toBe(true); - }); - - test("is stable for the lifetime of the process", () => { - expect(getCurrentVersion()).toBe(version); + expect(semver.lt(CURRENT_VERSION, cliPackage.version)).toBe(true); }); test("carries a YYYYMMDD. commit segment when run from a git checkout", () => { // The suite normally runs from a checkout, but an exported tarball with no // .git must still produce a usable version. - const suffix = version.slice(`${cliPackage.version}-dev`.length); + const suffix = CURRENT_VERSION.slice(`${cliPackage.version}-dev`.length); expect(suffix === "" || /^\.\d{8}\.[0-9a-fg]+(\.dirty)?$/.test(suffix)).toBe(true); }); }); @@ -122,15 +87,31 @@ describe("getCurrentVersion", () => { describe("compiled version", () => { test("bakes checkout metadata into a local compiled binary", async () => { expect(await compileVersionFixture(undefined)).toEqual({ - current: getCurrentVersion(), - resolved: null, + current: CURRENT_VERSION, + isDev: true, }); }); test("prefers an explicitly defined release version", async () => { expect(await compileVersionFixture("3.1.0")).toEqual({ current: "3.1.0", - resolved: "3.1.0", + isDev: false, + }); + }); + + test("treats an explicitly defined dev version as unversioned", async () => { + const version = "3.0.0-dev.20260803.f51f1e4"; + expect(await compileVersionFixture(version)).toEqual({ + current: version, + isDev: true, + }); + }); + + test("keeps an explicitly defined canary version versioned", async () => { + const version = "0.0.2-canary.v20260409211526"; + expect(await compileVersionFixture(version)).toEqual({ + current: version, + isDev: false, }); }); }); diff --git a/packages/cli-core/src/lib/version.ts b/packages/cli-core/src/lib/version.ts index 8eb7180ae..0aafd2006 100644 --- a/packages/cli-core/src/lib/version.ts +++ b/packages/cli-core/src/lib/version.ts @@ -13,47 +13,26 @@ * metadata without executing Git commands at runtime. * * Anything that displays a version (`--version`, the outbound user agent, or - * MCP client info) calls `getCurrentVersion()`, which prefers an injected - * version even when that is itself a dev version. + * MCP client info) reads `CURRENT_VERSION`, which prefers an injected version + * even when that is itself a dev version. `IS_DEV_BUILD` is computed at the + * same time so runtime consumers do not need to classify the version. * * Two callers care about the dev/release distinction rather than the string: * `credential-store` namespaces the macOS keychain away from release builds, - * and `update-check` suppresses update prompts. Both go through - * `resolveCliVersion` / `isDevVersion` rather than matching a fixed constant. + * and `update-check` suppresses update prompts. Both read + * `IS_DEV_BUILD`. */ -import { resolveDevVersionAtBuildTime } from "./version.macro.ts" with { type: "macro" }; +import { resolveVersionAtBuildTime } from "./version.macro.ts" with { type: "macro" }; -const DEV_TAG = "dev"; -const CURRENT_VERSION = - typeof CLI_VERSION === "undefined" ? resolveDevVersionAtBuildTime() : CLI_VERSION; +const { currentVersion, isDevBuild } = resolveVersionAtBuildTime(); /** - * True for any version whose prerelease starts with `dev` — the shape every - * unversioned build reports. Real prereleases (`-canary.*`, `-snapshot.*`) and - * stable versions are not dev. + * The version embedded while this module was transpiled or compiled. */ -export function isDevVersion(version: string): boolean { - const dash = version.indexOf("-"); - if (dash === -1) return false; - const prerelease = version.slice(dash + 1); - return prerelease === DEV_TAG || prerelease.startsWith(`${DEV_TAG}.`); -} +export const CURRENT_VERSION = currentVersion; /** - * Resolve the current CLI version, or `undefined` when running an unversioned - * dev build. Anything that wants to *display* a version should call - * `getCurrentVersion()`; anything that wants to *decide* whether this binary is - * meaningfully versioned should check for `undefined` here. + * Whether the current build carries a development version. */ -export function resolveCliVersion(): string | undefined { - if (isDevVersion(CURRENT_VERSION)) return undefined; - return CURRENT_VERSION; -} - -/** - * Return the version embedded while this module was transpiled or compiled. - */ -export function getCurrentVersion(): string { - return CURRENT_VERSION; -} +export const IS_DEV_BUILD = isDevBuild;