From 7af0a7fb165e61d6758edbd1e3225c0346cb9b01 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Tue, 4 Aug 2026 16:56:20 -0400 Subject: [PATCH 1/2] fix(version): Use versioned dev versions While developing Claude skills that use the Clerk CLI, it's convenient to gate the skill on a minimum CLI version. However, when using a local dev version of the Clerk CLI, all versions would have the same version string `0.0.0-dev`. Instead, version dev versions based on the underlying version. Assuming the last release in `packages/cli/package.json` was 3.0.0: ``` 3.0.0-dev.20260803.f51f1e4 clean tree at commit f51f1e4 3.0.0-dev.20260803.f51f1e4.dirty ...with uncommitted changes 3.0.0-dev git unavailable, or not run from a checkout ``` I thought it was cleanest to include both the date and the commit, since the date provides human-readable information on how old your working tree is. But we can switch to just the commit, which would match our canary tags. --- .changeset/silent-weeks-decide.md | 4 + CLAUDE.md | 4 +- docs/releasing.md | 49 +++++----- packages/cli-core/src/commands/mcp/probe.ts | 4 +- .../cli-core/src/commands/update/index.ts | 2 +- .../cli-core/src/lib/credential-store.test.ts | 3 +- .../cli-core/src/lib/update-check.test.ts | 12 ++- packages/cli-core/src/lib/update-check.ts | 9 +- packages/cli-core/src/lib/user-agent.ts | 4 +- packages/cli-core/src/lib/version.test.ts | 70 +++++++++++++ packages/cli-core/src/lib/version.ts | 98 +++++++++++++++++-- scripts/build.ts | 6 +- 12 files changed, 214 insertions(+), 51 deletions(-) create mode 100644 .changeset/silent-weeks-decide.md create mode 100644 packages/cli-core/src/lib/version.test.ts diff --git a/.changeset/silent-weeks-decide.md b/.changeset/silent-weeks-decide.md new file mode 100644 index 000000000..748070096 --- /dev/null +++ b/.changeset/silent-weeks-decide.md @@ -0,0 +1,4 @@ +--- +--- + +Unversioned builds now report a version derived from the checkout they run from (`3.0.0-dev.20260803.f51f1e4`, `.dirty` for an unclean tree) instead of a flat `0.0.0-dev`. Dev-only: release binaries are compiled with an explicit `CLI_VERSION`, so published behavior is unchanged. diff --git a/CLAUDE.md b/CLAUDE.md index bd4118cba..272579612 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,4 +58,6 @@ These flags require Bun >= 1.3.13 — older versions silently ignore them and lo ## Versioning -The `CLI_VERSION` global is injected at compile time via `bun build --compile --define "CLI_VERSION=..."`. Local `build:compile` omits it, so the binary reports `0.0.0-dev`. The CI release workflow injects the real version. +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, `packages/cli-core`'s own `build:compile` — fall back to a version derived from the checkout by `src/lib/version.ts`: `-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. Code that needs to know whether a build is versioned at all should use `resolveCliVersion()` / `isDevVersion()`, never an equality check against a literal. diff --git a/docs/releasing.md b/docs/releasing.md index 2bcd106c4..1a0f2c094 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -205,29 +205,30 @@ Install: `brew install clerk/stable/clerk` ## Key Files -| File | Purpose | -| -------------------------------------- | ------------------------------------------------------------------------------ | -| `packages/cli/bin/clerk` | CJS shim that resolves and spawns the platform binary | -| `packages/cli/package.json` | Wrapper package (has `prepublishOnly` guard against accidental direct publish) | -| `packages/cli-core/src/cli.ts` | CLI entrypoint (reads `CLI_VERSION` global at runtime) | -| `packages/cli-core/src/globals.d.ts` | TypeScript declaration for the `CLI_VERSION` compile-time define | -| `install.sh` | Shell install script, downloads binary from GitHub Releases | -| `scripts/releaser.ts` | Generates platform packages and publishes everything to npm | -| `.github/release-notes/vX.Y.Z.md` | Optional version-specific intro prepended to stable GitHub Release notes | -| `scripts/lib/targets.ts` | Target definitions (used by both releaser and build.ts) | -| `scripts/build.ts` | Cross-compiles CLI binaries for all 8 platform targets | -| `scripts/sign-macos.ts` | Signs and notarizes macOS binaries (keychain, codesign, notarytool) | -| `scripts/entitlements.plist` | macOS entitlements for Bun's JIT engine (used by codesign) | -| `scripts/canary.ts` | Versions packages for canary channel using Changesets snapshots | -| `scripts/snapshot.ts` | Versions packages for snapshot channel using Changesets snapshots | -| `scripts/check-release.ts` | Detects if a stable release is needed (compares version to npm registry) | -| `scripts/homebrew.ts` | Creates Homebrew archives, uploads to release, renders and pushes formula | -| `scripts/lib/homebrew.ts` | Homebrew formula renderer, target list, and helper utilities | -| `.changeset/config.json` | Changesets configuration | -| `.github/workflows/build-binaries.yml` | Reusable workflow for cross-compiling binaries (called by release + snapshot) | -| `.github/workflows/sign-macos.yml` | Reusable workflow for macOS code signing and notarization | -| `.github/workflows/smoke-test.yml` | Reusable workflow for smoke-testing binaries (called by release + snapshot) | -| `.github/workflows/release.yml` | GitHub Actions release, canary, and snapshot workflow | +| File | Purpose | +| -------------------------------------- | ---------------------------------------------------------------------------------- | +| `packages/cli/bin/clerk` | CJS shim that resolves and spawns the platform binary | +| `packages/cli/package.json` | Wrapper package (has `prepublishOnly` guard against accidental direct publish) | +| `packages/cli-core/src/cli.ts` | CLI entrypoint (reads `CLI_VERSION` global at runtime) | +| `packages/cli-core/src/globals.d.ts` | TypeScript declaration for the `CLI_VERSION` compile-time define | +| `packages/cli-core/src/lib/version.ts` | Resolves `CLI_VERSION`, or derives a `-dev..` version from the checkout | +| `install.sh` | Shell install script, downloads binary from GitHub Releases | +| `scripts/releaser.ts` | Generates platform packages and publishes everything to npm | +| `.github/release-notes/vX.Y.Z.md` | Optional version-specific intro prepended to stable GitHub Release notes | +| `scripts/lib/targets.ts` | Target definitions (used by both releaser and build.ts) | +| `scripts/build.ts` | Cross-compiles CLI binaries for all 8 platform targets | +| `scripts/sign-macos.ts` | Signs and notarizes macOS binaries (keychain, codesign, notarytool) | +| `scripts/entitlements.plist` | macOS entitlements for Bun's JIT engine (used by codesign) | +| `scripts/canary.ts` | Versions packages for canary channel using Changesets snapshots | +| `scripts/snapshot.ts` | Versions packages for snapshot channel using Changesets snapshots | +| `scripts/check-release.ts` | Detects if a stable release is needed (compares version to npm registry) | +| `scripts/homebrew.ts` | Creates Homebrew archives, uploads to release, renders and pushes formula | +| `scripts/lib/homebrew.ts` | Homebrew formula renderer, target list, and helper utilities | +| `.changeset/config.json` | Changesets configuration | +| `.github/workflows/build-binaries.yml` | Reusable workflow for cross-compiling binaries (called by release + snapshot) | +| `.github/workflows/sign-macos.yml` | Reusable workflow for macOS code signing and notarization | +| `.github/workflows/smoke-test.yml` | Reusable workflow for smoke-testing binaries (called by release + snapshot) | +| `.github/workflows/release.yml` | GitHub Actions release, canary, and snapshot workflow | ## Keeping Targets in Sync @@ -255,7 +256,7 @@ bun run build:compile:all bun run scripts/build.ts --target=bun-darwin-arm64 ``` -The `dev` and `start` commands do not inject a version (falls back to `0.0.0-dev`). The release workflow handles version injection. +The `dev` and `start` commands do not inject a version, so the CLI falls back to a checkout-derived dev version — `-dev..`, suffixed `.dirty` for an unclean tree. `scripts/build.ts` uses the same string as its `--version` default, so a local `build:compile:all` rehearsal stamps binaries with the commit they were built from. The release workflow always passes an explicit `--version`. > **Bun version for local rehearsal:** CI pins Bun `1.3.11` in [`build-binaries.yml`](../.github/workflows/build-binaries.yml) because `1.3.12` produces darwin-arm64 binaries that macOS codesign rejects. If you are rehearsing a release locally and plan to execute the compiled darwin-arm64 binary, match the CI pin. This will stop being relevant once the pin is lifted. Note the pin only covers compiling binaries — it sits below the workspace's `engines.bun` floor (`>=1.3.13`), which is what running the test suite requires (`bun test --parallel` support). diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts index 664c1f708..dbced2c86 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 { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.ts"; +import { resolveCliVersion, resolveDevVersion } 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: resolveCliVersion() ?? DEV_CLI_VERSION }, + clientInfo: { name: "clerk-cli", version: resolveCliVersion() ?? resolveDevVersion() }, }, } satisfies JSONRPCRequest & InitializeRequest; diff --git a/packages/cli-core/src/commands/update/index.ts b/packages/cli-core/src/commands/update/index.ts index 58dc7a1ad..c7657790b 100644 --- a/packages/cli-core/src/commands/update/index.ts +++ b/packages/cli-core/src/commands/update/index.ts @@ -256,7 +256,7 @@ export async function update(options: UpdateOptions): Promise { const currentVersion = getCurrentVersion(); if (isDevVersion(currentVersion)) { - log.info("Running development build (0.0.0-dev); update not applicable."); + log.info(`Running development build (${currentVersion}); update not applicable.`); return; } diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index cc58b7f79..b86577456 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -21,8 +21,9 @@ mock.module("@napi-rs/keyring", () => ({ })); mock.module("./version.ts", () => ({ - DEV_CLI_VERSION: "0.0.0-dev", + isDevVersion: (version: string) => version.includes("-dev"), resolveCliVersion: () => undefined, + resolveDevVersion: () => "0.0.0-dev", })); mock.module("./token-exchange.ts", () => ({ diff --git a/packages/cli-core/src/lib/update-check.test.ts b/packages/cli-core/src/lib/update-check.test.ts index 724f3702c..8c72ff474 100644 --- a/packages/cli-core/src/lib/update-check.test.ts +++ b/packages/cli-core/src/lib/update-check.test.ts @@ -73,14 +73,15 @@ 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() = "0.0.0-dev" - // inferChannelFromVersion("0.0.0-dev") = "dev" + // CLI_VERSION is undefined in tests, so getCurrentVersion() returns the + // checkout-derived dev version ("-dev[..]"), whose first + // prerelease identifier — and therefore inferred channel — is "dev" expect(getUpdateChannel()).toBe("dev"); }); test("falls through to version inference when env var is unset", () => { delete process.env.CLERK_UPDATE_CHANNEL; - // CLI_VERSION is undefined in tests → "0.0.0-dev" → "dev" + // CLI_VERSION is undefined in tests → "-dev.…" → "dev" expect(getUpdateChannel()).toBe("dev"); }); }); @@ -169,6 +170,11 @@ describe("shouldCheckForUpdates", () => { 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 when CI is set", () => { process.env.CI = "1"; expect(shouldCheckForUpdates("1.0.0")).toBe(false); diff --git a/packages/cli-core/src/lib/update-check.ts b/packages/cli-core/src/lib/update-check.ts index 8ad654455..4f81891fb 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 { DEV_CLI_VERSION } from "./version.ts"; +import { isDevVersion, resolveDevVersion } from "./version.ts"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -38,12 +38,11 @@ export function getUpdateChannel(): string { // ── Version helpers ─────────────────────────────────────────────────────────── export function getCurrentVersion(): string { - return typeof CLI_VERSION !== "undefined" ? CLI_VERSION : DEV_CLI_VERSION; + return typeof CLI_VERSION !== "undefined" ? CLI_VERSION : resolveDevVersion(); } -export function isDevVersion(version: string): boolean { - return version === DEV_CLI_VERSION; -} +// Re-exported so callers can pull the whole version/update surface from here. +export { isDevVersion }; export function compareSemver(a: string, b: string): number { return semver.compare(a, b); diff --git a/packages/cli-core/src/lib/user-agent.ts b/packages/cli-core/src/lib/user-agent.ts index 5b136fc2d..d0c7e5b1a 100644 --- a/packages/cli-core/src/lib/user-agent.ts +++ b/packages/cli-core/src/lib/user-agent.ts @@ -10,10 +10,10 @@ * - `ci` segment is appended when running under a recognized CI environment. */ -import { DEV_CLI_VERSION, resolveCliVersion } from "./version.ts"; +import { resolveCliVersion, resolveDevVersion } from "./version.ts"; export function buildUserAgent(): string { - const version = resolveCliVersion() ?? DEV_CLI_VERSION; + const version = resolveCliVersion() ?? resolveDevVersion(); const segments = [`Bun/${Bun.version}`, `${process.platform}-${process.arch}`]; if (process.env.CI) segments.push("ci"); return `Clerk-CLI/${version} (${segments.join("; ")})`; diff --git a/packages/cli-core/src/lib/version.test.ts b/packages/cli-core/src/lib/version.test.ts new file mode 100644 index 000000000..8872f5903 --- /dev/null +++ b/packages/cli-core/src/lib/version.test.ts @@ -0,0 +1,70 @@ +import { test, expect, describe } from "bun:test"; +import semver from "semver"; +import cliPackage from "../../../cli/package.json"; +import { isDevVersion, resolveCliVersion, resolveDevVersion } from "./version.ts"; + +// ── isDevVersion ────────────────────────────────────────────────────────────── + +describe("isDevVersion", () => { + 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); + }); +}); + +// ── resolveCliVersion ───────────────────────────────────────────────────────── + +describe("resolveCliVersion", () => { + test("returns undefined under the test runner, where CLI_VERSION is undefined", () => { + expect(resolveCliVersion()).toBeUndefined(); + }); +}); + +// ── resolveDevVersion ───────────────────────────────────────────────────────── + +describe("resolveDevVersion", () => { + const version = resolveDevVersion(); + + 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); + }); + + test("is valid semver, so update-check comparisons can never throw on it", () => { + expect(semver.valid(version)).not.toBeNull(); + }); + + test("sorts below the release it is based on", () => { + expect(semver.lt(version, cliPackage.version)).toBe(true); + }); + + test("is memoized", () => { + expect(resolveDevVersion()).toBe(version); + }); + + test("carries a YYYYMMDD. commit segment when run from a git checkout", () => { + // The suite always runs from a checkout, but guard anyway: an exported + // tarball with no .git must still produce a usable version. + const suffix = version.slice(`${cliPackage.version}-dev`.length); + expect(suffix === "" || /^\.\d{8}\.[0-9a-fg]+(\.dirty)?$/.test(suffix)).toBe(true); + }); +}); diff --git a/packages/cli-core/src/lib/version.ts b/packages/cli-core/src/lib/version.ts index 4fdf8adc1..3c119b682 100644 --- a/packages/cli-core/src/lib/version.ts +++ b/packages/cli-core/src/lib/version.ts @@ -1,20 +1,98 @@ /** - * The string printed by `clerk --version` when the compile-time `CLI_VERSION` - * global is undefined (dev builds via `bun run dev`). Used in two places that - * must stay in lockstep: the CLI's own version flag fallback, and the skill - * installer's detection of "this binary isn't really versioned" so it can tell - * the installed skill to pin against `latest` instead of a fake number. + * Release binaries are compiled with `--define CLI_VERSION="x.y.z"`, so the + * global holds the published version. Everything else — `bun run dev`, a + * `bun link`ed checkout on your PATH, a local `build:compile` — has no define + * and reports a *dev* version derived from the checkout it runs from: + * + * 3.0.0-dev.20260803.f51f1e4 clean tree at commit f51f1e4 + * 3.0.0-dev.20260803.f51f1e4.dirty ...with uncommitted changes + * 3.0.0-dev git unavailable, or not run from a checkout + * + * The base is the version `packages/cli` currently publishes at, so you can see + * which release your checkout sits on. The commit segment is the part that + * moves when you pull, which is what makes `clerk --version` able to answer + * "is the `clerk` on my PATH the code I just fetched?". + * + * 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, + * since a dev version is no longer a single literal. */ -export const DEV_CLI_VERSION = "0.0.0-dev"; + +import cliPackage from "../../../cli/package.json"; + +const DEV_TAG = "dev"; + +/** + * 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. + */ +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}.`); +} /** * Resolve the current CLI version, or `undefined` when running an unversioned - * dev build. Anything downstream that wants to *display* a version should use - * `DEV_CLI_VERSION` as a fallback; anything that wants to *decide* whether - * this binary is meaningfully versioned should check for `undefined` here. + * dev build. Anything that wants to *display* a version should fall back to + * `resolveDevVersion()`; anything that wants to *decide* whether this binary is + * meaningfully versioned should check for `undefined` here. */ export function resolveCliVersion(): string | undefined { if (typeof CLI_VERSION === "undefined") return undefined; - if (CLI_VERSION === DEV_CLI_VERSION) return undefined; + if (isDevVersion(CLI_VERSION)) return undefined; return CLI_VERSION; } + +function git(args: string[]): { exitCode: number; stdout: string } | undefined { + try { + // `-C import.meta.dir` anchors on the checkout this code was loaded from, + // not the user's cwd — the CLI is normally run from some other project. + // `--no-optional-locks` keeps a version lookup from fighting a concurrent + // git command for the index lock. + const proc = Bun.spawnSync(["git", "--no-optional-locks", "-C", import.meta.dir, ...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + return { exitCode: proc.exitCode, stdout: proc.stdout.toString().trim() }; + } catch { + // git missing from PATH, or import.meta.dir isn't a real directory (it + // points inside the virtual filesystem of a compiled binary). + return undefined; + } +} + +function describeCheckout(): string | undefined { + const head = git(["log", "-1", "--format=%cs %h"]); + if (!head || head.exitCode !== 0) return undefined; + + const [date, sha] = head.stdout.split(" "); + if (!date || !sha) return undefined; + + // Semver forbids leading zeroes in an all-numeric prerelease identifier, and + // an abbreviated sha can come out all digits. `g` is git-describe's own + // escape for the same problem. + const commit = /^0\d*$/.test(sha) ? `g${sha}` : sha; + + const diff = git(["diff", "--quiet", "HEAD"]); + const dirty = diff?.exitCode === 1 ? ".dirty" : ""; + + return `${date.replaceAll("-", "")}.${commit}${dirty}`; +} + +let devVersion: string | undefined; + +/** + * The version string an unversioned build reports. Memoized: it shells out to + * git, and `--version`, the outbound user agent, and MCP client info all ask + * for it. + */ +export function resolveDevVersion(): string { + if (devVersion) return devVersion; + const checkout = describeCheckout(); + devVersion = `${cliPackage.version}-${DEV_TAG}${checkout ? `.${checkout}` : ""}`; + return devVersion; +} diff --git a/scripts/build.ts b/scripts/build.ts index db8d08ae6..d5ddd9544 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,7 +1,7 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import { parseArgs } from "node:util"; -import { DEV_CLI_VERSION } from "../packages/cli-core/src/lib/version.ts"; +import { resolveDevVersion } from "../packages/cli-core/src/lib/version.ts"; import { type Target, targets } from "./lib/targets.ts"; function keyringBindingPath(target: Target): string { @@ -15,7 +15,9 @@ const { values } = parseArgs({ args: Bun.argv.slice(2), options: { target: { type: "string" }, - version: { type: "string", default: DEV_CLI_VERSION }, + // CI always passes --version. Without it (local rehearsal), stamp the + // binary with the checkout it was built from rather than a fixed literal. + version: { type: "string", default: resolveDevVersion() }, "env-profiles-path": { type: "string" }, }, }); From 6c95f22c36311e23b67253681e7b56c3708394b1 Mon Sep 17 00:00:00 2001 From: Daniel Moerner Date: Wed, 5 Aug 2026 09:04:16 -0400 Subject: [PATCH 2/2] fix: Address CodeRabbit feedback --- packages/cli-core/src/commands/mcp/probe.ts | 4 +- packages/cli-core/src/lib/update-check.ts | 8 +--- packages/cli-core/src/lib/user-agent.ts | 4 +- packages/cli-core/src/lib/version.test.ts | 53 ++++++++++++++++++++- packages/cli-core/src/lib/version.ts | 31 ++++++++++-- 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts index dbced2c86..95bb49623 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 { resolveCliVersion, resolveDevVersion } from "../../lib/version.ts"; +import { getCurrentVersion } 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: resolveCliVersion() ?? resolveDevVersion() }, + clientInfo: { name: "clerk-cli", version: getCurrentVersion() }, }, } satisfies JSONRPCRequest & InitializeRequest; diff --git a/packages/cli-core/src/lib/update-check.ts b/packages/cli-core/src/lib/update-check.ts index 4f81891fb..18d06d7c3 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 { isDevVersion, resolveDevVersion } from "./version.ts"; +import { getCurrentVersion, isDevVersion } from "./version.ts"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -37,12 +37,8 @@ export function getUpdateChannel(): string { // ── Version helpers ─────────────────────────────────────────────────────────── -export function getCurrentVersion(): string { - return typeof CLI_VERSION !== "undefined" ? CLI_VERSION : resolveDevVersion(); -} - // Re-exported so callers can pull the whole version/update surface from here. -export { isDevVersion }; +export { getCurrentVersion, isDevVersion }; export function compareSemver(a: string, b: string): number { return semver.compare(a, b); diff --git a/packages/cli-core/src/lib/user-agent.ts b/packages/cli-core/src/lib/user-agent.ts index d0c7e5b1a..01da7f42d 100644 --- a/packages/cli-core/src/lib/user-agent.ts +++ b/packages/cli-core/src/lib/user-agent.ts @@ -10,10 +10,10 @@ * - `ci` segment is appended when running under a recognized CI environment. */ -import { resolveCliVersion, resolveDevVersion } from "./version.ts"; +import { getCurrentVersion } from "./version.ts"; export function buildUserAgent(): string { - const version = resolveCliVersion() ?? resolveDevVersion(); + 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("; ")})`; diff --git a/packages/cli-core/src/lib/version.test.ts b/packages/cli-core/src/lib/version.test.ts index 8872f5903..5961b69e2 100644 --- a/packages/cli-core/src/lib/version.test.ts +++ b/packages/cli-core/src/lib/version.test.ts @@ -1,7 +1,17 @@ -import { test, expect, describe } from "bun:test"; +import { test, expect, describe, afterEach } from "bun:test"; import semver from "semver"; import cliPackage from "../../../cli/package.json"; -import { isDevVersion, resolveCliVersion, resolveDevVersion } from "./version.ts"; +import { + getCurrentVersion, + isDevVersion, + resolveCliVersion, + resolveDevVersion, +} from "./version.ts"; + +// `CLI_VERSION` is a compile-time define, absent under the test runner. Setting +// the global stands in for a binary built with one, since the unresolved +// identifier reads through to `globalThis`. +const globals = globalThis as unknown as { CLI_VERSION?: string }; // ── isDevVersion ────────────────────────────────────────────────────────────── @@ -31,9 +41,48 @@ describe("isDevVersion", () => { // ── resolveCliVersion ───────────────────────────────────────────────────────── describe("resolveCliVersion", () => { + afterEach(() => { + delete globals.CLI_VERSION; + }); + test("returns undefined under the test runner, where CLI_VERSION is undefined", () => { expect(resolveCliVersion()).toBeUndefined(); }); + + test("returns an injected release version", () => { + globals.CLI_VERSION = "3.1.0"; + expect(resolveCliVersion()).toBe("3.1.0"); + }); + + test("treats an injected dev version as unversioned", () => { + globals.CLI_VERSION = "3.0.0-dev.20260803.f51f1e4"; + expect(resolveCliVersion()).toBeUndefined(); + }); +}); + +// ── getCurrentVersion ───────────────────────────────────────────────────────── + +describe("getCurrentVersion", () => { + afterEach(() => { + delete globals.CLI_VERSION; + }); + + test("falls back to the checkout when nothing was injected", () => { + expect(getCurrentVersion()).toBe(resolveDevVersion()); + }); + + test("reports an injected release version", () => { + globals.CLI_VERSION = "3.1.0"; + expect(getCurrentVersion()).toBe("3.1.0"); + }); + + test("keeps the commit segment of an injected dev version", () => { + // A locally compiled binary can't reach git at runtime — `import.meta.dir` + // points into its embedded filesystem — so the version stamped in at build + // time is the only thing that still knows which commit it came from. + globals.CLI_VERSION = "3.0.0-dev.20260803.f51f1e4.dirty"; + expect(getCurrentVersion()).toBe("3.0.0-dev.20260803.f51f1e4.dirty"); + }); }); // ── resolveDevVersion ───────────────────────────────────────────────────────── diff --git a/packages/cli-core/src/lib/version.ts b/packages/cli-core/src/lib/version.ts index 3c119b682..8d0c86fa7 100644 --- a/packages/cli-core/src/lib/version.ts +++ b/packages/cli-core/src/lib/version.ts @@ -13,6 +13,10 @@ * moves when you pull, which is what makes `clerk --version` able to answer * "is the `clerk` on my PATH the code I just fetched?". * + * Anything that *displays* a version — `--version`, the outbound user agent, + * MCP client info — calls `getCurrentVersion()`, which prefers whatever was + * injected even when that is itself a dev 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 @@ -38,8 +42,8 @@ export function isDevVersion(version: string): boolean { /** * Resolve the current CLI version, or `undefined` when running an unversioned - * dev build. Anything that wants to *display* a version should fall back to - * `resolveDevVersion()`; anything that wants to *decide* whether this binary is + * 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. */ export function resolveCliVersion(): string | undefined { @@ -77,8 +81,11 @@ function describeCheckout(): string | undefined { // escape for the same problem. const commit = /^0\d*$/.test(sha) ? `g${sha}` : sha; - const diff = git(["diff", "--quiet", "HEAD"]); - const dirty = diff?.exitCode === 1 ? ".dirty" : ""; + // `status --porcelain` rather than `diff --quiet HEAD`: an untracked source + // file is code the build picks up but the commit doesn't describe, so it + // makes the checkout dirty just as a modified tracked file does. + const status = git(["status", "--porcelain", "--untracked-files=normal"]); + const dirty = status?.exitCode === 0 && status.stdout !== "" ? ".dirty" : ""; return `${date.replaceAll("-", "")}.${commit}${dirty}`; } @@ -96,3 +103,19 @@ export function resolveDevVersion(): string { devVersion = `${cliPackage.version}-${DEV_TAG}${checkout ? `.${checkout}` : ""}`; return devVersion; } + +/** + * The version this build reports to anyone who asks — `--version`, the outbound + * user agent, MCP client info. + * + * Prefers the injected `CLI_VERSION` even when it is a dev version. A local + * `build:compile` stamps the checkout it was built from into the binary, and + * that string is strictly better than anything the binary can recompute later: + * `import.meta.dir` inside a compiled binary points at the embedded virtual + * filesystem, so git is unreachable and `resolveDevVersion()` would degrade to + * a bare `-dev`, dropping the date and commit. + */ +export function getCurrentVersion(): string { + if (typeof CLI_VERSION !== "undefined") return CLI_VERSION; + return resolveDevVersion(); +}