From 4777a135aba2a6325c4e3afcef6ba8d1c0d87455 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Fri, 21 Aug 2026 13:58:49 -0300 Subject: [PATCH 1/3] fix(cli): await the next-steps outro so post-command output prints below it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 outro(). The command's promise resolved mid-animation, so the postAction update check printed the "Update available" notice at the parked cursor position — overwriting the step lines and leaving a stray duplicate "Next steps" header. Await every outro() call so output printed after a command lands below the finished block. Adds a regression test that runs the real spinner/gradient on a forced-interactive TTY and asserts the notice prints after the animation's cursor-restore. --- .changeset/slack-thread-fix-issue.md | 7 ++ packages/cli-core/src/commands/api/index.ts | 4 +- packages/cli-core/src/commands/apps/create.ts | 4 +- packages/cli-core/src/commands/apps/list.ts | 4 +- packages/cli-core/src/commands/auth/login.ts | 6 +- packages/cli-core/src/commands/auth/logout.ts | 2 +- packages/cli-core/src/commands/config/push.ts | 4 +- .../cli-core/src/commands/deploy/index.ts | 12 +-- .../cli-core/src/commands/doctor/index.ts | 4 +- packages/cli-core/src/commands/init/index.ts | 4 +- packages/cli-core/src/commands/link/index.ts | 4 +- packages/cli-core/src/commands/open/index.ts | 4 +- .../cli-core/src/commands/switch-env/index.ts | 10 +-- .../switch-env/update-notice-race.test.ts | 81 +++++++++++++++++++ .../cli-core/src/commands/unlink/index.ts | 2 +- .../cli-core/src/commands/update/index.ts | 8 +- .../cli-core/src/commands/users/create.ts | 12 +-- packages/cli-core/src/commands/users/list.ts | 4 +- packages/cli-core/src/commands/users/menu.ts | 2 +- packages/cli-core/src/commands/users/open.ts | 4 +- 20 files changed, 135 insertions(+), 47 deletions(-) create mode 100644 .changeset/slack-thread-fix-issue.md create mode 100644 packages/cli-core/src/commands/switch-env/update-notice-race.test.ts 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/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); + // Drain any animation frames still in flight so the buffer is complete + // (and so a buggy run doesn't leak writes into the next test). + await Bun.sleep(700); + + 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({ From 186689ad701b1618a8ab10719a983a58d4550e2b Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Fri, 21 Aug 2026 14:16:39 -0300 Subject: [PATCH 2/3] fix(build): read the CLI_VERSION define outside the version macro for Bun 1.4 Bun 1.4 runs macros in a sealed transpiler context that --define globals no longer reach, so resolveVersionAtBuildTime() always took the checkout-derived fallback and the compiled-version tests failed on CI (which floats to the latest Bun satisfying engines). Move the injected-vs-fallback choice and dev classification into version.ts module scope, where define substitution still applies on every Bun version. The macro now only derives the Git checkout fallback, so the compiled CLI still never runs Git at runtime. Verified: version tests and the full suite pass under both Bun 1.3.14 and 1.4.0. --- .../slack-thread-fix-issue-bun-1-4-define.md | 7 ++++ .claude/rules/versioning.md | 11 ++++-- CLAUDE.md | 2 +- packages/cli-core/src/lib/version.macro.ts | 37 +++++-------------- packages/cli-core/src/lib/version.ts | 28 +++++++++++--- 5 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 .changeset/slack-thread-fix-issue-bun-1-4-define.md 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/.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/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); From fa65fd72b715798274798f38d3af64ebff2d5310 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Fri, 21 Aug 2026 14:22:38 -0300 Subject: [PATCH 3/3] test(cli): replace the fixed drain sleep with a bounded cursor-restore poll On fixed code the animation completes before switchEnv() resolves, so the 700ms unconditional sleep was pure dead time on every green run. Poll for the cursor-restore escape with a 2s bound instead: green runs return immediately, and a regression still drains the orphaned animation so the ordering assertion fails with its precise message. --- .../commands/switch-env/update-notice-race.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/switch-env/update-notice-race.test.ts b/packages/cli-core/src/commands/switch-env/update-notice-race.test.ts index 87f7d72cb..01398e011 100644 --- a/packages/cli-core/src/commands/switch-env/update-notice-race.test.ts +++ b/packages/cli-core/src/commands/switch-env/update-notice-race.test.ts @@ -68,9 +68,14 @@ describe("switch-env update-notice race", () => { // The postAction hook prints the update notice as soon as the action's // promise resolves — reproduce that exact ordering. log.warn(NOTICE); - // Drain any animation frames still in flight so the buffer is complete - // (and so a buggy run doesn't leak writes into the next test). - await Bun.sleep(700); + // 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);