Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/dry-spoons-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
25 changes: 25 additions & 0 deletions .claude/rules/versioning.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<version in packages/cli/package.json>-dev.<YYYYMMDD>.<short sha>`, 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 `<version>-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: `<version in packages/cli/package.json>-dev.<YYYYMMDD>.<short sha>`, 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 `<version>-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`.
7 changes: 4 additions & 3 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 5 additions & 8 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -448,9 +447,7 @@ export async function checkConfigFile(ctx: DoctorContext): Promise<CheckResult>

export async function checkCliVersion(): Promise<CheckResult> {
const check = defineCheck("CLI version");
const currentVersion = getCurrentVersion();

if (isDevVersion(currentVersion)) {
if (IS_DEV_BUILD) {
return check.pass("Running development build");
}

Expand All @@ -468,11 +465,11 @@ export async function checkCliVersion(): Promise<CheckResult> {
// 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`,
});
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/mcp/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;

Expand Down
17 changes: 7 additions & 10 deletions packages/cli-core/src/commands/update/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -253,10 +252,8 @@ async function confirmUpdate(currentVersion: string, latestVersion: string): Pro
// ── Main ─────────────────────────────────────────────────────────────────────

export async function update(options: UpdateOptions): Promise<void> {
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;
}

Expand All @@ -273,14 +270,14 @@ export async function update(options: UpdateOptions): Promise<void> {

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();
Expand Down Expand Up @@ -347,7 +344,7 @@ export async function update(options: UpdateOptions): Promise<void> {
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");
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/lib/credential-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand Down
7 changes: 3 additions & 4 deletions packages/cli-core/src/lib/credential-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -82,8 +82,7 @@ async function resolveKeychainService(): Promise<string> {
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)`,
);
Expand All @@ -95,7 +94,7 @@ async function resolveKeychainService(): Promise<string> {
});
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;
}

Expand Down
23 changes: 9 additions & 14 deletions packages/cli-core/src/lib/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ("<base>-dev[.<date>.<sha>]"), whose first
// prerelease identifier — and therefore inferred channel — is "dev"
expect(getUpdateChannel()).toBe("dev");
Expand Down Expand Up @@ -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();
}
Expand All @@ -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();
}
Expand Down
15 changes: 5 additions & 10 deletions packages/cli-core/src/lib/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────────────

Expand All @@ -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;
Expand Down Expand Up @@ -144,7 +139,7 @@ function notifyIfNewer(currentVersion: string, latestVersion: string, distTag: s
}

export async function maybeNotifyUpdate(currentVersion: string): Promise<void> {
if (!shouldCheckForUpdates(currentVersion)) return;
if (!shouldCheckForUpdates(IS_DEV_BUILD)) return;

const distTag = getUpdateChannel();
const cache = await readUpdateCache();
Expand Down
5 changes: 2 additions & 3 deletions packages/cli-core/src/lib/user-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("; ")})`;
}
34 changes: 28 additions & 6 deletions packages/cli-core/src/lib/version.macro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.`);
}
Comment on lines +15 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not classify build metadata as a prerelease.

isDevVersion("3.1.0+release-dev") returns true because Line 16 finds the dash in build metadata. This is a release SemVer value. IS_DEV_BUILD then disables update checks and changes the macOS keychain namespace.

Stop parsing at + before checking the prerelease identifier. Add a compiled-fixture test for 3.1.0+release-dev with isDev: false.

Proposed fix
 function isDevVersion(version: string): boolean {
   const dash = version.indexOf("-");
-  if (dash === -1) return false;
-  const prerelease = version.slice(dash + 1);
+  const buildMetadata = version.indexOf("+");
+  if (dash === -1 || (buildMetadata !== -1 && dash > buildMetadata)) return false;
+  const prerelease = version.slice(dash + 1, buildMetadata === -1 ? undefined : buildMetadata);
   return prerelease === DEV_TAG || prerelease.startsWith(`${DEV_TAG}.`);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 isDevVersion(version: string): boolean {
const dash = version.indexOf("-");
const buildMetadata = version.indexOf("+");
if (dash === -1 || (buildMetadata !== -1 && dash > buildMetadata)) return false;
const prerelease = version.slice(dash + 1, buildMetadata === -1 ? undefined : buildMetadata);
return prerelease === DEV_TAG || prerelease.startsWith(`${DEV_TAG}.`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli-core/src/lib/version.macro.ts` around lines 15 - 20, Update
isDevVersion to isolate the SemVer prerelease portion before the +
build-metadata delimiter, so dashes in build metadata cannot identify a
development version. Add a compiled-fixture case for 3.1.0+release-dev asserting
isDev: false.


function git(args: string[]): GitResult | undefined {
try {
// Anchor the lookup on this source file rather than the user's current
Expand Down Expand Up @@ -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),
};
}
Loading