fix(auth): give each login failure its own error code and stage - #442

Merged
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes
Aug 24, 2026
Merged

fix(auth): give each login failure its own error code and stage#442
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes

Conversation

@djgould

@djgoulddjgould commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #441 — retarget to main once it merges.

Every failure in the auth login browser flow reported as one generic unexpected_error. Each now has its own code — auth_timeout, oauth_provider_error, oauth_state_mismatch, oauth_no_code, callback_bind_failed — and login records which step it reached (session_check → awaiting_callback → token_exchange → store → done), so a walked-away browser wait is distinguishable from real breakage.

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0416a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
clerkPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from a3d8784 to d3ff139CompareAugust 24, 2026 15:17
@djgould
djgould marked this pull request as ready for review August 24, 2026 16:28
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());

bar();
setTelemetryStage("done");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

login() isn't only the clerk auth login command body — init calls it from resolveAuthLabel (init/index.ts:460), link from ensureAuth (link/index.ts:119), and doctor from its context fixer. The telemetry context is process-global (telemetry.ts:87), created once per run by the preAction hook against the top-level command, so a stage set in here lands on that command's event.

The concrete failure: clerk link in a logged-out directory calls login(), which reaches this line and sets stage: "done". Control returns to ensureAuth, and link then does the work it actually exists to do — resolveProfile, app selection, writing the profile. If any of that throws, the event is command: "link", outcome: "error", stage: "done". link sets no stages of its own, so this is the only stage it will ever report, and it reports it exclusively on runs that failed after completing auth.

init has the same shape: setTelemetryStage("link"), then authenticateAndLinklogin()"done", then the rest of authenticateAndLink plus detectAndInstall all run before init's next marker ("keys" at index.ts:179). Every failure in that window is attributed to "done".

That inverts what the stage field is for. The same hazard applies to the whole flat TelemetryStage union — a future command adding a "link" or "keys" stage will silently collide with init's.

Suggested fix — restore the caller's stage on a clean return

Restoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports awaiting_callback, correctly attributed to whichever command was running. It's the successful return that has to hand the stage back.

In lib/telemetry.ts:

/** Read the stage a caller had set, so a nested flow can hand it back. */exportfunctioncurrentTelemetryStage(): TelemetryStage|null{returncontext?.stage??null;}

In commands/auth/login.ts — rename the existing body to runLogin and wrap it:

exportasyncfunctionlogin(options: LoginOptions={}): Promise<UserInfo>{// `init`, `link`, and `doctor` call this mid-flow and share the one// process-global stage. Its own markers are worth having while it runs, but// on a clean return the caller's stage comes back so the rest of *their*// work isn't reported as `done`. On a throw the login stage stands: that is// genuinely where the run stopped.constcallerStage=currentTelemetryStage();constuserInfo=awaitrunLogin(options);if(callerStage)setTelemetryStage(callerStage);returnuserInfo;}

Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have (showNextSteps: false correlates with nested calls today, which is a coincidence worth not depending on).

Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — took the wrapper: currentTelemetryStage() reader, and login() hands the caller's stage back on a clean return (login's own stage stands on a throw). Mutation-checked: the nested test fails with the restore removed.

}),
);

setTelemetryStage("store");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"store" is the last stage set before performOAuthFlow returns, so it covers storeToken, fetchUserInfo, setAuth, and then back in login(): revokeToken for the superseded session and ensureFirstApplication.

ensureFirstApplication is the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there produces stage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.

Suggested fix — give the app-creation step its own marker

In lib/telemetry.ts:

 // `clerk auth login`
| "session_check"
| "awaiting_callback"
| "token_exchange"
| "store"
+ | "first_application"

In commands/auth/login.ts:

 // Best-effort: ensure the user has at least one application so downstream
// commands (clerk link, clerk init) have something to operate on.
+ setTelemetryStage("first_application");
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbfirst_application stage added before ensureFirstApplication.

expect(error).toMatchObject({ code: ERROR_CODE.AUTH_TIMEOUT });
expect((error as Error).message).toContain("timed out");

timeoutSpy.mockRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timeoutSpy.mockRestore() and server.stop() run after the assertions, so any failing expect above them skips both. The consequences are asymmetric with the usual leaked-mock nit: a live spyOn(globalThis, "setTimeout") stays installed for the rest of the file, and every later test that calls startAuthServer gets its deadline swallowed and its fire callback captured into a stale closure. One real assertion failure here cascades into a set of confusing unrelated ones.

The file already has the right pattern for this — serveSpy and clearTimeoutSpy are let-declared at describe scope and restored in afterEach.

Suggested fix — move both into the existing afterEach
 describe("auth-server", () => {
let serveSpy: ReturnType<typeof spyOn> | undefined;
let clearTimeoutSpy: ReturnType<typeof spyOn> | undefined;
+ let timeoutSpy: ReturnType<typeof spyOn> | undefined;+ let openServer: { stop: () => void } | undefined;
useCaptureLog();
afterEach(() => {
serveSpy?.mockRestore();
clearTimeoutSpy?.mockRestore();
+ timeoutSpy?.mockRestore();+ openServer?.stop();
serveSpy = undefined;
clearTimeoutSpy = undefined;
+ timeoutSpy = undefined;+ openServer = undefined;
});

Then in the timeout test, assign instead of declaring, and drop the trailing cleanup:

- const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((+ timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((
cb: () => void,
ms?: number,
...rest: unknown[]
) => {
@@
const server = startAuthServer("test-state");
+ openServer = server;
const errorPromise = server.waitForCallback().catch((e: unknown) => e);
@@
expect((error as Error).message).toContain("timed out");
-- timeoutSpy.mockRestore();- server.stop();
});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbtimeoutSpy and the open server moved into the existing afterEach.

await runLogin();

expect(stages.calls().at(-1)).toBe("done");
stages.restore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same shape as the auth-server.test.ts teardown, lower blast radius: stages.restore() is unreachable if expect(stages.calls().at(-1)) fails, leaving the module spy installed for the two tests that follow — each of which then calls spyOn on an already-spied property.

While you're here: the three tests cover done, awaiting_callback, and token_exchange, but nothing pins session_check or store, and nothing covers login() invoked as a subroutine — which is where the stage bookkeeping actually goes wrong.

Suggested fix — register the restore in a teardown hook
 describe("telemetry stages", () => {
+ let stageSpy: ReturnType<typeof spyOn> | undefined;++ afterEach(() => {+ stageSpy?.mockRestore();+ stageSpy = undefined;+ });+
function trackStages() {
- const stage = spyOn(telemetryMod, "setTelemetryStage");- return {- calls: () => stage.mock.calls.map((call) => call[0]),- restore: () => stage.mockRestore(),- };+ const spy = spyOn(telemetryMod, "setTelemetryStage");+ stageSpy = spy;+ return { calls: () => spy.mock.calls.map((call) => call[0]) };
}

Each test then drops its trailing stages.restore() line.

A fourth case worth adding, which is what LOG-001 is about:

test("a nested login leaves the caller's stage in place",async()=>{mockGetValidToken.mockResolvedValue(null);mockOAuthSuccess();telemetryMod.setTelemetryStage("link");conststages=trackStages();awaitrunLogin({showNextSteps: false});expect(stages.calls().at(-1)).toBe("link");});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — spy restore moved to afterEach, and added the nested-login handback test plus a store pin. Skipped a session_check pin: getExistingSession swallows every failure, so no run can terminate at that stage.

throw error;
// A sandbox or firewall that forbids binding loopback fails every login on
// the machine; it is a distinct condition from anything the user did.
throw new CliError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

errorMessage(error) preserves the message text, but the thrown CliError carries no reference to the original — CliErrorOptions has no cause, and observeHostCapabilityFailure only logs when isAgent() and the failure matches its sandbox heuristic. In a normal human-mode run the errno and stack are gone before anyone can look at them, and EACCES (sandbox/firewall) versus EADDRINUSE versus something else is exactly the distinction the new callback_bind_failed code exists to help diagnose.

Suggested fix — keep the original recoverable under --verbose

Every other branch in this file already debug-logs before rejecting; this one is the outlier.

Suggested change
thrownewCliError(
log.debug(`auth-server: bind failed — ${errorinstanceofError ? (error.stack??error.message) : String(error)}`);
thrownewCliError(
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it.

Comment thread.changeset/auth-login-error-codes.md Outdated
@@ -0,0 +1,6 @@
---
"clerk": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit is fix(auth): ... and the change is a diagnostics fix rather than new CLI surface. The repo's bump table maps fix: to patch and reserves minor for new user-facing features, commands, or flags, with an explicit default-to-patch rule when the split is unclear. The parent PR's minor is correct for its own feat(telemetry): commit, but that doesn't carry over.

Suggested fix
Suggested change
"clerk": minor
"clerk": patch
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbpatch.

import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { setTelemetryStage } from "../../lib/telemetry.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other import in this file — including ../../lib/errors.ts and ../../lib/log.ts a few lines up — uses a .ts specifier, as do ~1100 imports across cli-core. There's a small pocket of .js specifiers (lib/skills.ts:10, init/scan.ts:2) and the parent commit added one more at init/index.ts:24, so this is drifting rather than isolated. Bun resolves both, but it's worth keeping the new ones consistent.

Suggested fix
Suggested change
import{setTelemetryStage}from"../../lib/telemetry.js";
import{setTelemetryStage}from"../../lib/telemetry.ts";
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db here, and the parent's init/index.ts specifier in c4658a7 on #441.

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from d3ff139 to 7330d8cCompareAugust 24, 2026 18:11
Base automatically changed from devin/init-telemetry-stages to mainAugust 24, 2026 18:29
djgouldand others added 2 commits August 24, 2026 14:29
Every failure in the browser-callback phase of `clerk auth login` was a plain
`Error`, and `telemetryResultForError` only reads a code off `CliError` and
`ApiError` — so a timed-out wait, an OAuth provider error, a state mismatch, a
missing authorization code, and a loopback bind failure all landed in the
warehouse as `unexpected_error`, indistinguishable from each other and from
every other uncaught throw.
Types those five sites as `CliError` with distinct codes, and instruments login
with stage markers (session_check → awaiting_callback → token_exchange → store
→ done) so an abandoned browser wait is attributable to the step it stopped at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Nested login (init, link) hands the caller's telemetry stage back on a
clean return, so their post-auth work stops reporting stage "done"
- ensureFirstApplication gets its own first_application stage; failures
there no longer read as credential-store problems
- Debug-log the original bind error before wrapping it in CliError
- Restore test spies in afterEach so a failed assertion can't leak them
- Changeset minor -> patch per the bump policy for fix: changes
- .ts import specifier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from bf122db to 8a0416aCompareAugust 24, 2026 18:29
@coderabbitai

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd6729ec-18a2-4f76-ad6e-f63cd1876850

📥 Commits

Reviewing files that changed from the base of the PR and between a736e6a and 8a0416a.

📒 Files selected for processing (7)
  • .changeset/auth-login-error-codes.md
  • packages/cli-core/src/commands/auth/login.test.ts
  • packages/cli-core/src/commands/auth/login.ts
  • packages/cli-core/src/lib/auth-server.test.ts
  • packages/cli-core/src/lib/auth-server.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/telemetry.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go(manual)
  • clerk/dashboard(manual)
  • clerk/accounts(manual)
  • clerk/backoffice(manual)
  • clerk/clerk(manual)
  • clerk/clerk-docs(manual)
  • clerk/cloudflare-workers(manual)
  • clerk/javascript(auto-detected)

Included review availability: 6 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

The login command now records telemetry stages for session checks, OAuth callback waiting, token exchange, credential storage, application setup, and completion. Nested login calls restore the caller’s stage after successful execution. Authentication-server failures now use typed CliError values with machine-readable codes. Tests cover telemetry stages, callback failures, timeout handling, server cleanup, and structured error codes. A patch Changeset documents the release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk:⚪ Minimal · up to 8a041

This PR gives auth login failures specific error codes and records the login stage reached, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers:wyattjoh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. (1 skipped: 1 unsupported.)Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: distinct login failure codes and telemetry stage tracking.
Description check✅ PassedThe description accurately explains the new authentication error codes and login telemetry stages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@djgould
djgould merged commit 71bcb8e into mainAug 24, 2026
11 checks passed
@djgould
djgould deleted the devin/auth-login-error-codes branch August 24, 2026 19:01
@github-actionsgithub-actionsBot mentioned this pull request Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@djgould@wyattjoh
, '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

fix(auth): give each login failure its own error code and stage - #442

Merged
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes
Aug 24, 2026
Merged

fix(auth): give each login failure its own error code and stage#442
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes

Conversation

@djgould

@djgoulddjgould commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #441 — retarget to main once it merges.

Every failure in the auth login browser flow reported as one generic unexpected_error. Each now has its own code — auth_timeout, oauth_provider_error, oauth_state_mismatch, oauth_no_code, callback_bind_failed — and login records which step it reached (session_check → awaiting_callback → token_exchange → store → done), so a walked-away browser wait is distinguishable from real breakage.

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0416a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
clerkPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from a3d8784 to d3ff139CompareAugust 24, 2026 15:17
@djgould
djgould marked this pull request as ready for review August 24, 2026 16:28
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());

bar();
setTelemetryStage("done");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

login() isn't only the clerk auth login command body — init calls it from resolveAuthLabel (init/index.ts:460), link from ensureAuth (link/index.ts:119), and doctor from its context fixer. The telemetry context is process-global (telemetry.ts:87), created once per run by the preAction hook against the top-level command, so a stage set in here lands on that command's event.

The concrete failure: clerk link in a logged-out directory calls login(), which reaches this line and sets stage: "done". Control returns to ensureAuth, and link then does the work it actually exists to do — resolveProfile, app selection, writing the profile. If any of that throws, the event is command: "link", outcome: "error", stage: "done". link sets no stages of its own, so this is the only stage it will ever report, and it reports it exclusively on runs that failed after completing auth.

init has the same shape: setTelemetryStage("link"), then authenticateAndLinklogin()"done", then the rest of authenticateAndLink plus detectAndInstall all run before init's next marker ("keys" at index.ts:179). Every failure in that window is attributed to "done".

That inverts what the stage field is for. The same hazard applies to the whole flat TelemetryStage union — a future command adding a "link" or "keys" stage will silently collide with init's.

Suggested fix — restore the caller's stage on a clean return

Restoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports awaiting_callback, correctly attributed to whichever command was running. It's the successful return that has to hand the stage back.

In lib/telemetry.ts:

/** Read the stage a caller had set, so a nested flow can hand it back. */exportfunctioncurrentTelemetryStage(): TelemetryStage|null{returncontext?.stage??null;}

In commands/auth/login.ts — rename the existing body to runLogin and wrap it:

exportasyncfunctionlogin(options: LoginOptions={}): Promise<UserInfo>{// `init`, `link`, and `doctor` call this mid-flow and share the one// process-global stage. Its own markers are worth having while it runs, but// on a clean return the caller's stage comes back so the rest of *their*// work isn't reported as `done`. On a throw the login stage stands: that is// genuinely where the run stopped.constcallerStage=currentTelemetryStage();constuserInfo=awaitrunLogin(options);if(callerStage)setTelemetryStage(callerStage);returnuserInfo;}

Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have (showNextSteps: false correlates with nested calls today, which is a coincidence worth not depending on).

Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — took the wrapper: currentTelemetryStage() reader, and login() hands the caller's stage back on a clean return (login's own stage stands on a throw). Mutation-checked: the nested test fails with the restore removed.

}),
);

setTelemetryStage("store");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"store" is the last stage set before performOAuthFlow returns, so it covers storeToken, fetchUserInfo, setAuth, and then back in login(): revokeToken for the superseded session and ensureFirstApplication.

ensureFirstApplication is the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there produces stage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.

Suggested fix — give the app-creation step its own marker

In lib/telemetry.ts:

 // `clerk auth login`
| "session_check"
| "awaiting_callback"
| "token_exchange"
| "store"
+ | "first_application"

In commands/auth/login.ts:

 // Best-effort: ensure the user has at least one application so downstream
// commands (clerk link, clerk init) have something to operate on.
+ setTelemetryStage("first_application");
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbfirst_application stage added before ensureFirstApplication.

expect(error).toMatchObject({ code: ERROR_CODE.AUTH_TIMEOUT });
expect((error as Error).message).toContain("timed out");

timeoutSpy.mockRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timeoutSpy.mockRestore() and server.stop() run after the assertions, so any failing expect above them skips both. The consequences are asymmetric with the usual leaked-mock nit: a live spyOn(globalThis, "setTimeout") stays installed for the rest of the file, and every later test that calls startAuthServer gets its deadline swallowed and its fire callback captured into a stale closure. One real assertion failure here cascades into a set of confusing unrelated ones.

The file already has the right pattern for this — serveSpy and clearTimeoutSpy are let-declared at describe scope and restored in afterEach.

Suggested fix — move both into the existing afterEach
 describe("auth-server", () => {
let serveSpy: ReturnType<typeof spyOn> | undefined;
let clearTimeoutSpy: ReturnType<typeof spyOn> | undefined;
+ let timeoutSpy: ReturnType<typeof spyOn> | undefined;+ let openServer: { stop: () => void } | undefined;
useCaptureLog();
afterEach(() => {
serveSpy?.mockRestore();
clearTimeoutSpy?.mockRestore();
+ timeoutSpy?.mockRestore();+ openServer?.stop();
serveSpy = undefined;
clearTimeoutSpy = undefined;
+ timeoutSpy = undefined;+ openServer = undefined;
});

Then in the timeout test, assign instead of declaring, and drop the trailing cleanup:

- const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((+ timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((
cb: () => void,
ms?: number,
...rest: unknown[]
) => {
@@
const server = startAuthServer("test-state");
+ openServer = server;
const errorPromise = server.waitForCallback().catch((e: unknown) => e);
@@
expect((error as Error).message).toContain("timed out");
-- timeoutSpy.mockRestore();- server.stop();
});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbtimeoutSpy and the open server moved into the existing afterEach.

await runLogin();

expect(stages.calls().at(-1)).toBe("done");
stages.restore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same shape as the auth-server.test.ts teardown, lower blast radius: stages.restore() is unreachable if expect(stages.calls().at(-1)) fails, leaving the module spy installed for the two tests that follow — each of which then calls spyOn on an already-spied property.

While you're here: the three tests cover done, awaiting_callback, and token_exchange, but nothing pins session_check or store, and nothing covers login() invoked as a subroutine — which is where the stage bookkeeping actually goes wrong.

Suggested fix — register the restore in a teardown hook
 describe("telemetry stages", () => {
+ let stageSpy: ReturnType<typeof spyOn> | undefined;++ afterEach(() => {+ stageSpy?.mockRestore();+ stageSpy = undefined;+ });+
function trackStages() {
- const stage = spyOn(telemetryMod, "setTelemetryStage");- return {- calls: () => stage.mock.calls.map((call) => call[0]),- restore: () => stage.mockRestore(),- };+ const spy = spyOn(telemetryMod, "setTelemetryStage");+ stageSpy = spy;+ return { calls: () => spy.mock.calls.map((call) => call[0]) };
}

Each test then drops its trailing stages.restore() line.

A fourth case worth adding, which is what LOG-001 is about:

test("a nested login leaves the caller's stage in place",async()=>{mockGetValidToken.mockResolvedValue(null);mockOAuthSuccess();telemetryMod.setTelemetryStage("link");conststages=trackStages();awaitrunLogin({showNextSteps: false});expect(stages.calls().at(-1)).toBe("link");});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — spy restore moved to afterEach, and added the nested-login handback test plus a store pin. Skipped a session_check pin: getExistingSession swallows every failure, so no run can terminate at that stage.

throw error;
// A sandbox or firewall that forbids binding loopback fails every login on
// the machine; it is a distinct condition from anything the user did.
throw new CliError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

errorMessage(error) preserves the message text, but the thrown CliError carries no reference to the original — CliErrorOptions has no cause, and observeHostCapabilityFailure only logs when isAgent() and the failure matches its sandbox heuristic. In a normal human-mode run the errno and stack are gone before anyone can look at them, and EACCES (sandbox/firewall) versus EADDRINUSE versus something else is exactly the distinction the new callback_bind_failed code exists to help diagnose.

Suggested fix — keep the original recoverable under --verbose

Every other branch in this file already debug-logs before rejecting; this one is the outlier.

Suggested change
thrownewCliError(
log.debug(`auth-server: bind failed — ${errorinstanceofError ? (error.stack??error.message) : String(error)}`);
thrownewCliError(
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it.

Comment thread.changeset/auth-login-error-codes.md Outdated
@@ -0,0 +1,6 @@
---
"clerk": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit is fix(auth): ... and the change is a diagnostics fix rather than new CLI surface. The repo's bump table maps fix: to patch and reserves minor for new user-facing features, commands, or flags, with an explicit default-to-patch rule when the split is unclear. The parent PR's minor is correct for its own feat(telemetry): commit, but that doesn't carry over.

Suggested fix
Suggested change
"clerk": minor
"clerk": patch
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbpatch.

import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { setTelemetryStage } from "../../lib/telemetry.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other import in this file — including ../../lib/errors.ts and ../../lib/log.ts a few lines up — uses a .ts specifier, as do ~1100 imports across cli-core. There's a small pocket of .js specifiers (lib/skills.ts:10, init/scan.ts:2) and the parent commit added one more at init/index.ts:24, so this is drifting rather than isolated. Bun resolves both, but it's worth keeping the new ones consistent.

Suggested fix
Suggested change
import{setTelemetryStage}from"../../lib/telemetry.js";
import{setTelemetryStage}from"../../lib/telemetry.ts";
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db here, and the parent's init/index.ts specifier in c4658a7 on #441.

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from d3ff139 to 7330d8cCompareAugust 24, 2026 18:11
Base automatically changed from devin/init-telemetry-stages to mainAugust 24, 2026 18:29
djgouldand others added 2 commits August 24, 2026 14:29
Every failure in the browser-callback phase of `clerk auth login` was a plain
`Error`, and `telemetryResultForError` only reads a code off `CliError` and
`ApiError` — so a timed-out wait, an OAuth provider error, a state mismatch, a
missing authorization code, and a loopback bind failure all landed in the
warehouse as `unexpected_error`, indistinguishable from each other and from
every other uncaught throw.
Types those five sites as `CliError` with distinct codes, and instruments login
with stage markers (session_check → awaiting_callback → token_exchange → store
→ done) so an abandoned browser wait is attributable to the step it stopped at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Nested login (init, link) hands the caller's telemetry stage back on a
clean return, so their post-auth work stops reporting stage "done"
- ensureFirstApplication gets its own first_application stage; failures
there no longer read as credential-store problems
- Debug-log the original bind error before wrapping it in CliError
- Restore test spies in afterEach so a failed assertion can't leak them
- Changeset minor -> patch per the bump policy for fix: changes
- .ts import specifier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from bf122db to 8a0416aCompareAugust 24, 2026 18:29
@coderabbitai

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd6729ec-18a2-4f76-ad6e-f63cd1876850

📥 Commits

Reviewing files that changed from the base of the PR and between a736e6a and 8a0416a.

📒 Files selected for processing (7)
  • .changeset/auth-login-error-codes.md
  • packages/cli-core/src/commands/auth/login.test.ts
  • packages/cli-core/src/commands/auth/login.ts
  • packages/cli-core/src/lib/auth-server.test.ts
  • packages/cli-core/src/lib/auth-server.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/telemetry.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go(manual)
  • clerk/dashboard(manual)
  • clerk/accounts(manual)
  • clerk/backoffice(manual)
  • clerk/clerk(manual)
  • clerk/clerk-docs(manual)
  • clerk/cloudflare-workers(manual)
  • clerk/javascript(auto-detected)

Included review availability: 6 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

The login command now records telemetry stages for session checks, OAuth callback waiting, token exchange, credential storage, application setup, and completion. Nested login calls restore the caller’s stage after successful execution. Authentication-server failures now use typed CliError values with machine-readable codes. Tests cover telemetry stages, callback failures, timeout handling, server cleanup, and structured error codes. A patch Changeset documents the release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk:⚪ Minimal · up to 8a041

This PR gives auth login failures specific error codes and records the login stage reached, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers:wyattjoh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. (1 skipped: 1 unsupported.)Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: distinct login failure codes and telemetry stage tracking.
Description check✅ PassedThe description accurately explains the new authentication error codes and login telemetry stages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@djgould
djgould merged commit 71bcb8e into mainAug 24, 2026
11 checks passed
@djgould
djgould deleted the devin/auth-login-error-codes branch August 24, 2026 19:01
@github-actionsgithub-actionsBot mentioned this pull request Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@djgould@wyattjoh
, '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

fix(auth): give each login failure its own error code and stage - #442

Merged
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes
Aug 24, 2026
Merged

fix(auth): give each login failure its own error code and stage#442
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes

Conversation

@djgould

@djgoulddjgould commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #441 — retarget to main once it merges.

Every failure in the auth login browser flow reported as one generic unexpected_error. Each now has its own code — auth_timeout, oauth_provider_error, oauth_state_mismatch, oauth_no_code, callback_bind_failed — and login records which step it reached (session_check → awaiting_callback → token_exchange → store → done), so a walked-away browser wait is distinguishable from real breakage.

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0416a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
clerkPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from a3d8784 to d3ff139CompareAugust 24, 2026 15:17
@djgould
djgould marked this pull request as ready for review August 24, 2026 16:28
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());

bar();
setTelemetryStage("done");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

login() isn't only the clerk auth login command body — init calls it from resolveAuthLabel (init/index.ts:460), link from ensureAuth (link/index.ts:119), and doctor from its context fixer. The telemetry context is process-global (telemetry.ts:87), created once per run by the preAction hook against the top-level command, so a stage set in here lands on that command's event.

The concrete failure: clerk link in a logged-out directory calls login(), which reaches this line and sets stage: "done". Control returns to ensureAuth, and link then does the work it actually exists to do — resolveProfile, app selection, writing the profile. If any of that throws, the event is command: "link", outcome: "error", stage: "done". link sets no stages of its own, so this is the only stage it will ever report, and it reports it exclusively on runs that failed after completing auth.

init has the same shape: setTelemetryStage("link"), then authenticateAndLinklogin()"done", then the rest of authenticateAndLink plus detectAndInstall all run before init's next marker ("keys" at index.ts:179). Every failure in that window is attributed to "done".

That inverts what the stage field is for. The same hazard applies to the whole flat TelemetryStage union — a future command adding a "link" or "keys" stage will silently collide with init's.

Suggested fix — restore the caller's stage on a clean return

Restoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports awaiting_callback, correctly attributed to whichever command was running. It's the successful return that has to hand the stage back.

In lib/telemetry.ts:

/** Read the stage a caller had set, so a nested flow can hand it back. */exportfunctioncurrentTelemetryStage(): TelemetryStage|null{returncontext?.stage??null;}

In commands/auth/login.ts — rename the existing body to runLogin and wrap it:

exportasyncfunctionlogin(options: LoginOptions={}): Promise<UserInfo>{// `init`, `link`, and `doctor` call this mid-flow and share the one// process-global stage. Its own markers are worth having while it runs, but// on a clean return the caller's stage comes back so the rest of *their*// work isn't reported as `done`. On a throw the login stage stands: that is// genuinely where the run stopped.constcallerStage=currentTelemetryStage();constuserInfo=awaitrunLogin(options);if(callerStage)setTelemetryStage(callerStage);returnuserInfo;}

Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have (showNextSteps: false correlates with nested calls today, which is a coincidence worth not depending on).

Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — took the wrapper: currentTelemetryStage() reader, and login() hands the caller's stage back on a clean return (login's own stage stands on a throw). Mutation-checked: the nested test fails with the restore removed.

}),
);

setTelemetryStage("store");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"store" is the last stage set before performOAuthFlow returns, so it covers storeToken, fetchUserInfo, setAuth, and then back in login(): revokeToken for the superseded session and ensureFirstApplication.

ensureFirstApplication is the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there produces stage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.

Suggested fix — give the app-creation step its own marker

In lib/telemetry.ts:

 // `clerk auth login`
| "session_check"
| "awaiting_callback"
| "token_exchange"
| "store"
+ | "first_application"

In commands/auth/login.ts:

 // Best-effort: ensure the user has at least one application so downstream
// commands (clerk link, clerk init) have something to operate on.
+ setTelemetryStage("first_application");
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbfirst_application stage added before ensureFirstApplication.

expect(error).toMatchObject({ code: ERROR_CODE.AUTH_TIMEOUT });
expect((error as Error).message).toContain("timed out");

timeoutSpy.mockRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timeoutSpy.mockRestore() and server.stop() run after the assertions, so any failing expect above them skips both. The consequences are asymmetric with the usual leaked-mock nit: a live spyOn(globalThis, "setTimeout") stays installed for the rest of the file, and every later test that calls startAuthServer gets its deadline swallowed and its fire callback captured into a stale closure. One real assertion failure here cascades into a set of confusing unrelated ones.

The file already has the right pattern for this — serveSpy and clearTimeoutSpy are let-declared at describe scope and restored in afterEach.

Suggested fix — move both into the existing afterEach
 describe("auth-server", () => {
let serveSpy: ReturnType<typeof spyOn> | undefined;
let clearTimeoutSpy: ReturnType<typeof spyOn> | undefined;
+ let timeoutSpy: ReturnType<typeof spyOn> | undefined;+ let openServer: { stop: () => void } | undefined;
useCaptureLog();
afterEach(() => {
serveSpy?.mockRestore();
clearTimeoutSpy?.mockRestore();
+ timeoutSpy?.mockRestore();+ openServer?.stop();
serveSpy = undefined;
clearTimeoutSpy = undefined;
+ timeoutSpy = undefined;+ openServer = undefined;
});

Then in the timeout test, assign instead of declaring, and drop the trailing cleanup:

- const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((+ timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((
cb: () => void,
ms?: number,
...rest: unknown[]
) => {
@@
const server = startAuthServer("test-state");
+ openServer = server;
const errorPromise = server.waitForCallback().catch((e: unknown) => e);
@@
expect((error as Error).message).toContain("timed out");
-- timeoutSpy.mockRestore();- server.stop();
});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbtimeoutSpy and the open server moved into the existing afterEach.

await runLogin();

expect(stages.calls().at(-1)).toBe("done");
stages.restore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same shape as the auth-server.test.ts teardown, lower blast radius: stages.restore() is unreachable if expect(stages.calls().at(-1)) fails, leaving the module spy installed for the two tests that follow — each of which then calls spyOn on an already-spied property.

While you're here: the three tests cover done, awaiting_callback, and token_exchange, but nothing pins session_check or store, and nothing covers login() invoked as a subroutine — which is where the stage bookkeeping actually goes wrong.

Suggested fix — register the restore in a teardown hook
 describe("telemetry stages", () => {
+ let stageSpy: ReturnType<typeof spyOn> | undefined;++ afterEach(() => {+ stageSpy?.mockRestore();+ stageSpy = undefined;+ });+
function trackStages() {
- const stage = spyOn(telemetryMod, "setTelemetryStage");- return {- calls: () => stage.mock.calls.map((call) => call[0]),- restore: () => stage.mockRestore(),- };+ const spy = spyOn(telemetryMod, "setTelemetryStage");+ stageSpy = spy;+ return { calls: () => spy.mock.calls.map((call) => call[0]) };
}

Each test then drops its trailing stages.restore() line.

A fourth case worth adding, which is what LOG-001 is about:

test("a nested login leaves the caller's stage in place",async()=>{mockGetValidToken.mockResolvedValue(null);mockOAuthSuccess();telemetryMod.setTelemetryStage("link");conststages=trackStages();awaitrunLogin({showNextSteps: false});expect(stages.calls().at(-1)).toBe("link");});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — spy restore moved to afterEach, and added the nested-login handback test plus a store pin. Skipped a session_check pin: getExistingSession swallows every failure, so no run can terminate at that stage.

throw error;
// A sandbox or firewall that forbids binding loopback fails every login on
// the machine; it is a distinct condition from anything the user did.
throw new CliError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

errorMessage(error) preserves the message text, but the thrown CliError carries no reference to the original — CliErrorOptions has no cause, and observeHostCapabilityFailure only logs when isAgent() and the failure matches its sandbox heuristic. In a normal human-mode run the errno and stack are gone before anyone can look at them, and EACCES (sandbox/firewall) versus EADDRINUSE versus something else is exactly the distinction the new callback_bind_failed code exists to help diagnose.

Suggested fix — keep the original recoverable under --verbose

Every other branch in this file already debug-logs before rejecting; this one is the outlier.

Suggested change
thrownewCliError(
log.debug(`auth-server: bind failed — ${errorinstanceofError ? (error.stack??error.message) : String(error)}`);
thrownewCliError(
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it.

Comment thread.changeset/auth-login-error-codes.md Outdated
@@ -0,0 +1,6 @@
---
"clerk": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit is fix(auth): ... and the change is a diagnostics fix rather than new CLI surface. The repo's bump table maps fix: to patch and reserves minor for new user-facing features, commands, or flags, with an explicit default-to-patch rule when the split is unclear. The parent PR's minor is correct for its own feat(telemetry): commit, but that doesn't carry over.

Suggested fix
Suggested change
"clerk": minor
"clerk": patch
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbpatch.

import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { setTelemetryStage } from "../../lib/telemetry.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other import in this file — including ../../lib/errors.ts and ../../lib/log.ts a few lines up — uses a .ts specifier, as do ~1100 imports across cli-core. There's a small pocket of .js specifiers (lib/skills.ts:10, init/scan.ts:2) and the parent commit added one more at init/index.ts:24, so this is drifting rather than isolated. Bun resolves both, but it's worth keeping the new ones consistent.

Suggested fix
Suggested change
import{setTelemetryStage}from"../../lib/telemetry.js";
import{setTelemetryStage}from"../../lib/telemetry.ts";
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db here, and the parent's init/index.ts specifier in c4658a7 on #441.

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from d3ff139 to 7330d8cCompareAugust 24, 2026 18:11
Base automatically changed from devin/init-telemetry-stages to mainAugust 24, 2026 18:29
djgouldand others added 2 commits August 24, 2026 14:29
Every failure in the browser-callback phase of `clerk auth login` was a plain
`Error`, and `telemetryResultForError` only reads a code off `CliError` and
`ApiError` — so a timed-out wait, an OAuth provider error, a state mismatch, a
missing authorization code, and a loopback bind failure all landed in the
warehouse as `unexpected_error`, indistinguishable from each other and from
every other uncaught throw.
Types those five sites as `CliError` with distinct codes, and instruments login
with stage markers (session_check → awaiting_callback → token_exchange → store
→ done) so an abandoned browser wait is attributable to the step it stopped at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Nested login (init, link) hands the caller's telemetry stage back on a
clean return, so their post-auth work stops reporting stage "done"
- ensureFirstApplication gets its own first_application stage; failures
there no longer read as credential-store problems
- Debug-log the original bind error before wrapping it in CliError
- Restore test spies in afterEach so a failed assertion can't leak them
- Changeset minor -> patch per the bump policy for fix: changes
- .ts import specifier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from bf122db to 8a0416aCompareAugust 24, 2026 18:29
@coderabbitai

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd6729ec-18a2-4f76-ad6e-f63cd1876850

📥 Commits

Reviewing files that changed from the base of the PR and between a736e6a and 8a0416a.

📒 Files selected for processing (7)
  • .changeset/auth-login-error-codes.md
  • packages/cli-core/src/commands/auth/login.test.ts
  • packages/cli-core/src/commands/auth/login.ts
  • packages/cli-core/src/lib/auth-server.test.ts
  • packages/cli-core/src/lib/auth-server.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/telemetry.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go(manual)
  • clerk/dashboard(manual)
  • clerk/accounts(manual)
  • clerk/backoffice(manual)
  • clerk/clerk(manual)
  • clerk/clerk-docs(manual)
  • clerk/cloudflare-workers(manual)
  • clerk/javascript(auto-detected)

Included review availability: 6 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

The login command now records telemetry stages for session checks, OAuth callback waiting, token exchange, credential storage, application setup, and completion. Nested login calls restore the caller’s stage after successful execution. Authentication-server failures now use typed CliError values with machine-readable codes. Tests cover telemetry stages, callback failures, timeout handling, server cleanup, and structured error codes. A patch Changeset documents the release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk:⚪ Minimal · up to 8a041

This PR gives auth login failures specific error codes and records the login stage reached, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers:wyattjoh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. (1 skipped: 1 unsupported.)Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: distinct login failure codes and telemetry stage tracking.
Description check✅ PassedThe description accurately explains the new authentication error codes and login telemetry stages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@djgould
djgould merged commit 71bcb8e into mainAug 24, 2026
11 checks passed
@djgould
djgould deleted the devin/auth-login-error-codes branch August 24, 2026 19:01
@github-actionsgithub-actionsBot mentioned this pull request Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@djgould@wyattjoh
, '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

fix(auth): give each login failure its own error code and stage - #442

Merged
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes
Aug 24, 2026
Merged

fix(auth): give each login failure its own error code and stage#442
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes

Conversation

@djgould

@djgoulddjgould commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #441 — retarget to main once it merges.

Every failure in the auth login browser flow reported as one generic unexpected_error. Each now has its own code — auth_timeout, oauth_provider_error, oauth_state_mismatch, oauth_no_code, callback_bind_failed — and login records which step it reached (session_check → awaiting_callback → token_exchange → store → done), so a walked-away browser wait is distinguishable from real breakage.

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0416a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
clerkPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from a3d8784 to d3ff139CompareAugust 24, 2026 15:17
@djgould
djgould marked this pull request as ready for review August 24, 2026 16:28
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());

bar();
setTelemetryStage("done");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

login() isn't only the clerk auth login command body — init calls it from resolveAuthLabel (init/index.ts:460), link from ensureAuth (link/index.ts:119), and doctor from its context fixer. The telemetry context is process-global (telemetry.ts:87), created once per run by the preAction hook against the top-level command, so a stage set in here lands on that command's event.

The concrete failure: clerk link in a logged-out directory calls login(), which reaches this line and sets stage: "done". Control returns to ensureAuth, and link then does the work it actually exists to do — resolveProfile, app selection, writing the profile. If any of that throws, the event is command: "link", outcome: "error", stage: "done". link sets no stages of its own, so this is the only stage it will ever report, and it reports it exclusively on runs that failed after completing auth.

init has the same shape: setTelemetryStage("link"), then authenticateAndLinklogin()"done", then the rest of authenticateAndLink plus detectAndInstall all run before init's next marker ("keys" at index.ts:179). Every failure in that window is attributed to "done".

That inverts what the stage field is for. The same hazard applies to the whole flat TelemetryStage union — a future command adding a "link" or "keys" stage will silently collide with init's.

Suggested fix — restore the caller's stage on a clean return

Restoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports awaiting_callback, correctly attributed to whichever command was running. It's the successful return that has to hand the stage back.

In lib/telemetry.ts:

/** Read the stage a caller had set, so a nested flow can hand it back. */exportfunctioncurrentTelemetryStage(): TelemetryStage|null{returncontext?.stage??null;}

In commands/auth/login.ts — rename the existing body to runLogin and wrap it:

exportasyncfunctionlogin(options: LoginOptions={}): Promise<UserInfo>{// `init`, `link`, and `doctor` call this mid-flow and share the one// process-global stage. Its own markers are worth having while it runs, but// on a clean return the caller's stage comes back so the rest of *their*// work isn't reported as `done`. On a throw the login stage stands: that is// genuinely where the run stopped.constcallerStage=currentTelemetryStage();constuserInfo=awaitrunLogin(options);if(callerStage)setTelemetryStage(callerStage);returnuserInfo;}

Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have (showNextSteps: false correlates with nested calls today, which is a coincidence worth not depending on).

Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — took the wrapper: currentTelemetryStage() reader, and login() hands the caller's stage back on a clean return (login's own stage stands on a throw). Mutation-checked: the nested test fails with the restore removed.

}),
);

setTelemetryStage("store");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"store" is the last stage set before performOAuthFlow returns, so it covers storeToken, fetchUserInfo, setAuth, and then back in login(): revokeToken for the superseded session and ensureFirstApplication.

ensureFirstApplication is the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there produces stage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.

Suggested fix — give the app-creation step its own marker

In lib/telemetry.ts:

 // `clerk auth login`
| "session_check"
| "awaiting_callback"
| "token_exchange"
| "store"
+ | "first_application"

In commands/auth/login.ts:

 // Best-effort: ensure the user has at least one application so downstream
// commands (clerk link, clerk init) have something to operate on.
+ setTelemetryStage("first_application");
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbfirst_application stage added before ensureFirstApplication.

expect(error).toMatchObject({ code: ERROR_CODE.AUTH_TIMEOUT });
expect((error as Error).message).toContain("timed out");

timeoutSpy.mockRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timeoutSpy.mockRestore() and server.stop() run after the assertions, so any failing expect above them skips both. The consequences are asymmetric with the usual leaked-mock nit: a live spyOn(globalThis, "setTimeout") stays installed for the rest of the file, and every later test that calls startAuthServer gets its deadline swallowed and its fire callback captured into a stale closure. One real assertion failure here cascades into a set of confusing unrelated ones.

The file already has the right pattern for this — serveSpy and clearTimeoutSpy are let-declared at describe scope and restored in afterEach.

Suggested fix — move both into the existing afterEach
 describe("auth-server", () => {
let serveSpy: ReturnType<typeof spyOn> | undefined;
let clearTimeoutSpy: ReturnType<typeof spyOn> | undefined;
+ let timeoutSpy: ReturnType<typeof spyOn> | undefined;+ let openServer: { stop: () => void } | undefined;
useCaptureLog();
afterEach(() => {
serveSpy?.mockRestore();
clearTimeoutSpy?.mockRestore();
+ timeoutSpy?.mockRestore();+ openServer?.stop();
serveSpy = undefined;
clearTimeoutSpy = undefined;
+ timeoutSpy = undefined;+ openServer = undefined;
});

Then in the timeout test, assign instead of declaring, and drop the trailing cleanup:

- const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((+ timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((
cb: () => void,
ms?: number,
...rest: unknown[]
) => {
@@
const server = startAuthServer("test-state");
+ openServer = server;
const errorPromise = server.waitForCallback().catch((e: unknown) => e);
@@
expect((error as Error).message).toContain("timed out");
-- timeoutSpy.mockRestore();- server.stop();
});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbtimeoutSpy and the open server moved into the existing afterEach.

await runLogin();

expect(stages.calls().at(-1)).toBe("done");
stages.restore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same shape as the auth-server.test.ts teardown, lower blast radius: stages.restore() is unreachable if expect(stages.calls().at(-1)) fails, leaving the module spy installed for the two tests that follow — each of which then calls spyOn on an already-spied property.

While you're here: the three tests cover done, awaiting_callback, and token_exchange, but nothing pins session_check or store, and nothing covers login() invoked as a subroutine — which is where the stage bookkeeping actually goes wrong.

Suggested fix — register the restore in a teardown hook
 describe("telemetry stages", () => {
+ let stageSpy: ReturnType<typeof spyOn> | undefined;++ afterEach(() => {+ stageSpy?.mockRestore();+ stageSpy = undefined;+ });+
function trackStages() {
- const stage = spyOn(telemetryMod, "setTelemetryStage");- return {- calls: () => stage.mock.calls.map((call) => call[0]),- restore: () => stage.mockRestore(),- };+ const spy = spyOn(telemetryMod, "setTelemetryStage");+ stageSpy = spy;+ return { calls: () => spy.mock.calls.map((call) => call[0]) };
}

Each test then drops its trailing stages.restore() line.

A fourth case worth adding, which is what LOG-001 is about:

test("a nested login leaves the caller's stage in place",async()=>{mockGetValidToken.mockResolvedValue(null);mockOAuthSuccess();telemetryMod.setTelemetryStage("link");conststages=trackStages();awaitrunLogin({showNextSteps: false});expect(stages.calls().at(-1)).toBe("link");});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — spy restore moved to afterEach, and added the nested-login handback test plus a store pin. Skipped a session_check pin: getExistingSession swallows every failure, so no run can terminate at that stage.

throw error;
// A sandbox or firewall that forbids binding loopback fails every login on
// the machine; it is a distinct condition from anything the user did.
throw new CliError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

errorMessage(error) preserves the message text, but the thrown CliError carries no reference to the original — CliErrorOptions has no cause, and observeHostCapabilityFailure only logs when isAgent() and the failure matches its sandbox heuristic. In a normal human-mode run the errno and stack are gone before anyone can look at them, and EACCES (sandbox/firewall) versus EADDRINUSE versus something else is exactly the distinction the new callback_bind_failed code exists to help diagnose.

Suggested fix — keep the original recoverable under --verbose

Every other branch in this file already debug-logs before rejecting; this one is the outlier.

Suggested change
thrownewCliError(
log.debug(`auth-server: bind failed — ${errorinstanceofError ? (error.stack??error.message) : String(error)}`);
thrownewCliError(
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it.

Comment thread.changeset/auth-login-error-codes.md Outdated
@@ -0,0 +1,6 @@
---
"clerk": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit is fix(auth): ... and the change is a diagnostics fix rather than new CLI surface. The repo's bump table maps fix: to patch and reserves minor for new user-facing features, commands, or flags, with an explicit default-to-patch rule when the split is unclear. The parent PR's minor is correct for its own feat(telemetry): commit, but that doesn't carry over.

Suggested fix
Suggested change
"clerk": minor
"clerk": patch
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbpatch.

import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { setTelemetryStage } from "../../lib/telemetry.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other import in this file — including ../../lib/errors.ts and ../../lib/log.ts a few lines up — uses a .ts specifier, as do ~1100 imports across cli-core. There's a small pocket of .js specifiers (lib/skills.ts:10, init/scan.ts:2) and the parent commit added one more at init/index.ts:24, so this is drifting rather than isolated. Bun resolves both, but it's worth keeping the new ones consistent.

Suggested fix
Suggested change
import{setTelemetryStage}from"../../lib/telemetry.js";
import{setTelemetryStage}from"../../lib/telemetry.ts";
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db here, and the parent's init/index.ts specifier in c4658a7 on #441.

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from d3ff139 to 7330d8cCompareAugust 24, 2026 18:11
Base automatically changed from devin/init-telemetry-stages to mainAugust 24, 2026 18:29
djgouldand others added 2 commits August 24, 2026 14:29
Every failure in the browser-callback phase of `clerk auth login` was a plain
`Error`, and `telemetryResultForError` only reads a code off `CliError` and
`ApiError` — so a timed-out wait, an OAuth provider error, a state mismatch, a
missing authorization code, and a loopback bind failure all landed in the
warehouse as `unexpected_error`, indistinguishable from each other and from
every other uncaught throw.
Types those five sites as `CliError` with distinct codes, and instruments login
with stage markers (session_check → awaiting_callback → token_exchange → store
→ done) so an abandoned browser wait is attributable to the step it stopped at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Nested login (init, link) hands the caller's telemetry stage back on a
clean return, so their post-auth work stops reporting stage "done"
- ensureFirstApplication gets its own first_application stage; failures
there no longer read as credential-store problems
- Debug-log the original bind error before wrapping it in CliError
- Restore test spies in afterEach so a failed assertion can't leak them
- Changeset minor -> patch per the bump policy for fix: changes
- .ts import specifier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from bf122db to 8a0416aCompareAugust 24, 2026 18:29
@coderabbitai

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd6729ec-18a2-4f76-ad6e-f63cd1876850

📥 Commits

Reviewing files that changed from the base of the PR and between a736e6a and 8a0416a.

📒 Files selected for processing (7)
  • .changeset/auth-login-error-codes.md
  • packages/cli-core/src/commands/auth/login.test.ts
  • packages/cli-core/src/commands/auth/login.ts
  • packages/cli-core/src/lib/auth-server.test.ts
  • packages/cli-core/src/lib/auth-server.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/telemetry.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go(manual)
  • clerk/dashboard(manual)
  • clerk/accounts(manual)
  • clerk/backoffice(manual)
  • clerk/clerk(manual)
  • clerk/clerk-docs(manual)
  • clerk/cloudflare-workers(manual)
  • clerk/javascript(auto-detected)

Included review availability: 6 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

The login command now records telemetry stages for session checks, OAuth callback waiting, token exchange, credential storage, application setup, and completion. Nested login calls restore the caller’s stage after successful execution. Authentication-server failures now use typed CliError values with machine-readable codes. Tests cover telemetry stages, callback failures, timeout handling, server cleanup, and structured error codes. A patch Changeset documents the release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk:⚪ Minimal · up to 8a041

This PR gives auth login failures specific error codes and records the login stage reached, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers:wyattjoh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. (1 skipped: 1 unsupported.)Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: distinct login failure codes and telemetry stage tracking.
Description check✅ PassedThe description accurately explains the new authentication error codes and login telemetry stages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@djgould
djgould merged commit 71bcb8e into mainAug 24, 2026
11 checks passed
@djgould
djgould deleted the devin/auth-login-error-codes branch August 24, 2026 19:01
@github-actionsgithub-actionsBot mentioned this pull request Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@djgould@wyattjoh
, '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

fix(auth): give each login failure its own error code and stage - #442

Merged
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes
Aug 24, 2026
Merged

fix(auth): give each login failure its own error code and stage#442
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes

Conversation

@djgould

@djgoulddjgould commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #441 — retarget to main once it merges.

Every failure in the auth login browser flow reported as one generic unexpected_error. Each now has its own code — auth_timeout, oauth_provider_error, oauth_state_mismatch, oauth_no_code, callback_bind_failed — and login records which step it reached (session_check → awaiting_callback → token_exchange → store → done), so a walked-away browser wait is distinguishable from real breakage.

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0416a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
clerkPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from a3d8784 to d3ff139CompareAugust 24, 2026 15:17
@djgould
djgould marked this pull request as ready for review August 24, 2026 16:28
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());

bar();
setTelemetryStage("done");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

login() isn't only the clerk auth login command body — init calls it from resolveAuthLabel (init/index.ts:460), link from ensureAuth (link/index.ts:119), and doctor from its context fixer. The telemetry context is process-global (telemetry.ts:87), created once per run by the preAction hook against the top-level command, so a stage set in here lands on that command's event.

The concrete failure: clerk link in a logged-out directory calls login(), which reaches this line and sets stage: "done". Control returns to ensureAuth, and link then does the work it actually exists to do — resolveProfile, app selection, writing the profile. If any of that throws, the event is command: "link", outcome: "error", stage: "done". link sets no stages of its own, so this is the only stage it will ever report, and it reports it exclusively on runs that failed after completing auth.

init has the same shape: setTelemetryStage("link"), then authenticateAndLinklogin()"done", then the rest of authenticateAndLink plus detectAndInstall all run before init's next marker ("keys" at index.ts:179). Every failure in that window is attributed to "done".

That inverts what the stage field is for. The same hazard applies to the whole flat TelemetryStage union — a future command adding a "link" or "keys" stage will silently collide with init's.

Suggested fix — restore the caller's stage on a clean return

Restoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports awaiting_callback, correctly attributed to whichever command was running. It's the successful return that has to hand the stage back.

In lib/telemetry.ts:

/** Read the stage a caller had set, so a nested flow can hand it back. */exportfunctioncurrentTelemetryStage(): TelemetryStage|null{returncontext?.stage??null;}

In commands/auth/login.ts — rename the existing body to runLogin and wrap it:

exportasyncfunctionlogin(options: LoginOptions={}): Promise<UserInfo>{// `init`, `link`, and `doctor` call this mid-flow and share the one// process-global stage. Its own markers are worth having while it runs, but// on a clean return the caller's stage comes back so the rest of *their*// work isn't reported as `done`. On a throw the login stage stands: that is// genuinely where the run stopped.constcallerStage=currentTelemetryStage();constuserInfo=awaitrunLogin(options);if(callerStage)setTelemetryStage(callerStage);returnuserInfo;}

Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have (showNextSteps: false correlates with nested calls today, which is a coincidence worth not depending on).

Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — took the wrapper: currentTelemetryStage() reader, and login() hands the caller's stage back on a clean return (login's own stage stands on a throw). Mutation-checked: the nested test fails with the restore removed.

}),
);

setTelemetryStage("store");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"store" is the last stage set before performOAuthFlow returns, so it covers storeToken, fetchUserInfo, setAuth, and then back in login(): revokeToken for the superseded session and ensureFirstApplication.

ensureFirstApplication is the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there produces stage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.

Suggested fix — give the app-creation step its own marker

In lib/telemetry.ts:

 // `clerk auth login`
| "session_check"
| "awaiting_callback"
| "token_exchange"
| "store"
+ | "first_application"

In commands/auth/login.ts:

 // Best-effort: ensure the user has at least one application so downstream
// commands (clerk link, clerk init) have something to operate on.
+ setTelemetryStage("first_application");
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbfirst_application stage added before ensureFirstApplication.

expect(error).toMatchObject({ code: ERROR_CODE.AUTH_TIMEOUT });
expect((error as Error).message).toContain("timed out");

timeoutSpy.mockRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timeoutSpy.mockRestore() and server.stop() run after the assertions, so any failing expect above them skips both. The consequences are asymmetric with the usual leaked-mock nit: a live spyOn(globalThis, "setTimeout") stays installed for the rest of the file, and every later test that calls startAuthServer gets its deadline swallowed and its fire callback captured into a stale closure. One real assertion failure here cascades into a set of confusing unrelated ones.

The file already has the right pattern for this — serveSpy and clearTimeoutSpy are let-declared at describe scope and restored in afterEach.

Suggested fix — move both into the existing afterEach
 describe("auth-server", () => {
let serveSpy: ReturnType<typeof spyOn> | undefined;
let clearTimeoutSpy: ReturnType<typeof spyOn> | undefined;
+ let timeoutSpy: ReturnType<typeof spyOn> | undefined;+ let openServer: { stop: () => void } | undefined;
useCaptureLog();
afterEach(() => {
serveSpy?.mockRestore();
clearTimeoutSpy?.mockRestore();
+ timeoutSpy?.mockRestore();+ openServer?.stop();
serveSpy = undefined;
clearTimeoutSpy = undefined;
+ timeoutSpy = undefined;+ openServer = undefined;
});

Then in the timeout test, assign instead of declaring, and drop the trailing cleanup:

- const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((+ timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((
cb: () => void,
ms?: number,
...rest: unknown[]
) => {
@@
const server = startAuthServer("test-state");
+ openServer = server;
const errorPromise = server.waitForCallback().catch((e: unknown) => e);
@@
expect((error as Error).message).toContain("timed out");
-- timeoutSpy.mockRestore();- server.stop();
});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbtimeoutSpy and the open server moved into the existing afterEach.

await runLogin();

expect(stages.calls().at(-1)).toBe("done");
stages.restore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same shape as the auth-server.test.ts teardown, lower blast radius: stages.restore() is unreachable if expect(stages.calls().at(-1)) fails, leaving the module spy installed for the two tests that follow — each of which then calls spyOn on an already-spied property.

While you're here: the three tests cover done, awaiting_callback, and token_exchange, but nothing pins session_check or store, and nothing covers login() invoked as a subroutine — which is where the stage bookkeeping actually goes wrong.

Suggested fix — register the restore in a teardown hook
 describe("telemetry stages", () => {
+ let stageSpy: ReturnType<typeof spyOn> | undefined;++ afterEach(() => {+ stageSpy?.mockRestore();+ stageSpy = undefined;+ });+
function trackStages() {
- const stage = spyOn(telemetryMod, "setTelemetryStage");- return {- calls: () => stage.mock.calls.map((call) => call[0]),- restore: () => stage.mockRestore(),- };+ const spy = spyOn(telemetryMod, "setTelemetryStage");+ stageSpy = spy;+ return { calls: () => spy.mock.calls.map((call) => call[0]) };
}

Each test then drops its trailing stages.restore() line.

A fourth case worth adding, which is what LOG-001 is about:

test("a nested login leaves the caller's stage in place",async()=>{mockGetValidToken.mockResolvedValue(null);mockOAuthSuccess();telemetryMod.setTelemetryStage("link");conststages=trackStages();awaitrunLogin({showNextSteps: false});expect(stages.calls().at(-1)).toBe("link");});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — spy restore moved to afterEach, and added the nested-login handback test plus a store pin. Skipped a session_check pin: getExistingSession swallows every failure, so no run can terminate at that stage.

throw error;
// A sandbox or firewall that forbids binding loopback fails every login on
// the machine; it is a distinct condition from anything the user did.
throw new CliError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

errorMessage(error) preserves the message text, but the thrown CliError carries no reference to the original — CliErrorOptions has no cause, and observeHostCapabilityFailure only logs when isAgent() and the failure matches its sandbox heuristic. In a normal human-mode run the errno and stack are gone before anyone can look at them, and EACCES (sandbox/firewall) versus EADDRINUSE versus something else is exactly the distinction the new callback_bind_failed code exists to help diagnose.

Suggested fix — keep the original recoverable under --verbose

Every other branch in this file already debug-logs before rejecting; this one is the outlier.

Suggested change
thrownewCliError(
log.debug(`auth-server: bind failed — ${errorinstanceofError ? (error.stack??error.message) : String(error)}`);
thrownewCliError(
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it.

Comment thread.changeset/auth-login-error-codes.md Outdated
@@ -0,0 +1,6 @@
---
"clerk": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit is fix(auth): ... and the change is a diagnostics fix rather than new CLI surface. The repo's bump table maps fix: to patch and reserves minor for new user-facing features, commands, or flags, with an explicit default-to-patch rule when the split is unclear. The parent PR's minor is correct for its own feat(telemetry): commit, but that doesn't carry over.

Suggested fix
Suggested change
"clerk": minor
"clerk": patch
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbpatch.

import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { setTelemetryStage } from "../../lib/telemetry.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other import in this file — including ../../lib/errors.ts and ../../lib/log.ts a few lines up — uses a .ts specifier, as do ~1100 imports across cli-core. There's a small pocket of .js specifiers (lib/skills.ts:10, init/scan.ts:2) and the parent commit added one more at init/index.ts:24, so this is drifting rather than isolated. Bun resolves both, but it's worth keeping the new ones consistent.

Suggested fix
Suggested change
import{setTelemetryStage}from"../../lib/telemetry.js";
import{setTelemetryStage}from"../../lib/telemetry.ts";
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db here, and the parent's init/index.ts specifier in c4658a7 on #441.

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from d3ff139 to 7330d8cCompareAugust 24, 2026 18:11
Base automatically changed from devin/init-telemetry-stages to mainAugust 24, 2026 18:29
djgouldand others added 2 commits August 24, 2026 14:29
Every failure in the browser-callback phase of `clerk auth login` was a plain
`Error`, and `telemetryResultForError` only reads a code off `CliError` and
`ApiError` — so a timed-out wait, an OAuth provider error, a state mismatch, a
missing authorization code, and a loopback bind failure all landed in the
warehouse as `unexpected_error`, indistinguishable from each other and from
every other uncaught throw.
Types those five sites as `CliError` with distinct codes, and instruments login
with stage markers (session_check → awaiting_callback → token_exchange → store
→ done) so an abandoned browser wait is attributable to the step it stopped at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Nested login (init, link) hands the caller's telemetry stage back on a
clean return, so their post-auth work stops reporting stage "done"
- ensureFirstApplication gets its own first_application stage; failures
there no longer read as credential-store problems
- Debug-log the original bind error before wrapping it in CliError
- Restore test spies in afterEach so a failed assertion can't leak them
- Changeset minor -> patch per the bump policy for fix: changes
- .ts import specifier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from bf122db to 8a0416aCompareAugust 24, 2026 18:29
@coderabbitai

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd6729ec-18a2-4f76-ad6e-f63cd1876850

📥 Commits

Reviewing files that changed from the base of the PR and between a736e6a and 8a0416a.

📒 Files selected for processing (7)
  • .changeset/auth-login-error-codes.md
  • packages/cli-core/src/commands/auth/login.test.ts
  • packages/cli-core/src/commands/auth/login.ts
  • packages/cli-core/src/lib/auth-server.test.ts
  • packages/cli-core/src/lib/auth-server.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/telemetry.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go(manual)
  • clerk/dashboard(manual)
  • clerk/accounts(manual)
  • clerk/backoffice(manual)
  • clerk/clerk(manual)
  • clerk/clerk-docs(manual)
  • clerk/cloudflare-workers(manual)
  • clerk/javascript(auto-detected)

Included review availability: 6 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

The login command now records telemetry stages for session checks, OAuth callback waiting, token exchange, credential storage, application setup, and completion. Nested login calls restore the caller’s stage after successful execution. Authentication-server failures now use typed CliError values with machine-readable codes. Tests cover telemetry stages, callback failures, timeout handling, server cleanup, and structured error codes. A patch Changeset documents the release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk:⚪ Minimal · up to 8a041

This PR gives auth login failures specific error codes and records the login stage reached, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers:wyattjoh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. (1 skipped: 1 unsupported.)Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: distinct login failure codes and telemetry stage tracking.
Description check✅ PassedThe description accurately explains the new authentication error codes and login telemetry stages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@djgould
djgould merged commit 71bcb8e into mainAug 24, 2026
11 checks passed
@djgould
djgould deleted the devin/auth-login-error-codes branch August 24, 2026 19:01
@github-actionsgithub-actionsBot mentioned this pull request Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@djgould@wyattjoh
, '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

fix(auth): give each login failure its own error code and stage - #442

Merged
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes
Aug 24, 2026
Merged

fix(auth): give each login failure its own error code and stage#442
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes

Conversation

@djgould

@djgoulddjgould commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #441 — retarget to main once it merges.

Every failure in the auth login browser flow reported as one generic unexpected_error. Each now has its own code — auth_timeout, oauth_provider_error, oauth_state_mismatch, oauth_no_code, callback_bind_failed — and login records which step it reached (session_check → awaiting_callback → token_exchange → store → done), so a walked-away browser wait is distinguishable from real breakage.

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0416a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
clerkPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from a3d8784 to d3ff139CompareAugust 24, 2026 15:17
@djgould
djgould marked this pull request as ready for review August 24, 2026 16:28
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());

bar();
setTelemetryStage("done");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

login() isn't only the clerk auth login command body — init calls it from resolveAuthLabel (init/index.ts:460), link from ensureAuth (link/index.ts:119), and doctor from its context fixer. The telemetry context is process-global (telemetry.ts:87), created once per run by the preAction hook against the top-level command, so a stage set in here lands on that command's event.

The concrete failure: clerk link in a logged-out directory calls login(), which reaches this line and sets stage: "done". Control returns to ensureAuth, and link then does the work it actually exists to do — resolveProfile, app selection, writing the profile. If any of that throws, the event is command: "link", outcome: "error", stage: "done". link sets no stages of its own, so this is the only stage it will ever report, and it reports it exclusively on runs that failed after completing auth.

init has the same shape: setTelemetryStage("link"), then authenticateAndLinklogin()"done", then the rest of authenticateAndLink plus detectAndInstall all run before init's next marker ("keys" at index.ts:179). Every failure in that window is attributed to "done".

That inverts what the stage field is for. The same hazard applies to the whole flat TelemetryStage union — a future command adding a "link" or "keys" stage will silently collide with init's.

Suggested fix — restore the caller's stage on a clean return

Restoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports awaiting_callback, correctly attributed to whichever command was running. It's the successful return that has to hand the stage back.

In lib/telemetry.ts:

/** Read the stage a caller had set, so a nested flow can hand it back. */exportfunctioncurrentTelemetryStage(): TelemetryStage|null{returncontext?.stage??null;}

In commands/auth/login.ts — rename the existing body to runLogin and wrap it:

exportasyncfunctionlogin(options: LoginOptions={}): Promise<UserInfo>{// `init`, `link`, and `doctor` call this mid-flow and share the one// process-global stage. Its own markers are worth having while it runs, but// on a clean return the caller's stage comes back so the rest of *their*// work isn't reported as `done`. On a throw the login stage stands: that is// genuinely where the run stopped.constcallerStage=currentTelemetryStage();constuserInfo=awaitrunLogin(options);if(callerStage)setTelemetryStage(callerStage);returnuserInfo;}

Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have (showNextSteps: false correlates with nested calls today, which is a coincidence worth not depending on).

Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — took the wrapper: currentTelemetryStage() reader, and login() hands the caller's stage back on a clean return (login's own stage stands on a throw). Mutation-checked: the nested test fails with the restore removed.

}),
);

setTelemetryStage("store");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"store" is the last stage set before performOAuthFlow returns, so it covers storeToken, fetchUserInfo, setAuth, and then back in login(): revokeToken for the superseded session and ensureFirstApplication.

ensureFirstApplication is the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there produces stage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.

Suggested fix — give the app-creation step its own marker

In lib/telemetry.ts:

 // `clerk auth login`
| "session_check"
| "awaiting_callback"
| "token_exchange"
| "store"
+ | "first_application"

In commands/auth/login.ts:

 // Best-effort: ensure the user has at least one application so downstream
// commands (clerk link, clerk init) have something to operate on.
+ setTelemetryStage("first_application");
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbfirst_application stage added before ensureFirstApplication.

expect(error).toMatchObject({ code: ERROR_CODE.AUTH_TIMEOUT });
expect((error as Error).message).toContain("timed out");

timeoutSpy.mockRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timeoutSpy.mockRestore() and server.stop() run after the assertions, so any failing expect above them skips both. The consequences are asymmetric with the usual leaked-mock nit: a live spyOn(globalThis, "setTimeout") stays installed for the rest of the file, and every later test that calls startAuthServer gets its deadline swallowed and its fire callback captured into a stale closure. One real assertion failure here cascades into a set of confusing unrelated ones.

The file already has the right pattern for this — serveSpy and clearTimeoutSpy are let-declared at describe scope and restored in afterEach.

Suggested fix — move both into the existing afterEach
 describe("auth-server", () => {
let serveSpy: ReturnType<typeof spyOn> | undefined;
let clearTimeoutSpy: ReturnType<typeof spyOn> | undefined;
+ let timeoutSpy: ReturnType<typeof spyOn> | undefined;+ let openServer: { stop: () => void } | undefined;
useCaptureLog();
afterEach(() => {
serveSpy?.mockRestore();
clearTimeoutSpy?.mockRestore();
+ timeoutSpy?.mockRestore();+ openServer?.stop();
serveSpy = undefined;
clearTimeoutSpy = undefined;
+ timeoutSpy = undefined;+ openServer = undefined;
});

Then in the timeout test, assign instead of declaring, and drop the trailing cleanup:

- const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((+ timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((
cb: () => void,
ms?: number,
...rest: unknown[]
) => {
@@
const server = startAuthServer("test-state");
+ openServer = server;
const errorPromise = server.waitForCallback().catch((e: unknown) => e);
@@
expect((error as Error).message).toContain("timed out");
-- timeoutSpy.mockRestore();- server.stop();
});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbtimeoutSpy and the open server moved into the existing afterEach.

await runLogin();

expect(stages.calls().at(-1)).toBe("done");
stages.restore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same shape as the auth-server.test.ts teardown, lower blast radius: stages.restore() is unreachable if expect(stages.calls().at(-1)) fails, leaving the module spy installed for the two tests that follow — each of which then calls spyOn on an already-spied property.

While you're here: the three tests cover done, awaiting_callback, and token_exchange, but nothing pins session_check or store, and nothing covers login() invoked as a subroutine — which is where the stage bookkeeping actually goes wrong.

Suggested fix — register the restore in a teardown hook
 describe("telemetry stages", () => {
+ let stageSpy: ReturnType<typeof spyOn> | undefined;++ afterEach(() => {+ stageSpy?.mockRestore();+ stageSpy = undefined;+ });+
function trackStages() {
- const stage = spyOn(telemetryMod, "setTelemetryStage");- return {- calls: () => stage.mock.calls.map((call) => call[0]),- restore: () => stage.mockRestore(),- };+ const spy = spyOn(telemetryMod, "setTelemetryStage");+ stageSpy = spy;+ return { calls: () => spy.mock.calls.map((call) => call[0]) };
}

Each test then drops its trailing stages.restore() line.

A fourth case worth adding, which is what LOG-001 is about:

test("a nested login leaves the caller's stage in place",async()=>{mockGetValidToken.mockResolvedValue(null);mockOAuthSuccess();telemetryMod.setTelemetryStage("link");conststages=trackStages();awaitrunLogin({showNextSteps: false});expect(stages.calls().at(-1)).toBe("link");});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — spy restore moved to afterEach, and added the nested-login handback test plus a store pin. Skipped a session_check pin: getExistingSession swallows every failure, so no run can terminate at that stage.

throw error;
// A sandbox or firewall that forbids binding loopback fails every login on
// the machine; it is a distinct condition from anything the user did.
throw new CliError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

errorMessage(error) preserves the message text, but the thrown CliError carries no reference to the original — CliErrorOptions has no cause, and observeHostCapabilityFailure only logs when isAgent() and the failure matches its sandbox heuristic. In a normal human-mode run the errno and stack are gone before anyone can look at them, and EACCES (sandbox/firewall) versus EADDRINUSE versus something else is exactly the distinction the new callback_bind_failed code exists to help diagnose.

Suggested fix — keep the original recoverable under --verbose

Every other branch in this file already debug-logs before rejecting; this one is the outlier.

Suggested change
thrownewCliError(
log.debug(`auth-server: bind failed — ${errorinstanceofError ? (error.stack??error.message) : String(error)}`);
thrownewCliError(
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it.

Comment thread.changeset/auth-login-error-codes.md Outdated
@@ -0,0 +1,6 @@
---
"clerk": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit is fix(auth): ... and the change is a diagnostics fix rather than new CLI surface. The repo's bump table maps fix: to patch and reserves minor for new user-facing features, commands, or flags, with an explicit default-to-patch rule when the split is unclear. The parent PR's minor is correct for its own feat(telemetry): commit, but that doesn't carry over.

Suggested fix
Suggested change
"clerk": minor
"clerk": patch
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbpatch.

import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { setTelemetryStage } from "../../lib/telemetry.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other import in this file — including ../../lib/errors.ts and ../../lib/log.ts a few lines up — uses a .ts specifier, as do ~1100 imports across cli-core. There's a small pocket of .js specifiers (lib/skills.ts:10, init/scan.ts:2) and the parent commit added one more at init/index.ts:24, so this is drifting rather than isolated. Bun resolves both, but it's worth keeping the new ones consistent.

Suggested fix
Suggested change
import{setTelemetryStage}from"../../lib/telemetry.js";
import{setTelemetryStage}from"../../lib/telemetry.ts";
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db here, and the parent's init/index.ts specifier in c4658a7 on #441.

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from d3ff139 to 7330d8cCompareAugust 24, 2026 18:11
Base automatically changed from devin/init-telemetry-stages to mainAugust 24, 2026 18:29
djgouldand others added 2 commits August 24, 2026 14:29
Every failure in the browser-callback phase of `clerk auth login` was a plain
`Error`, and `telemetryResultForError` only reads a code off `CliError` and
`ApiError` — so a timed-out wait, an OAuth provider error, a state mismatch, a
missing authorization code, and a loopback bind failure all landed in the
warehouse as `unexpected_error`, indistinguishable from each other and from
every other uncaught throw.
Types those five sites as `CliError` with distinct codes, and instruments login
with stage markers (session_check → awaiting_callback → token_exchange → store
→ done) so an abandoned browser wait is attributable to the step it stopped at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Nested login (init, link) hands the caller's telemetry stage back on a
clean return, so their post-auth work stops reporting stage "done"
- ensureFirstApplication gets its own first_application stage; failures
there no longer read as credential-store problems
- Debug-log the original bind error before wrapping it in CliError
- Restore test spies in afterEach so a failed assertion can't leak them
- Changeset minor -> patch per the bump policy for fix: changes
- .ts import specifier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from bf122db to 8a0416aCompareAugust 24, 2026 18:29
@coderabbitai

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd6729ec-18a2-4f76-ad6e-f63cd1876850

📥 Commits

Reviewing files that changed from the base of the PR and between a736e6a and 8a0416a.

📒 Files selected for processing (7)
  • .changeset/auth-login-error-codes.md
  • packages/cli-core/src/commands/auth/login.test.ts
  • packages/cli-core/src/commands/auth/login.ts
  • packages/cli-core/src/lib/auth-server.test.ts
  • packages/cli-core/src/lib/auth-server.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/telemetry.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go(manual)
  • clerk/dashboard(manual)
  • clerk/accounts(manual)
  • clerk/backoffice(manual)
  • clerk/clerk(manual)
  • clerk/clerk-docs(manual)
  • clerk/cloudflare-workers(manual)
  • clerk/javascript(auto-detected)

Included review availability: 6 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

The login command now records telemetry stages for session checks, OAuth callback waiting, token exchange, credential storage, application setup, and completion. Nested login calls restore the caller’s stage after successful execution. Authentication-server failures now use typed CliError values with machine-readable codes. Tests cover telemetry stages, callback failures, timeout handling, server cleanup, and structured error codes. A patch Changeset documents the release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk:⚪ Minimal · up to 8a041

This PR gives auth login failures specific error codes and records the login stage reached, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers:wyattjoh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. (1 skipped: 1 unsupported.)Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: distinct login failure codes and telemetry stage tracking.
Description check✅ PassedThe description accurately explains the new authentication error codes and login telemetry stages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@djgould
djgould merged commit 71bcb8e into mainAug 24, 2026
11 checks passed
@djgould
djgould deleted the devin/auth-login-error-codes branch August 24, 2026 19:01
@github-actionsgithub-actionsBot mentioned this pull request Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@djgould@wyattjoh
, '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

fix(auth): give each login failure its own error code and stage - #442

Merged
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes
Aug 24, 2026
Merged

fix(auth): give each login failure its own error code and stage#442
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes

Conversation

@djgould

@djgoulddjgould commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #441 — retarget to main once it merges.

Every failure in the auth login browser flow reported as one generic unexpected_error. Each now has its own code — auth_timeout, oauth_provider_error, oauth_state_mismatch, oauth_no_code, callback_bind_failed — and login records which step it reached (session_check → awaiting_callback → token_exchange → store → done), so a walked-away browser wait is distinguishable from real breakage.

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0416a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
clerkPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from a3d8784 to d3ff139CompareAugust 24, 2026 15:17
@djgould
djgould marked this pull request as ready for review August 24, 2026 16:28
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());

bar();
setTelemetryStage("done");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

login() isn't only the clerk auth login command body — init calls it from resolveAuthLabel (init/index.ts:460), link from ensureAuth (link/index.ts:119), and doctor from its context fixer. The telemetry context is process-global (telemetry.ts:87), created once per run by the preAction hook against the top-level command, so a stage set in here lands on that command's event.

The concrete failure: clerk link in a logged-out directory calls login(), which reaches this line and sets stage: "done". Control returns to ensureAuth, and link then does the work it actually exists to do — resolveProfile, app selection, writing the profile. If any of that throws, the event is command: "link", outcome: "error", stage: "done". link sets no stages of its own, so this is the only stage it will ever report, and it reports it exclusively on runs that failed after completing auth.

init has the same shape: setTelemetryStage("link"), then authenticateAndLinklogin()"done", then the rest of authenticateAndLink plus detectAndInstall all run before init's next marker ("keys" at index.ts:179). Every failure in that window is attributed to "done".

That inverts what the stage field is for. The same hazard applies to the whole flat TelemetryStage union — a future command adding a "link" or "keys" stage will silently collide with init's.

Suggested fix — restore the caller's stage on a clean return

Restoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports awaiting_callback, correctly attributed to whichever command was running. It's the successful return that has to hand the stage back.

In lib/telemetry.ts:

/** Read the stage a caller had set, so a nested flow can hand it back. */exportfunctioncurrentTelemetryStage(): TelemetryStage|null{returncontext?.stage??null;}

In commands/auth/login.ts — rename the existing body to runLogin and wrap it:

exportasyncfunctionlogin(options: LoginOptions={}): Promise<UserInfo>{// `init`, `link`, and `doctor` call this mid-flow and share the one// process-global stage. Its own markers are worth having while it runs, but// on a clean return the caller's stage comes back so the rest of *their*// work isn't reported as `done`. On a throw the login stage stands: that is// genuinely where the run stopped.constcallerStage=currentTelemetryStage();constuserInfo=awaitrunLogin(options);if(callerStage)setTelemetryStage(callerStage);returnuserInfo;}

Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have (showNextSteps: false correlates with nested calls today, which is a coincidence worth not depending on).

Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — took the wrapper: currentTelemetryStage() reader, and login() hands the caller's stage back on a clean return (login's own stage stands on a throw). Mutation-checked: the nested test fails with the restore removed.

}),
);

setTelemetryStage("store");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"store" is the last stage set before performOAuthFlow returns, so it covers storeToken, fetchUserInfo, setAuth, and then back in login(): revokeToken for the superseded session and ensureFirstApplication.

ensureFirstApplication is the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there produces stage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.

Suggested fix — give the app-creation step its own marker

In lib/telemetry.ts:

 // `clerk auth login`
| "session_check"
| "awaiting_callback"
| "token_exchange"
| "store"
+ | "first_application"

In commands/auth/login.ts:

 // Best-effort: ensure the user has at least one application so downstream
// commands (clerk link, clerk init) have something to operate on.
+ setTelemetryStage("first_application");
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbfirst_application stage added before ensureFirstApplication.

expect(error).toMatchObject({ code: ERROR_CODE.AUTH_TIMEOUT });
expect((error as Error).message).toContain("timed out");

timeoutSpy.mockRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timeoutSpy.mockRestore() and server.stop() run after the assertions, so any failing expect above them skips both. The consequences are asymmetric with the usual leaked-mock nit: a live spyOn(globalThis, "setTimeout") stays installed for the rest of the file, and every later test that calls startAuthServer gets its deadline swallowed and its fire callback captured into a stale closure. One real assertion failure here cascades into a set of confusing unrelated ones.

The file already has the right pattern for this — serveSpy and clearTimeoutSpy are let-declared at describe scope and restored in afterEach.

Suggested fix — move both into the existing afterEach
 describe("auth-server", () => {
let serveSpy: ReturnType<typeof spyOn> | undefined;
let clearTimeoutSpy: ReturnType<typeof spyOn> | undefined;
+ let timeoutSpy: ReturnType<typeof spyOn> | undefined;+ let openServer: { stop: () => void } | undefined;
useCaptureLog();
afterEach(() => {
serveSpy?.mockRestore();
clearTimeoutSpy?.mockRestore();
+ timeoutSpy?.mockRestore();+ openServer?.stop();
serveSpy = undefined;
clearTimeoutSpy = undefined;
+ timeoutSpy = undefined;+ openServer = undefined;
});

Then in the timeout test, assign instead of declaring, and drop the trailing cleanup:

- const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((+ timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((
cb: () => void,
ms?: number,
...rest: unknown[]
) => {
@@
const server = startAuthServer("test-state");
+ openServer = server;
const errorPromise = server.waitForCallback().catch((e: unknown) => e);
@@
expect((error as Error).message).toContain("timed out");
-- timeoutSpy.mockRestore();- server.stop();
});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbtimeoutSpy and the open server moved into the existing afterEach.

await runLogin();

expect(stages.calls().at(-1)).toBe("done");
stages.restore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same shape as the auth-server.test.ts teardown, lower blast radius: stages.restore() is unreachable if expect(stages.calls().at(-1)) fails, leaving the module spy installed for the two tests that follow — each of which then calls spyOn on an already-spied property.

While you're here: the three tests cover done, awaiting_callback, and token_exchange, but nothing pins session_check or store, and nothing covers login() invoked as a subroutine — which is where the stage bookkeeping actually goes wrong.

Suggested fix — register the restore in a teardown hook
 describe("telemetry stages", () => {
+ let stageSpy: ReturnType<typeof spyOn> | undefined;++ afterEach(() => {+ stageSpy?.mockRestore();+ stageSpy = undefined;+ });+
function trackStages() {
- const stage = spyOn(telemetryMod, "setTelemetryStage");- return {- calls: () => stage.mock.calls.map((call) => call[0]),- restore: () => stage.mockRestore(),- };+ const spy = spyOn(telemetryMod, "setTelemetryStage");+ stageSpy = spy;+ return { calls: () => spy.mock.calls.map((call) => call[0]) };
}

Each test then drops its trailing stages.restore() line.

A fourth case worth adding, which is what LOG-001 is about:

test("a nested login leaves the caller's stage in place",async()=>{mockGetValidToken.mockResolvedValue(null);mockOAuthSuccess();telemetryMod.setTelemetryStage("link");conststages=trackStages();awaitrunLogin({showNextSteps: false});expect(stages.calls().at(-1)).toBe("link");});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — spy restore moved to afterEach, and added the nested-login handback test plus a store pin. Skipped a session_check pin: getExistingSession swallows every failure, so no run can terminate at that stage.

throw error;
// A sandbox or firewall that forbids binding loopback fails every login on
// the machine; it is a distinct condition from anything the user did.
throw new CliError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

errorMessage(error) preserves the message text, but the thrown CliError carries no reference to the original — CliErrorOptions has no cause, and observeHostCapabilityFailure only logs when isAgent() and the failure matches its sandbox heuristic. In a normal human-mode run the errno and stack are gone before anyone can look at them, and EACCES (sandbox/firewall) versus EADDRINUSE versus something else is exactly the distinction the new callback_bind_failed code exists to help diagnose.

Suggested fix — keep the original recoverable under --verbose

Every other branch in this file already debug-logs before rejecting; this one is the outlier.

Suggested change
thrownewCliError(
log.debug(`auth-server: bind failed — ${errorinstanceofError ? (error.stack??error.message) : String(error)}`);
thrownewCliError(
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it.

Comment thread.changeset/auth-login-error-codes.md Outdated
@@ -0,0 +1,6 @@
---
"clerk": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit is fix(auth): ... and the change is a diagnostics fix rather than new CLI surface. The repo's bump table maps fix: to patch and reserves minor for new user-facing features, commands, or flags, with an explicit default-to-patch rule when the split is unclear. The parent PR's minor is correct for its own feat(telemetry): commit, but that doesn't carry over.

Suggested fix
Suggested change
"clerk": minor
"clerk": patch
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbpatch.

import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { setTelemetryStage } from "../../lib/telemetry.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other import in this file — including ../../lib/errors.ts and ../../lib/log.ts a few lines up — uses a .ts specifier, as do ~1100 imports across cli-core. There's a small pocket of .js specifiers (lib/skills.ts:10, init/scan.ts:2) and the parent commit added one more at init/index.ts:24, so this is drifting rather than isolated. Bun resolves both, but it's worth keeping the new ones consistent.

Suggested fix
Suggested change
import{setTelemetryStage}from"../../lib/telemetry.js";
import{setTelemetryStage}from"../../lib/telemetry.ts";
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db here, and the parent's init/index.ts specifier in c4658a7 on #441.

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from d3ff139 to 7330d8cCompareAugust 24, 2026 18:11
Base automatically changed from devin/init-telemetry-stages to mainAugust 24, 2026 18:29
djgouldand others added 2 commits August 24, 2026 14:29
Every failure in the browser-callback phase of `clerk auth login` was a plain
`Error`, and `telemetryResultForError` only reads a code off `CliError` and
`ApiError` — so a timed-out wait, an OAuth provider error, a state mismatch, a
missing authorization code, and a loopback bind failure all landed in the
warehouse as `unexpected_error`, indistinguishable from each other and from
every other uncaught throw.
Types those five sites as `CliError` with distinct codes, and instruments login
with stage markers (session_check → awaiting_callback → token_exchange → store
→ done) so an abandoned browser wait is attributable to the step it stopped at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Nested login (init, link) hands the caller's telemetry stage back on a
clean return, so their post-auth work stops reporting stage "done"
- ensureFirstApplication gets its own first_application stage; failures
there no longer read as credential-store problems
- Debug-log the original bind error before wrapping it in CliError
- Restore test spies in afterEach so a failed assertion can't leak them
- Changeset minor -> patch per the bump policy for fix: changes
- .ts import specifier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from bf122db to 8a0416aCompareAugust 24, 2026 18:29
@coderabbitai

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd6729ec-18a2-4f76-ad6e-f63cd1876850

📥 Commits

Reviewing files that changed from the base of the PR and between a736e6a and 8a0416a.

📒 Files selected for processing (7)
  • .changeset/auth-login-error-codes.md
  • packages/cli-core/src/commands/auth/login.test.ts
  • packages/cli-core/src/commands/auth/login.ts
  • packages/cli-core/src/lib/auth-server.test.ts
  • packages/cli-core/src/lib/auth-server.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/telemetry.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go(manual)
  • clerk/dashboard(manual)
  • clerk/accounts(manual)
  • clerk/backoffice(manual)
  • clerk/clerk(manual)
  • clerk/clerk-docs(manual)
  • clerk/cloudflare-workers(manual)
  • clerk/javascript(auto-detected)

Included review availability: 6 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

The login command now records telemetry stages for session checks, OAuth callback waiting, token exchange, credential storage, application setup, and completion. Nested login calls restore the caller’s stage after successful execution. Authentication-server failures now use typed CliError values with machine-readable codes. Tests cover telemetry stages, callback failures, timeout handling, server cleanup, and structured error codes. A patch Changeset documents the release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk:⚪ Minimal · up to 8a041

This PR gives auth login failures specific error codes and records the login stage reached, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers:wyattjoh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. (1 skipped: 1 unsupported.)Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: distinct login failure codes and telemetry stage tracking.
Description check✅ PassedThe description accurately explains the new authentication error codes and login telemetry stages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@djgould
djgould merged commit 71bcb8e into mainAug 24, 2026
11 checks passed
@djgould
djgould deleted the devin/auth-login-error-codes branch August 24, 2026 19:01
@github-actionsgithub-actionsBot mentioned this pull request Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@djgould@wyattjoh
, '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

fix(auth): give each login failure its own error code and stage - #442

Merged
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes
Aug 24, 2026
Merged

fix(auth): give each login failure its own error code and stage#442
djgould merged 2 commits into
mainfrom
devin/auth-login-error-codes

Conversation

@djgould

@djgoulddjgould commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Stacked on #441 — retarget to main once it merges.

Every failure in the auth login browser flow reported as one generic unexpected_error. Each now has its own code — auth_timeout, oauth_provider_error, oauth_state_mismatch, oauth_no_code, callback_bind_failed — and login records which step it reached (session_check → awaiting_callback → token_exchange → store → done), so a walked-away browser wait is distinguishable from real breakage.

🤖 Generated with Claude Code

@changeset-bot

changeset-botBot commented Aug 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a0416a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
clerkPatch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from a3d8784 to d3ff139CompareAugust 24, 2026 15:17
@djgould
djgould marked this pull request as ready for review August 24, 2026 16:28
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());

bar();
setTelemetryStage("done");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

login() isn't only the clerk auth login command body — init calls it from resolveAuthLabel (init/index.ts:460), link from ensureAuth (link/index.ts:119), and doctor from its context fixer. The telemetry context is process-global (telemetry.ts:87), created once per run by the preAction hook against the top-level command, so a stage set in here lands on that command's event.

The concrete failure: clerk link in a logged-out directory calls login(), which reaches this line and sets stage: "done". Control returns to ensureAuth, and link then does the work it actually exists to do — resolveProfile, app selection, writing the profile. If any of that throws, the event is command: "link", outcome: "error", stage: "done". link sets no stages of its own, so this is the only stage it will ever report, and it reports it exclusively on runs that failed after completing auth.

init has the same shape: setTelemetryStage("link"), then authenticateAndLinklogin()"done", then the rest of authenticateAndLink plus detectAndInstall all run before init's next marker ("keys" at index.ts:179). Every failure in that window is attributed to "done".

That inverts what the stage field is for. The same hazard applies to the whole flat TelemetryStage union — a future command adding a "link" or "keys" stage will silently collide with init's.

Suggested fix — restore the caller's stage on a clean return

Restoring only on a clean return keeps the useful half: a run that dies inside the browser wait still reports awaiting_callback, correctly attributed to whichever command was running. It's the successful return that has to hand the stage back.

In lib/telemetry.ts:

/** Read the stage a caller had set, so a nested flow can hand it back. */exportfunctioncurrentTelemetryStage(): TelemetryStage|null{returncontext?.stage??null;}

In commands/auth/login.ts — rename the existing body to runLogin and wrap it:

exportasyncfunctionlogin(options: LoginOptions={}): Promise<UserInfo>{// `init`, `link`, and `doctor` call this mid-flow and share the one// process-global stage. Its own markers are worth having while it runs, but// on a clean return the caller's stage comes back so the rest of *their*// work isn't reported as `done`. On a throw the login stage stands: that is// genuinely where the run stopped.constcallerStage=currentTelemetryStage();constuserInfo=awaitrunLogin(options);if(callerStage)setTelemetryStage(callerStage);returnuserInfo;}

Gating the stage calls on login being the invoked command works too, and is more explicit — but it needs a signal login doesn't currently have (showNextSteps: false correlates with nested calls today, which is a coincidence worth not depending on).

Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — took the wrapper: currentTelemetryStage() reader, and login() hands the caller's stage back on a clean return (login's own stage stands on a throw). Mutation-checked: the nested test fails with the restore removed.

}),
);

setTelemetryStage("store");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"store" is the last stage set before performOAuthFlow returns, so it covers storeToken, fetchUserInfo, setAuth, and then back in login(): revokeToken for the superseded session and ensureFirstApplication.

ensureFirstApplication is the one that matters — it's a PLAPI round trip that creates an application, and it's the slowest and most failure-prone step in the tail of this flow. A user whose login dies there produces stage: "store", which reads as a credential-store problem and points debugging at the keychain instead of the API.

Suggested fix — give the app-creation step its own marker

In lib/telemetry.ts:

 // `clerk auth login`
| "session_check"
| "awaiting_callback"
| "token_exchange"
| "store"
+ | "first_application"

In commands/auth/login.ts:

 // Best-effort: ensure the user has at least one application so downstream
// commands (clerk link, clerk init) have something to operate on.
+ setTelemetryStage("first_application");
await withSpinner("Setting up your default application...", async () => ensureFirstApplication());
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbfirst_application stage added before ensureFirstApplication.

expect(error).toMatchObject({ code: ERROR_CODE.AUTH_TIMEOUT });
expect((error as Error).message).toContain("timed out");

timeoutSpy.mockRestore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timeoutSpy.mockRestore() and server.stop() run after the assertions, so any failing expect above them skips both. The consequences are asymmetric with the usual leaked-mock nit: a live spyOn(globalThis, "setTimeout") stays installed for the rest of the file, and every later test that calls startAuthServer gets its deadline swallowed and its fire callback captured into a stale closure. One real assertion failure here cascades into a set of confusing unrelated ones.

The file already has the right pattern for this — serveSpy and clearTimeoutSpy are let-declared at describe scope and restored in afterEach.

Suggested fix — move both into the existing afterEach
 describe("auth-server", () => {
let serveSpy: ReturnType<typeof spyOn> | undefined;
let clearTimeoutSpy: ReturnType<typeof spyOn> | undefined;
+ let timeoutSpy: ReturnType<typeof spyOn> | undefined;+ let openServer: { stop: () => void } | undefined;
useCaptureLog();
afterEach(() => {
serveSpy?.mockRestore();
clearTimeoutSpy?.mockRestore();
+ timeoutSpy?.mockRestore();+ openServer?.stop();
serveSpy = undefined;
clearTimeoutSpy = undefined;
+ timeoutSpy = undefined;+ openServer = undefined;
});

Then in the timeout test, assign instead of declaring, and drop the trailing cleanup:

- const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((+ timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(((
cb: () => void,
ms?: number,
...rest: unknown[]
) => {
@@
const server = startAuthServer("test-state");
+ openServer = server;
const errorPromise = server.waitForCallback().catch((e: unknown) => e);
@@
expect((error as Error).message).toContain("timed out");
-- timeoutSpy.mockRestore();- server.stop();
});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbtimeoutSpy and the open server moved into the existing afterEach.

await runLogin();

expect(stages.calls().at(-1)).toBe("done");
stages.restore();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same shape as the auth-server.test.ts teardown, lower blast radius: stages.restore() is unreachable if expect(stages.calls().at(-1)) fails, leaving the module spy installed for the two tests that follow — each of which then calls spyOn on an already-spied property.

While you're here: the three tests cover done, awaiting_callback, and token_exchange, but nothing pins session_check or store, and nothing covers login() invoked as a subroutine — which is where the stage bookkeeping actually goes wrong.

Suggested fix — register the restore in a teardown hook
 describe("telemetry stages", () => {
+ let stageSpy: ReturnType<typeof spyOn> | undefined;++ afterEach(() => {+ stageSpy?.mockRestore();+ stageSpy = undefined;+ });+
function trackStages() {
- const stage = spyOn(telemetryMod, "setTelemetryStage");- return {- calls: () => stage.mock.calls.map((call) => call[0]),- restore: () => stage.mockRestore(),- };+ const spy = spyOn(telemetryMod, "setTelemetryStage");+ stageSpy = spy;+ return { calls: () => spy.mock.calls.map((call) => call[0]) };
}

Each test then drops its trailing stages.restore() line.

A fourth case worth adding, which is what LOG-001 is about:

test("a nested login leaves the caller's stage in place",async()=>{mockGetValidToken.mockResolvedValue(null);mockOAuthSuccess();telemetryMod.setTelemetryStage("link");conststages=trackStages();awaitrunLogin({showNextSteps: false});expect(stages.calls().at(-1)).toBe("link");});
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — spy restore moved to afterEach, and added the nested-login handback test plus a store pin. Skipped a session_check pin: getExistingSession swallows every failure, so no run can terminate at that stage.

throw error;
// A sandbox or firewall that forbids binding loopback fails every login on
// the machine; it is a distinct condition from anything the user did.
throw new CliError(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

errorMessage(error) preserves the message text, but the thrown CliError carries no reference to the original — CliErrorOptions has no cause, and observeHostCapabilityFailure only logs when isAgent() and the failure matches its sandbox heuristic. In a normal human-mode run the errno and stack are gone before anyone can look at them, and EACCES (sandbox/firewall) versus EADDRINUSE versus something else is exactly the distinction the new callback_bind_failed code exists to help diagnose.

Suggested fix — keep the original recoverable under --verbose

Every other branch in this file already debug-logs before rejecting; this one is the outlier.

Suggested change
thrownewCliError(
log.debug(`auth-server: bind failed — ${errorinstanceofError ? (error.stack??error.message) : String(error)}`);
thrownewCliError(
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db — original error (stack included) debug-logged before the CliError wraps it.

Comment thread.changeset/auth-login-error-codes.md Outdated
@@ -0,0 +1,6 @@
---
"clerk": minor

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The commit is fix(auth): ... and the change is a diagnostics fix rather than new CLI surface. The repo's bump table maps fix: to patch and reserves minor for new user-facing features, commands, or flags, with an explicit default-to-patch rule when the split is unclear. The parent PR's minor is correct for its own feat(telemetry): commit, but that doesn't carry over.

Suggested fix
Suggested change
"clerk": minor
"clerk": patch
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122dbpatch.

import { openBrowser } from "../../lib/open.ts";
import { cyan, dim } from "../../lib/color.ts";
import { log } from "../../lib/log.ts";
import { setTelemetryStage } from "../../lib/telemetry.js";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every other import in this file — including ../../lib/errors.ts and ../../lib/log.ts a few lines up — uses a .ts specifier, as do ~1100 imports across cli-core. There's a small pocket of .js specifiers (lib/skills.ts:10, init/scan.ts:2) and the parent commit added one more at init/index.ts:24, so this is drifting rather than isolated. Bun resolves both, but it's worth keeping the new ones consistent.

Suggested fix
Suggested change
import{setTelemetryStage}from"../../lib/telemetry.js";
import{setTelemetryStage}from"../../lib/telemetry.ts";
Sent from Claude

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Fixed in bf122db here, and the parent's init/index.ts specifier in c4658a7 on #441.

@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from d3ff139 to 7330d8cCompareAugust 24, 2026 18:11
Base automatically changed from devin/init-telemetry-stages to mainAugust 24, 2026 18:29
djgouldand others added 2 commits August 24, 2026 14:29
Every failure in the browser-callback phase of `clerk auth login` was a plain
`Error`, and `telemetryResultForError` only reads a code off `CliError` and
`ApiError` — so a timed-out wait, an OAuth provider error, a state mismatch, a
missing authorization code, and a loopback bind failure all landed in the
warehouse as `unexpected_error`, indistinguishable from each other and from
every other uncaught throw.
Types those five sites as `CliError` with distinct codes, and instruments login
with stage markers (session_check → awaiting_callback → token_exchange → store
→ done) so an abandoned browser wait is attributable to the step it stopped at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Nested login (init, link) hands the caller's telemetry stage back on a
clean return, so their post-auth work stops reporting stage "done"
- ensureFirstApplication gets its own first_application stage; failures
there no longer read as credential-store problems
- Debug-log the original bind error before wrapping it in CliError
- Restore test spies in afterEach so a failed assertion can't leak them
- Changeset minor -> patch per the bump policy for fix: changes
- .ts import specifier
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@djgould
djgouldforce-pushed the devin/auth-login-error-codes branch from bf122db to 8a0416aCompareAugust 24, 2026 18:29
@coderabbitai

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dd6729ec-18a2-4f76-ad6e-f63cd1876850

📥 Commits

Reviewing files that changed from the base of the PR and between a736e6a and 8a0416a.

📒 Files selected for processing (7)
  • .changeset/auth-login-error-codes.md
  • packages/cli-core/src/commands/auth/login.test.ts
  • packages/cli-core/src/commands/auth/login.ts
  • packages/cli-core/src/lib/auth-server.test.ts
  • packages/cli-core/src/lib/auth-server.ts
  • packages/cli-core/src/lib/errors.ts
  • packages/cli-core/src/lib/telemetry.ts
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go(manual)
  • clerk/dashboard(manual)
  • clerk/accounts(manual)
  • clerk/backoffice(manual)
  • clerk/clerk(manual)
  • clerk/clerk-docs(manual)
  • clerk/cloudflare-workers(manual)
  • clerk/javascript(auto-detected)

Included review availability: 6 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 10 reviews per hour.


📝 Walkthrough

Walkthrough

The login command now records telemetry stages for session checks, OAuth callback waiting, token exchange, credential storage, application setup, and completion. Nested login calls restore the caller’s stage after successful execution. Authentication-server failures now use typed CliError values with machine-readable codes. Tests cover telemetry stages, callback failures, timeout handling, server cleanup, and structured error codes. A patch Changeset documents the release.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk:⚪ Minimal · up to 8a041

This PR gives auth login failures specific error codes and records the login stage reached, with no actionable merge-blocking risk remaining beyond normal checks and review.

Suggested reviewers:wyattjoh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check nameStatusExplanationResolution
Docstring Coverage⚠️ WarningDocstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. (1 skipped: 1 unsupported.)Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check nameStatusExplanation
Title check✅ PassedThe title clearly summarizes the main changes: distinct login failure codes and telemetry stage tracking.
Description check✅ PassedThe description accurately explains the new authentication error codes and login telemetry stages.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@djgould
djgould merged commit 71bcb8e into mainAug 24, 2026
11 checks passed
@djgould
djgould deleted the devin/auth-login-error-codes branch August 24, 2026 19:01
@github-actionsgithub-actionsBot mentioned this pull request Aug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@djgould@wyattjoh