diff --git a/.changeset/slack-thread-fix-issue-bun-1-4-define.md b/.changeset/slack-thread-fix-issue-bun-1-4-define.md new file mode 100644 index 000000000..22281a1b8 --- /dev/null +++ b/.changeset/slack-thread-fix-issue-bun-1-4-define.md @@ -0,0 +1,7 @@ +--- +"clerk": patch +--- + +Keep `--define CLI_VERSION` working when building with Bun 1.4. + +Bun 1.4 runs macros in a sealed transpiler context that `--define` globals no longer reach, so the version macro silently fell back to the checkout-derived dev version even when a release version was injected. The define check and dev classification now live in `version.ts` module scope, where define substitution still applies; the macro only derives the Git checkout fallback. diff --git a/.changeset/slack-thread-fix-issue.md b/.changeset/slack-thread-fix-issue.md new file mode 100644 index 000000000..40b0653a9 --- /dev/null +++ b/.changeset/slack-thread-fix-issue.md @@ -0,0 +1,7 @@ +--- +"clerk": patch +--- + +Stop the "Update available" notice from garbling the "Next steps" block. + +The next-steps outro animation moves the cursor back onto the header line for ~450ms, but several commands (`switch-env`, `auth logout`, `unlink`, `users create`, `apps create`, and others) did not await it. The command's promise resolved mid-animation, so the post-command update check printed its notice at the parked cursor position — overwriting the step lines and leaving a stray duplicate "Next steps" header. Every `outro(...)` call is now awaited, so output printed after a command lands below the finished block. diff --git a/.claude/rules/versioning.md b/.claude/rules/versioning.md index 00c7e20fe..35740a686 100644 --- a/.claude/rules/versioning.md +++ b/.claude/rules/versioning.md @@ -16,10 +16,13 @@ import { CURRENT_VERSION, IS_DEV_BUILD } from "./version.ts"; 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. +- Keep checkout-derived version generation in `version.macro.ts`; the compiled + CLI must not execute Git at runtime. +- Keep the `CLI_VERSION` define check and dev classification in `version.ts` + module scope, NOT in the macro: since Bun 1.4, macros run in a sealed + transpiler context that `--define` globals do not reach, while defines still + substitute identifiers in transpiled modules. -The constants are evaluated while Bun transpiles or compiles the module, so +The macro fallback is inlined 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 3c1de3bef..fbe065d4e 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 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`. +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 at runtime; the injected-vs-fallback choice and dev classification happen in `version.ts` module scope (macros stopped seeing `--define` globals in Bun 1.4). 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/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index f9482eacd..545e2b992 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -167,9 +167,9 @@ export async function api( if (closeStatus === "paused") { pausedOutro(); } else if (closeStatus === "failed") { - outro("Failed"); + await outro("Failed"); } else { - outro(); + await outro(); } } } diff --git a/packages/cli-core/src/commands/apps/create.ts b/packages/cli-core/src/commands/apps/create.ts index 91467cdf8..25936d572 100644 --- a/packages/cli-core/src/commands/apps/create.ts +++ b/packages/cli-core/src/commands/apps/create.ts @@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise { if (closeStatus === "paused") { pausedOutro(); } else if (closeStatus === "failed") { - outro("Failed"); + await outro("Failed"); } else if (closeStatus === "success") { - outro(); + await outro(); } } } diff --git a/packages/cli-core/src/commands/auth/login.ts b/packages/cli-core/src/commands/auth/login.ts index 826216478..76e473c78 100644 --- a/packages/cli-core/src/commands/auth/login.ts +++ b/packages/cli-core/src/commands/auth/login.ts @@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise { if (showNextSteps) { await outro(await loginNextSteps(claimResult)); } else { - outro("Done"); + await outro("Done"); } return existingSession; } @@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise { default: false, }); if (!reauthenticate) { - outro(); + await outro(); throwUserAbort(); } } @@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise { if (showNextSteps) { await outro(await loginNextSteps(claimResult)); } else { - outro("Done"); + await outro("Done"); } return userInfo; diff --git a/packages/cli-core/src/commands/auth/logout.ts b/packages/cli-core/src/commands/auth/logout.ts index ee74d93d4..ad74ddd63 100644 --- a/packages/cli-core/src/commands/auth/logout.ts +++ b/packages/cli-core/src/commands/auth/logout.ts @@ -26,5 +26,5 @@ export async function logout(): Promise { ); } - outro(NEXT_STEPS.LOGOUT); + await outro(NEXT_STEPS.LOGOUT); } diff --git a/packages/cli-core/src/commands/config/push.ts b/packages/cli-core/src/commands/config/push.ts index 70187b807..53caa1d95 100644 --- a/packages/cli-core/src/commands/config/push.ts +++ b/packages/cli-core/src/commands/config/push.ts @@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise { const proceed = await confirmProceed(); if (!proceed) { log.info("No changes were made."); - outro("Cancelled"); + await outro("Cancelled"); return; } @@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise { log.blank(); log.info("A production instance exists, but Clerk did not return a production domain yet."); log.info("Run `clerk deploy` again after the domain is available from the API."); - outro("No deploy actions available"); + await outro("No deploy actions available"); return; } @@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise { code: ERROR_CODE.DOCTOR_FAILED, }); } - outro("All checks passing"); + await outro("All checks passing"); return; } } @@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { code: ERROR_CODE.DOCTOR_FAILED, }); } - outro("All checks passing"); + await outro("All checks passing"); } export function registerDoctor(program: Program): void { diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index afc18cc79..79198309d 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) { if (agent && strategy === "manual") { printBootstrapManualSetupInfo(ctx.framework); } - outro("Done"); + await outro("Done"); return; } @@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) { printBootstrapNextSteps(bootstrap, strategy === "keyless"); } - outro("Done"); + await outro("Done"); } /** diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 6815030c7..e9872bf98 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise { if (existing && agent) { printExistingStatus(existing, normalizedRemote); if (!targetsDifferentApp) { - outro(); + await outro(); return; } } else if (existing) { const shouldRelink = await handleExistingProfile(existing, normalizedRemote, options); if (!shouldRelink) { - outro(); + await outro(); return; } } diff --git a/packages/cli-core/src/commands/open/index.ts b/packages/cli-core/src/commands/open/index.ts index b0f9cb1a8..31c77122f 100644 --- a/packages/cli-core/src/commands/open/index.ts +++ b/packages/cli-core/src/commands/open/index.ts @@ -102,7 +102,7 @@ export async function openDashboard( ); } - outro(); + await outro(); } /** @@ -196,7 +196,7 @@ async function openKeylessDashboard( ); } - outro(); + await outro(); } export function registerOpen(program: Program): void { diff --git a/packages/cli-core/src/commands/switch-env/index.ts b/packages/cli-core/src/commands/switch-env/index.ts index 32135b4c2..a672b2bbc 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise ({ + ...configStubs, +})); + +mock.module("../../lib/credential-store.ts", () => ({ + ...credentialStoreStubs, + getToken: async () => "some-token", +})); + +mock.module("../../lib/environment.ts", () => ({ + getCurrentEnvName: () => mockCurrentEnv, + getAvailableEnvs: () => MOCK_ENVS, + isValidEnv: (name: string) => MOCK_ENVS.includes(name), + setCurrentEnv: (name: string) => { + mockCurrentEnv = name; + }, +})); + +const { switchEnv } = await import("./index.ts"); + +const CURSOR_SHOW = "\x1b[?25h"; +const NOTICE = "⬆ Update available: 3.0.1 → 3.1.0"; + +describe("switch-env update-notice race", () => { + const captured = useCaptureLog(); + const ENV_KEYS = ["CI", "NO_COLOR", "FORCE_COLOR", "COLORTERM"] as const; + let savedTTY: boolean | undefined; + let savedEnv: Record; + + beforeEach(() => { + setMode("human"); // the runner has no TTY, but the bug only shows in human mode + mockCurrentEnv = "production"; + savedTTY = process.stderr.isTTY; + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + for (const k of ENV_KEYS) delete process.env[k]; + process.env.COLORTERM = "truecolor"; + }); + + afterEach(() => { + Object.defineProperty(process.stderr, "isTTY", { value: savedTTY, configurable: true }); + for (const k of ENV_KEYS) { + if (savedEnv[k] == null) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + }); + + test("output printed after the command resolves lands below the finished animation", async () => { + await switchEnv("staging"); + // The postAction hook prints the update notice as soon as the action's + // promise resolves — reproduce that exact ordering. + log.warn(NOTICE); + // On fixed code the animation completed before switchEnv() resolved, so + // this returns immediately; on a regression it waits (bounded) for the + // orphaned animation's trailing frames so the ordering assertion below + // fails with a precise message instead of a missing-cursor-restore -1. + const deadline = Date.now() + 2_000; + while (!captured.err.includes(CURSOR_SHOW) && Date.now() < deadline) { + await Bun.sleep(25); + } + + const err = captured.err; + const cursorRestoredAt = err.lastIndexOf(CURSOR_SHOW); + const noticeAt = err.indexOf(NOTICE); + expect(cursorRestoredAt).toBeGreaterThanOrEqual(0); + expect(noticeAt).toBeGreaterThan(cursorRestoredAt); + }, 10_000); +}); diff --git a/packages/cli-core/src/commands/unlink/index.ts b/packages/cli-core/src/commands/unlink/index.ts index eb9df1efd..3bd0c4c97 100644 --- a/packages/cli-core/src/commands/unlink/index.ts +++ b/packages/cli-core/src/commands/unlink/index.ts @@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise { await removeProfile(existing.path); log.data(`\nUnlinked ${cyan(label)} from ${dim(displayPath)}`); - outro(NEXT_STEPS.UNLINK); + await outro(NEXT_STEPS.UNLINK); } export function registerUnlink(program: Program): void { diff --git a/packages/cli-core/src/commands/update/index.ts b/packages/cli-core/src/commands/update/index.ts index d0b69d28f..486b5a953 100644 --- a/packages/cli-core/src/commands/update/index.ts +++ b/packages/cli-core/src/commands/update/index.ts @@ -273,7 +273,7 @@ export async function update(options: UpdateOptions): Promise { if (compareSemver(latest, CURRENT_VERSION) <= 0) { log.info(`${green("✓")} Already on latest (${CURRENT_VERSION})`); reportOtherInstalls(others, channel); - if (isHuman()) outro("Up to date"); + if (isHuman()) await outro("Up to date"); return; } @@ -320,7 +320,7 @@ export async function update(options: UpdateOptions): Promise { } } reportOtherInstalls(others, channel); - if (isHuman()) outro("Update required manual action"); + if (isHuman()) await outro("Update required manual action"); return; } @@ -347,7 +347,7 @@ export async function update(options: UpdateOptions): Promise { const shouldInstall = options.yes || (await confirmUpdate(CURRENT_VERSION, latest)); if (!shouldInstall) { - if (isHuman()) outro("Update cancelled"); + if (isHuman()) await outro("Update cancelled"); return; } @@ -424,7 +424,7 @@ export async function update(options: UpdateOptions): Promise { } if (isHuman()) { - outro(anyFailed ? "Update completed with errors" : `Successfully updated to ${latest}`); + await outro(anyFailed ? "Update completed with errors" : `Successfully updated to ${latest}`); } } diff --git a/packages/cli-core/src/commands/users/create.ts b/packages/cli-core/src/commands/users/create.ts index 973e9af12..3b75c8571 100644 --- a/packages/cli-core/src/commands/users/create.ts +++ b/packages/cli-core/src/commands/users/create.ts @@ -50,7 +50,7 @@ export async function create(options: CreateUserOptions): Promise { log.info("[dry-run] POST /v1/users"); log.blank(); log.info(JSON.stringify(redactUsersDisplayPayload(payload), null, 2)); - if (shouldWrap) outro(); + if (shouldWrap) await outro(); return; } @@ -86,24 +86,24 @@ export async function create(options: CreateUserOptions): Promise { if (shouldWrap) { const userId = extractUserId(response.body); if (userId) { - outro([`Run \`clerk users open ${userId}\` to view this user in the dashboard`]); + await outro([`Run \`clerk users open ${userId}\` to view this user in the dashboard`]); } else { - outro(); + await outro(); } } } catch (error) { if (handleUsersBapiError(error, "Failed to create user", resolved)) { - if (shouldWrap) outro("Failed"); + if (shouldWrap) await outro("Failed"); return; } if (handleBapiError(error)) { - if (shouldWrap) outro("Failed"); + if (shouldWrap) await outro("Failed"); return; } if (shouldWrap && error instanceof UserAbortError) { pausedOutro(); } else if (shouldWrap) { - outro("Failed"); + await outro("Failed"); } throw error; } diff --git a/packages/cli-core/src/commands/users/list.ts b/packages/cli-core/src/commands/users/list.ts index ab25ed53a..9e6519e84 100644 --- a/packages/cli-core/src/commands/users/list.ts +++ b/packages/cli-core/src/commands/users/list.ts @@ -216,9 +216,9 @@ export async function list(options: UsersListOptions = {}): Promise { if (closeStatus === "paused") { pausedOutro(); } else if (closeStatus === "failed") { - outro("Failed"); + await outro("Failed"); } else if (closeStatus === "success") { - outro(); + await outro(); } } } diff --git a/packages/cli-core/src/commands/users/menu.ts b/packages/cli-core/src/commands/users/menu.ts index bc5312ead..5e86ad82c 100644 --- a/packages/cli-core/src/commands/users/menu.ts +++ b/packages/cli-core/src/commands/users/menu.ts @@ -39,5 +39,5 @@ export async function usersMenu(targeting: UsersActionTargeting = {}): Promise { ); } - outro(); + await outro(); return; } @@ -229,7 +229,7 @@ export async function open(options: UsersOpenOptions = {}): Promise { ); } - outro(); + await outro(); } registerUsersAction({ diff --git a/packages/cli-core/src/lib/version.macro.ts b/packages/cli-core/src/lib/version.macro.ts index 55d721e5c..07aafb3c3 100644 --- a/packages/cli-core/src/lib/version.macro.ts +++ b/packages/cli-core/src/lib/version.macro.ts @@ -2,23 +2,11 @@ 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 @@ -53,22 +41,15 @@ function describeCheckout(): string | undefined { } /** - * Resolve the current version and dev-build status during Bun transpilation. + * Derive the checkout-based fallback version during Bun transpilation. * - * Bun inlines the returned values at the macro call, so compiled binaries do - * not execute Git commands or classify versions at runtime. + * Bun inlines the returned string at the macro call, so compiled binaries do + * not execute Git commands at runtime. The `CLI_VERSION` define is deliberately + * NOT read here: since Bun 1.4 macros execute in a sealed transpiler context + * that `--define` globals no longer reach, so the injected-vs-fallback choice + * lives in `version.ts`, where define substitution still applies. */ -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), - }; +export function resolveFallbackVersionAtBuildTime(): string { + const checkout = describeCheckout(); + return `${cliPackage.version}-${DEV_TAG}${checkout ? `.${checkout}` : ""}`; } diff --git a/packages/cli-core/src/lib/version.ts b/packages/cli-core/src/lib/version.ts index 0aafd2006..583a5dbb1 100644 --- a/packages/cli-core/src/lib/version.ts +++ b/packages/cli-core/src/lib/version.ts @@ -12,10 +12,17 @@ * result into the module. Compiled binaries therefore retain their checkout * metadata without executing Git commands at runtime. * + * The `CLI_VERSION` check happens HERE, not in the macro: since Bun 1.4, + * macros run in a sealed transpiler context that `--define` globals do not + * reach, while defines still substitute identifiers in transpiled modules + * like this one. With a define present the ternary below collapses to the + * injected literal at build time; without one, `typeof` guards the absent + * global safely. + * * Anything that displays a version (`--version`, the outbound user agent, or * 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. + * even when that is itself a dev version. `IS_DEV_BUILD` is derived from the + * same value at module load — a pure string check, never Git. * * Two callers care about the dev/release distinction rather than the string: * `credential-store` namespaces the macOS keychain away from release builds, @@ -23,16 +30,25 @@ * `IS_DEV_BUILD`. */ -import { resolveVersionAtBuildTime } from "./version.macro.ts" with { type: "macro" }; +import { resolveFallbackVersionAtBuildTime } from "./version.macro.ts" with { type: "macro" }; + +const DEV_TAG = "dev"; + +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}.`); +} -const { currentVersion, isDevBuild } = resolveVersionAtBuildTime(); +const fallbackVersion = resolveFallbackVersionAtBuildTime(); /** * The version embedded while this module was transpiled or compiled. */ -export const CURRENT_VERSION = currentVersion; +export const CURRENT_VERSION = typeof CLI_VERSION === "undefined" ? fallbackVersion : CLI_VERSION; /** * Whether the current build carries a development version. */ -export const IS_DEV_BUILD = isDevBuild; +export const IS_DEV_BUILD = isDevVersion(CURRENT_VERSION);