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
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue-bun-1-4-define.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions .claude/rules/versioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff 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 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: `<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 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`.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise<v
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro(nextSteps);
await outro(nextSteps);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/list.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,9 +60,9 @@ export async function list(options: AppsOptions = {}): Promise<void> {
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}
return existingSession;
}
Expand All@@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
outro();
await outro();
throwUserAbort();
}
}
Expand DownExpand Up@@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}

return userInfo;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,5 @@ export async function logout(): Promise<void> {
);
}

outro(NEXT_STEPS.LOGOUT);
await outro(NEXT_STEPS.LOGOUT);
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/config/push.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise<vo
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,7 @@ export async function deploy(_options: DeployOptions = {}) {
throw error;
}
if (error instanceof DeployPausedError && isInsideGutter()) {
outro("Paused");
await outro("Paused");
}
if (error instanceof UserAbortError && isInsideGutter()) {
pausedOutro(pausedOperationNotice());
Expand All@@ -103,7 +103,7 @@ export async function deploy(_options: DeployOptions = {}) {
// Successful and paused paths call outro themselves. This balances the
// intro gutter if an unexpected error escapes.
if (isInsideGutter()) {
outro("Failed");
await outro("Failed");
}
}
}
Expand DownExpand Up@@ -154,7 +154,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
const proceed = await confirmProceed();
if (!proceed) {
log.info("No changes were made.");
outro("Cancelled");
await outro("Cancelled");
return;
}

Expand DownExpand Up@@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise<void> {
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;
}

Expand DownExpand Up@@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise<boolea

log.blank();
log.info("No production instance was created.");
outro("Cancelled");
await outro("Cancelled");
return false;
}

Expand DownExpand Up@@ -639,7 +639,7 @@ async function finishDeploy(
fallback: bold,
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
});
outro("Success");
await outro("Success");
}

export function registerDeploy(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
return;
}
}
Expand All@@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
}

export function registerDoctor(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) {
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
}
outro("Done");
await outro("Done");
return;
}

Expand All@@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

outro("Done");
await outro("Done");
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/link/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/open/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ export async function openDashboard(
);
}

outro();
await outro();
}

/**
Expand DownExpand Up@@ -196,7 +196,7 @@ async function openKeylessDashboard(
);
}

outro();
await outro();
}

export function registerOpen(program: Program): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
outro();
await outro();
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
outro();
await outro();
return;
}
}
Expand All@@ -68,7 +68,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi

if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
outro();
await outro();
return;
}

Expand All@@ -81,10 +81,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${target}.`);
outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
await outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
return;
}
outro(NEXT_STEPS.SWITCH_ENV);
await outro(NEXT_STEPS.SWITCH_ENV);
}

export function registerSwitchEnv(program: Program): void {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
/**
* Repro for the garbled "Update available" output reported in Slack: the
* next-steps outro animation parks the cursor on the header line for ~450ms,
* so anything printed after the command's promise resolves (the postAction
* update notice) must not run until the animation has finished. If the command
* resolves early, the notice lands mid-block and overwrites the step lines.
*
* Unlike index.test.ts this file deliberately uses the REAL spinner/gradient
* modules on a forced-interactive TTY so the animation actually runs.
*/
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { log } from "../../lib/log.ts";
import { setMode } from "../../mode.ts";
import { useCaptureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const MOCK_ENVS = ["production", "staging"];
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
...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<string, string | undefined>;

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);
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {

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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue-bun-1-4-define.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions .claude/rules/versioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff 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 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: `<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 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`.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise<v
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro(nextSteps);
await outro(nextSteps);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/list.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,9 +60,9 @@ export async function list(options: AppsOptions = {}): Promise<void> {
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}
return existingSession;
}
Expand All@@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
outro();
await outro();
throwUserAbort();
}
}
Expand DownExpand Up@@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}

return userInfo;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,5 @@ export async function logout(): Promise<void> {
);
}

outro(NEXT_STEPS.LOGOUT);
await outro(NEXT_STEPS.LOGOUT);
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/config/push.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise<vo
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,7 @@ export async function deploy(_options: DeployOptions = {}) {
throw error;
}
if (error instanceof DeployPausedError && isInsideGutter()) {
outro("Paused");
await outro("Paused");
}
if (error instanceof UserAbortError && isInsideGutter()) {
pausedOutro(pausedOperationNotice());
Expand All@@ -103,7 +103,7 @@ export async function deploy(_options: DeployOptions = {}) {
// Successful and paused paths call outro themselves. This balances the
// intro gutter if an unexpected error escapes.
if (isInsideGutter()) {
outro("Failed");
await outro("Failed");
}
}
}
Expand DownExpand Up@@ -154,7 +154,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
const proceed = await confirmProceed();
if (!proceed) {
log.info("No changes were made.");
outro("Cancelled");
await outro("Cancelled");
return;
}

Expand DownExpand Up@@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise<void> {
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;
}

Expand DownExpand Up@@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise<boolea

log.blank();
log.info("No production instance was created.");
outro("Cancelled");
await outro("Cancelled");
return false;
}

Expand DownExpand Up@@ -639,7 +639,7 @@ async function finishDeploy(
fallback: bold,
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
});
outro("Success");
await outro("Success");
}

export function registerDeploy(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
return;
}
}
Expand All@@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
}

export function registerDoctor(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) {
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
}
outro("Done");
await outro("Done");
return;
}

Expand All@@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

outro("Done");
await outro("Done");
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/link/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/open/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ export async function openDashboard(
);
}

outro();
await outro();
}

/**
Expand DownExpand Up@@ -196,7 +196,7 @@ async function openKeylessDashboard(
);
}

outro();
await outro();
}

export function registerOpen(program: Program): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
outro();
await outro();
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
outro();
await outro();
return;
}
}
Expand All@@ -68,7 +68,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi

if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
outro();
await outro();
return;
}

Expand All@@ -81,10 +81,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${target}.`);
outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
await outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
return;
}
outro(NEXT_STEPS.SWITCH_ENV);
await outro(NEXT_STEPS.SWITCH_ENV);
}

export function registerSwitchEnv(program: Program): void {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
/**
* Repro for the garbled "Update available" output reported in Slack: the
* next-steps outro animation parks the cursor on the header line for ~450ms,
* so anything printed after the command's promise resolves (the postAction
* update notice) must not run until the animation has finished. If the command
* resolves early, the notice lands mid-block and overwrites the step lines.
*
* Unlike index.test.ts this file deliberately uses the REAL spinner/gradient
* modules on a forced-interactive TTY so the animation actually runs.
*/
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { log } from "../../lib/log.ts";
import { setMode } from "../../mode.ts";
import { useCaptureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const MOCK_ENVS = ["production", "staging"];
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
...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<string, string | undefined>;

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);
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {

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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue-bun-1-4-define.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions .claude/rules/versioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff 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 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: `<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 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`.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise<v
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro(nextSteps);
await outro(nextSteps);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/list.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,9 +60,9 @@ export async function list(options: AppsOptions = {}): Promise<void> {
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}
return existingSession;
}
Expand All@@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
outro();
await outro();
throwUserAbort();
}
}
Expand DownExpand Up@@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}

return userInfo;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,5 @@ export async function logout(): Promise<void> {
);
}

outro(NEXT_STEPS.LOGOUT);
await outro(NEXT_STEPS.LOGOUT);
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/config/push.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise<vo
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,7 @@ export async function deploy(_options: DeployOptions = {}) {
throw error;
}
if (error instanceof DeployPausedError && isInsideGutter()) {
outro("Paused");
await outro("Paused");
}
if (error instanceof UserAbortError && isInsideGutter()) {
pausedOutro(pausedOperationNotice());
Expand All@@ -103,7 +103,7 @@ export async function deploy(_options: DeployOptions = {}) {
// Successful and paused paths call outro themselves. This balances the
// intro gutter if an unexpected error escapes.
if (isInsideGutter()) {
outro("Failed");
await outro("Failed");
}
}
}
Expand DownExpand Up@@ -154,7 +154,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
const proceed = await confirmProceed();
if (!proceed) {
log.info("No changes were made.");
outro("Cancelled");
await outro("Cancelled");
return;
}

Expand DownExpand Up@@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise<void> {
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;
}

Expand DownExpand Up@@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise<boolea

log.blank();
log.info("No production instance was created.");
outro("Cancelled");
await outro("Cancelled");
return false;
}

Expand DownExpand Up@@ -639,7 +639,7 @@ async function finishDeploy(
fallback: bold,
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
});
outro("Success");
await outro("Success");
}

export function registerDeploy(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
return;
}
}
Expand All@@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
}

export function registerDoctor(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) {
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
}
outro("Done");
await outro("Done");
return;
}

Expand All@@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

outro("Done");
await outro("Done");
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/link/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/open/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ export async function openDashboard(
);
}

outro();
await outro();
}

/**
Expand DownExpand Up@@ -196,7 +196,7 @@ async function openKeylessDashboard(
);
}

outro();
await outro();
}

export function registerOpen(program: Program): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
outro();
await outro();
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
outro();
await outro();
return;
}
}
Expand All@@ -68,7 +68,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi

if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
outro();
await outro();
return;
}

Expand All@@ -81,10 +81,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${target}.`);
outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
await outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
return;
}
outro(NEXT_STEPS.SWITCH_ENV);
await outro(NEXT_STEPS.SWITCH_ENV);
}

export function registerSwitchEnv(program: Program): void {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
/**
* Repro for the garbled "Update available" output reported in Slack: the
* next-steps outro animation parks the cursor on the header line for ~450ms,
* so anything printed after the command's promise resolves (the postAction
* update notice) must not run until the animation has finished. If the command
* resolves early, the notice lands mid-block and overwrites the step lines.
*
* Unlike index.test.ts this file deliberately uses the REAL spinner/gradient
* modules on a forced-interactive TTY so the animation actually runs.
*/
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { log } from "../../lib/log.ts";
import { setMode } from "../../mode.ts";
import { useCaptureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const MOCK_ENVS = ["production", "staging"];
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
...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<string, string | undefined>;

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);
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {

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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue-bun-1-4-define.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions .claude/rules/versioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff 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 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: `<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 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`.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise<v
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro(nextSteps);
await outro(nextSteps);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/list.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,9 +60,9 @@ export async function list(options: AppsOptions = {}): Promise<void> {
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}
return existingSession;
}
Expand All@@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
outro();
await outro();
throwUserAbort();
}
}
Expand DownExpand Up@@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}

return userInfo;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,5 @@ export async function logout(): Promise<void> {
);
}

outro(NEXT_STEPS.LOGOUT);
await outro(NEXT_STEPS.LOGOUT);
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/config/push.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise<vo
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,7 @@ export async function deploy(_options: DeployOptions = {}) {
throw error;
}
if (error instanceof DeployPausedError && isInsideGutter()) {
outro("Paused");
await outro("Paused");
}
if (error instanceof UserAbortError && isInsideGutter()) {
pausedOutro(pausedOperationNotice());
Expand All@@ -103,7 +103,7 @@ export async function deploy(_options: DeployOptions = {}) {
// Successful and paused paths call outro themselves. This balances the
// intro gutter if an unexpected error escapes.
if (isInsideGutter()) {
outro("Failed");
await outro("Failed");
}
}
}
Expand DownExpand Up@@ -154,7 +154,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
const proceed = await confirmProceed();
if (!proceed) {
log.info("No changes were made.");
outro("Cancelled");
await outro("Cancelled");
return;
}

Expand DownExpand Up@@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise<void> {
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;
}

Expand DownExpand Up@@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise<boolea

log.blank();
log.info("No production instance was created.");
outro("Cancelled");
await outro("Cancelled");
return false;
}

Expand DownExpand Up@@ -639,7 +639,7 @@ async function finishDeploy(
fallback: bold,
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
});
outro("Success");
await outro("Success");
}

export function registerDeploy(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
return;
}
}
Expand All@@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
}

export function registerDoctor(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) {
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
}
outro("Done");
await outro("Done");
return;
}

Expand All@@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

outro("Done");
await outro("Done");
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/link/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/open/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ export async function openDashboard(
);
}

outro();
await outro();
}

/**
Expand DownExpand Up@@ -196,7 +196,7 @@ async function openKeylessDashboard(
);
}

outro();
await outro();
}

export function registerOpen(program: Program): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
outro();
await outro();
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
outro();
await outro();
return;
}
}
Expand All@@ -68,7 +68,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi

if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
outro();
await outro();
return;
}

Expand All@@ -81,10 +81,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${target}.`);
outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
await outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
return;
}
outro(NEXT_STEPS.SWITCH_ENV);
await outro(NEXT_STEPS.SWITCH_ENV);
}

export function registerSwitchEnv(program: Program): void {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
/**
* Repro for the garbled "Update available" output reported in Slack: the
* next-steps outro animation parks the cursor on the header line for ~450ms,
* so anything printed after the command's promise resolves (the postAction
* update notice) must not run until the animation has finished. If the command
* resolves early, the notice lands mid-block and overwrites the step lines.
*
* Unlike index.test.ts this file deliberately uses the REAL spinner/gradient
* modules on a forced-interactive TTY so the animation actually runs.
*/
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { log } from "../../lib/log.ts";
import { setMode } from "../../mode.ts";
import { useCaptureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const MOCK_ENVS = ["production", "staging"];
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
...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<string, string | undefined>;

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);
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {

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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue-bun-1-4-define.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions .claude/rules/versioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff 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 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: `<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 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`.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise<v
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro(nextSteps);
await outro(nextSteps);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/list.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,9 +60,9 @@ export async function list(options: AppsOptions = {}): Promise<void> {
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}
return existingSession;
}
Expand All@@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
outro();
await outro();
throwUserAbort();
}
}
Expand DownExpand Up@@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}

return userInfo;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,5 @@ export async function logout(): Promise<void> {
);
}

outro(NEXT_STEPS.LOGOUT);
await outro(NEXT_STEPS.LOGOUT);
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/config/push.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise<vo
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,7 @@ export async function deploy(_options: DeployOptions = {}) {
throw error;
}
if (error instanceof DeployPausedError && isInsideGutter()) {
outro("Paused");
await outro("Paused");
}
if (error instanceof UserAbortError && isInsideGutter()) {
pausedOutro(pausedOperationNotice());
Expand All@@ -103,7 +103,7 @@ export async function deploy(_options: DeployOptions = {}) {
// Successful and paused paths call outro themselves. This balances the
// intro gutter if an unexpected error escapes.
if (isInsideGutter()) {
outro("Failed");
await outro("Failed");
}
}
}
Expand DownExpand Up@@ -154,7 +154,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
const proceed = await confirmProceed();
if (!proceed) {
log.info("No changes were made.");
outro("Cancelled");
await outro("Cancelled");
return;
}

Expand DownExpand Up@@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise<void> {
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;
}

Expand DownExpand Up@@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise<boolea

log.blank();
log.info("No production instance was created.");
outro("Cancelled");
await outro("Cancelled");
return false;
}

Expand DownExpand Up@@ -639,7 +639,7 @@ async function finishDeploy(
fallback: bold,
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
});
outro("Success");
await outro("Success");
}

export function registerDeploy(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
return;
}
}
Expand All@@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
}

export function registerDoctor(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) {
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
}
outro("Done");
await outro("Done");
return;
}

Expand All@@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

outro("Done");
await outro("Done");
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/link/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/open/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ export async function openDashboard(
);
}

outro();
await outro();
}

/**
Expand DownExpand Up@@ -196,7 +196,7 @@ async function openKeylessDashboard(
);
}

outro();
await outro();
}

export function registerOpen(program: Program): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
outro();
await outro();
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
outro();
await outro();
return;
}
}
Expand All@@ -68,7 +68,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi

if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
outro();
await outro();
return;
}

Expand All@@ -81,10 +81,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${target}.`);
outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
await outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
return;
}
outro(NEXT_STEPS.SWITCH_ENV);
await outro(NEXT_STEPS.SWITCH_ENV);
}

export function registerSwitchEnv(program: Program): void {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
/**
* Repro for the garbled "Update available" output reported in Slack: the
* next-steps outro animation parks the cursor on the header line for ~450ms,
* so anything printed after the command's promise resolves (the postAction
* update notice) must not run until the animation has finished. If the command
* resolves early, the notice lands mid-block and overwrites the step lines.
*
* Unlike index.test.ts this file deliberately uses the REAL spinner/gradient
* modules on a forced-interactive TTY so the animation actually runs.
*/
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { log } from "../../lib/log.ts";
import { setMode } from "../../mode.ts";
import { useCaptureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const MOCK_ENVS = ["production", "staging"];
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
...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<string, string | undefined>;

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);
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {

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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue-bun-1-4-define.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions .claude/rules/versioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff 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 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: `<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 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`.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise<v
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro(nextSteps);
await outro(nextSteps);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/list.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,9 +60,9 @@ export async function list(options: AppsOptions = {}): Promise<void> {
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}
return existingSession;
}
Expand All@@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
outro();
await outro();
throwUserAbort();
}
}
Expand DownExpand Up@@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}

return userInfo;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,5 @@ export async function logout(): Promise<void> {
);
}

outro(NEXT_STEPS.LOGOUT);
await outro(NEXT_STEPS.LOGOUT);
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/config/push.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise<vo
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,7 @@ export async function deploy(_options: DeployOptions = {}) {
throw error;
}
if (error instanceof DeployPausedError && isInsideGutter()) {
outro("Paused");
await outro("Paused");
}
if (error instanceof UserAbortError && isInsideGutter()) {
pausedOutro(pausedOperationNotice());
Expand All@@ -103,7 +103,7 @@ export async function deploy(_options: DeployOptions = {}) {
// Successful and paused paths call outro themselves. This balances the
// intro gutter if an unexpected error escapes.
if (isInsideGutter()) {
outro("Failed");
await outro("Failed");
}
}
}
Expand DownExpand Up@@ -154,7 +154,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
const proceed = await confirmProceed();
if (!proceed) {
log.info("No changes were made.");
outro("Cancelled");
await outro("Cancelled");
return;
}

Expand DownExpand Up@@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise<void> {
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;
}

Expand DownExpand Up@@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise<boolea

log.blank();
log.info("No production instance was created.");
outro("Cancelled");
await outro("Cancelled");
return false;
}

Expand DownExpand Up@@ -639,7 +639,7 @@ async function finishDeploy(
fallback: bold,
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
});
outro("Success");
await outro("Success");
}

export function registerDeploy(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
return;
}
}
Expand All@@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
}

export function registerDoctor(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) {
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
}
outro("Done");
await outro("Done");
return;
}

Expand All@@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

outro("Done");
await outro("Done");
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/link/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/open/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ export async function openDashboard(
);
}

outro();
await outro();
}

/**
Expand DownExpand Up@@ -196,7 +196,7 @@ async function openKeylessDashboard(
);
}

outro();
await outro();
}

export function registerOpen(program: Program): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
outro();
await outro();
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
outro();
await outro();
return;
}
}
Expand All@@ -68,7 +68,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi

if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
outro();
await outro();
return;
}

Expand All@@ -81,10 +81,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${target}.`);
outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
await outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
return;
}
outro(NEXT_STEPS.SWITCH_ENV);
await outro(NEXT_STEPS.SWITCH_ENV);
}

export function registerSwitchEnv(program: Program): void {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
/**
* Repro for the garbled "Update available" output reported in Slack: the
* next-steps outro animation parks the cursor on the header line for ~450ms,
* so anything printed after the command's promise resolves (the postAction
* update notice) must not run until the animation has finished. If the command
* resolves early, the notice lands mid-block and overwrites the step lines.
*
* Unlike index.test.ts this file deliberately uses the REAL spinner/gradient
* modules on a forced-interactive TTY so the animation actually runs.
*/
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { log } from "../../lib/log.ts";
import { setMode } from "../../mode.ts";
import { useCaptureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const MOCK_ENVS = ["production", "staging"];
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
...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<string, string | undefined>;

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);
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {

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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue-bun-1-4-define.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions .claude/rules/versioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff 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 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: `<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 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`.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise<v
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro(nextSteps);
await outro(nextSteps);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/list.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,9 +60,9 @@ export async function list(options: AppsOptions = {}): Promise<void> {
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}
return existingSession;
}
Expand All@@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
outro();
await outro();
throwUserAbort();
}
}
Expand DownExpand Up@@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}

return userInfo;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,5 @@ export async function logout(): Promise<void> {
);
}

outro(NEXT_STEPS.LOGOUT);
await outro(NEXT_STEPS.LOGOUT);
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/config/push.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise<vo
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,7 @@ export async function deploy(_options: DeployOptions = {}) {
throw error;
}
if (error instanceof DeployPausedError && isInsideGutter()) {
outro("Paused");
await outro("Paused");
}
if (error instanceof UserAbortError && isInsideGutter()) {
pausedOutro(pausedOperationNotice());
Expand All@@ -103,7 +103,7 @@ export async function deploy(_options: DeployOptions = {}) {
// Successful and paused paths call outro themselves. This balances the
// intro gutter if an unexpected error escapes.
if (isInsideGutter()) {
outro("Failed");
await outro("Failed");
}
}
}
Expand DownExpand Up@@ -154,7 +154,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
const proceed = await confirmProceed();
if (!proceed) {
log.info("No changes were made.");
outro("Cancelled");
await outro("Cancelled");
return;
}

Expand DownExpand Up@@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise<void> {
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;
}

Expand DownExpand Up@@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise<boolea

log.blank();
log.info("No production instance was created.");
outro("Cancelled");
await outro("Cancelled");
return false;
}

Expand DownExpand Up@@ -639,7 +639,7 @@ async function finishDeploy(
fallback: bold,
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
});
outro("Success");
await outro("Success");
}

export function registerDeploy(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
return;
}
}
Expand All@@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
}

export function registerDoctor(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) {
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
}
outro("Done");
await outro("Done");
return;
}

Expand All@@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

outro("Done");
await outro("Done");
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/link/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/open/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ export async function openDashboard(
);
}

outro();
await outro();
}

/**
Expand DownExpand Up@@ -196,7 +196,7 @@ async function openKeylessDashboard(
);
}

outro();
await outro();
}

export function registerOpen(program: Program): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
outro();
await outro();
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
outro();
await outro();
return;
}
}
Expand All@@ -68,7 +68,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi

if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
outro();
await outro();
return;
}

Expand All@@ -81,10 +81,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${target}.`);
outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
await outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
return;
}
outro(NEXT_STEPS.SWITCH_ENV);
await outro(NEXT_STEPS.SWITCH_ENV);
}

export function registerSwitchEnv(program: Program): void {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
/**
* Repro for the garbled "Update available" output reported in Slack: the
* next-steps outro animation parks the cursor on the header line for ~450ms,
* so anything printed after the command's promise resolves (the postAction
* update notice) must not run until the animation has finished. If the command
* resolves early, the notice lands mid-block and overwrites the step lines.
*
* Unlike index.test.ts this file deliberately uses the REAL spinner/gradient
* modules on a forced-interactive TTY so the animation actually runs.
*/
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { log } from "../../lib/log.ts";
import { setMode } from "../../mode.ts";
import { useCaptureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const MOCK_ENVS = ["production", "staging"];
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
...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<string, string | undefined>;

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);
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {

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 {
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue-bun-1-4-define.md
Original file line numberDiff line numberDiff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/slack-thread-fix-issue.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions .claude/rules/versioning.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line numberDiff line numberDiff 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 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: `<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 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`.
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/api/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/create.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -41,9 +41,9 @@ export async function create(name: string, options: AppsOptions = {}): Promise<v
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro(nextSteps);
await outro(nextSteps);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/apps/list.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -60,9 +60,9 @@ export async function list(options: AppsOptions = {}): Promise<void> {
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions packages/cli-core/src/commands/auth/login.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -140,7 +140,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}
return existingSession;
}
Expand All@@ -151,7 +151,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
default: false,
});
if (!reauthenticate) {
outro();
await outro();
throwUserAbort();
}
}
Expand DownExpand Up@@ -188,7 +188,7 @@ export async function login(options: LoginOptions = {}): Promise<UserInfo> {
if (showNextSteps) {
await outro(await loginNextSteps(claimResult));
} else {
outro("Done");
await outro("Done");
}

return userInfo;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/auth/logout.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,5 +26,5 @@ export async function logout(): Promise<void> {
);
}

outro(NEXT_STEPS.LOGOUT);
await outro(NEXT_STEPS.LOGOUT);
}
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/config/push.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -137,9 +137,9 @@ async function configPush(options: ConfigPushOptions, op: Operation): Promise<vo
if (closeStatus === "paused") {
pausedOutro();
} else if (closeStatus === "failed") {
outro("Failed");
await outro("Failed");
} else if (closeStatus === "success") {
outro();
await outro();
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions packages/cli-core/src/commands/deploy/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -92,7 +92,7 @@ export async function deploy(_options: DeployOptions = {}) {
throw error;
}
if (error instanceof DeployPausedError && isInsideGutter()) {
outro("Paused");
await outro("Paused");
}
if (error instanceof UserAbortError && isInsideGutter()) {
pausedOutro(pausedOperationNotice());
Expand All@@ -103,7 +103,7 @@ export async function deploy(_options: DeployOptions = {}) {
// Successful and paused paths call outro themselves. This balances the
// intro gutter if an unexpected error escapes.
if (isInsideGutter()) {
outro("Failed");
await outro("Failed");
}
}
}
Expand DownExpand Up@@ -154,7 +154,7 @@ async function startNewDeploy(ctx: DeployContext): Promise<void> {
const proceed = await confirmProceed();
if (!proceed) {
log.info("No changes were made.");
outro("Cancelled");
await outro("Cancelled");
return;
}

Expand DownExpand Up@@ -233,7 +233,7 @@ async function reconcileExistingDeploy(ctx: DeployContext): Promise<void> {
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;
}

Expand DownExpand Up@@ -361,7 +361,7 @@ async function confirmProductionInstanceCreation(domain: string): Promise<boolea

log.blank();
log.info("No production instance was created.");
outro("Cancelled");
await outro("Cancelled");
return false;
}

Expand DownExpand Up@@ -639,7 +639,7 @@ async function finishDeploy(
fallback: bold,
body: `${applyPrefix(nextStepsBody(ctx.appId, productionInstanceId))}\n`,
});
outro("Success");
await outro("Success");
}

export function registerDeploy(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -128,7 +128,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
return;
}
}
Expand All@@ -139,7 +139,7 @@ export async function doctor(options: DoctorOptions = {}): Promise<void> {
code: ERROR_CODE.DOCTOR_FAILED,
});
}
outro("All checks passing");
await outro("All checks passing");
}

export function registerDoctor(program: Program): void {
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/init/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -160,7 +160,7 @@ export async function init(options: InitOptions = {}) {
if (agent && strategy === "manual") {
printBootstrapManualSetupInfo(ctx.framework);
}
outro("Done");
await outro("Done");
return;
}

Expand All@@ -183,7 +183,7 @@ export async function init(options: InitOptions = {}) {
printBootstrapNextSteps(bootstrap, strategy === "keyless");
}

outro("Done");
await outro("Done");
}

/**
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/link/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,13 +61,13 @@ export async function link(options: LinkOptions = {}): Promise<void> {
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;
}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-core/src/commands/open/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ export async function openDashboard(
);
}

outro();
await outro();
}

/**
Expand DownExpand Up@@ -196,7 +196,7 @@ async function openKeylessDashboard(
);
}

outro();
await outro();
}

export function registerOpen(program: Program): void {
Expand Down
10 changes: 5 additions & 5 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,12 +48,12 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
outro();
await outro();
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
outro();
await outro();
return;
}
}
Expand All@@ -68,7 +68,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi

if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
outro();
await outro();
return;
}

Expand All@@ -81,10 +81,10 @@ export async function switchEnv(environmentArg: string | undefined): Promise<voi
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${target}.`);
outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
await outro(NEXT_STEPS.SWITCH_ENV_NO_TOKEN);
return;
}
outro(NEXT_STEPS.SWITCH_ENV);
await outro(NEXT_STEPS.SWITCH_ENV);
}

export function registerSwitchEnv(program: Program): void {
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
/**
* Repro for the garbled "Update available" output reported in Slack: the
* next-steps outro animation parks the cursor on the header line for ~450ms,
* so anything printed after the command's promise resolves (the postAction
* update notice) must not run until the animation has finished. If the command
* resolves early, the notice lands mid-block and overwrites the step lines.
*
* Unlike index.test.ts this file deliberately uses the REAL spinner/gradient
* modules on a forced-interactive TTY so the animation actually runs.
*/
import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test";
import { log } from "../../lib/log.ts";
import { setMode } from "../../mode.ts";
import { useCaptureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";

const MOCK_ENVS = ["production", "staging"];
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
...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<string, string | undefined>;

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);
});
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -45,7 +45,7 @@ export async function unlink(options: UnlinkOptions = {}): Promise<void> {

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 {
Expand Down
Loading