Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5639e43
feat(doctor): add iOS diagnostics
seanperez29 Aug 21, 2026
bcb8f92
fix(doctor): harden iOS verification
seanperez29 Aug 26, 2026
2c43ba3
fix(doctor): verify authentication and scheme evidence
seanperez29 Aug 27, 2026
135f8ce
fix(doctor): terminate timed-out Xcode process trees
seanperez29 Aug 27, 2026
587ab76
fix(doctor): account for transitive Swift packages
seanperez29 Aug 27, 2026
ad6dfe7
fix(doctor): audit custom Apple authentication
seanperez29 Aug 27, 2026
964d7d7
test(doctor): cover contextual associated domains
seanperez29 Aug 27, 2026
4c52945
test(doctor): verify root auth wiring
seanperez29 Aug 27, 2026
d284d3c
fix(doctor): stop Xcode jobs on interruption
seanperez29 Aug 27, 2026
688725e
fix(doctor): harden iOS verification
seanperez29 Aug 27, 2026
e8a5d2e
fix(doctor): redact multiline private keys
seanperez29 Aug 27, 2026
3e2aad7
fix(doctor): parse attributed Clerk environments
seanperez29 Aug 27, 2026
3c00637
test(doctor): verify proven runtime key wiring
seanperez29 Aug 27, 2026
31dbec4
fix(doctor): report invalid Apple entitlements
seanperez29 Aug 28, 2026
53e31bf
fix(doctor): verify built application identity
seanperez29 Aug 28, 2026
086a7fc
fix(doctor): bind verification to claimed app
seanperez29 Aug 28, 2026
207adbe
fix(doctor): quarantine temporary build cleanup
seanperez29 Aug 28, 2026
9fae8bc
fix(doctor): preserve custom build directories
seanperez29 Aug 28, 2026
769c35d
fix(doctor): validate AuthView SDK compatibility
seanperez29 Aug 28, 2026
3265c6a
fix(doctor): reap successful Xcode descendants
seanperez29 Aug 28, 2026
c098b7c
fix(doctor): report unsupported Apple repairs
seanperez29 Aug 28, 2026
a820e5b
fix(doctor): prefer configured Platform API key
seanperez29 Aug 28, 2026
35da5dd
fix(doctor): bound Xcode output draining
seanperez29 Aug 28, 2026
e779671
fix(doctor): correct Apple config scope guidance
seanperez29 Aug 28, 2026
df3c627
fix(doctor): harden native diagnostics
seanperez29 Aug 29, 2026
008f5e2
test(doctor): reject invalid environment overloads
seanperez29 Aug 29, 2026
d8e754c
fix(doctor): block incomplete package graphs
seanperez29 Aug 29, 2026
147e4fd
fix(doctor): inspect referenced project packages
seanperez29 Aug 29, 2026
6d5a904
refactor(doctor): clarify custom iOS key sources
seanperez29 Aug 29, 2026
61ea558
test(doctor): stop validating custom callbacks
seanperez29 Aug 29, 2026
eabaa01
refactor(doctor): consume explicit key state
seanperez29 Aug 29, 2026
a807fe9
fix(doctor): redact PEM output before truncation
seanperez29 Aug 29, 2026
17ec20e
fix(doctor): validate selected iOS SDK graphs
seanperez29 Aug 29, 2026
d36c161
fix(doctor): verify FAPI host before inspection
seanperez29 Aug 30, 2026
bbfcfd3
fix(doctor): use registered Bundle ID casing
seanperez29 Aug 30, 2026
f375bc4
fix(doctor): reconcile Apple Bundle ID casing
seanperez29 Aug 30, 2026
bcf29c3
refactor(doctor): remove Xcode execution checks
seanperez29 Aug 31, 2026
b003c9f
test(doctor): cover malformed associated domains
seanperez29 Aug 31, 2026
a6d9946
test(e2e): cover live native iOS provisioning
seanperez29 Sep 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ios-aware-doctor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add native iOS project diagnostics to `clerk doctor`.
59 changes: 50 additions & 9 deletions packages/cli-core/src/commands/doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Runs a series of diagnostic checks on your Clerk CLI setup and reports
the status of each check. The command is read-only and never modifies
any state (unless `--fix` is used).
project or remote application state unless `--fix` is used.

## Usage

Expand All @@ -12,6 +12,7 @@ clerk doctor --verbose # Show detailed output
clerk doctor --json # Output results as JSON
clerk doctor --spotlight # Only show warnings and failures
clerk doctor --fix # Offer to auto-fix issues
clerk doctor --target MyApp
```

## Options
Expand All @@ -22,21 +23,56 @@ clerk doctor --fix # Offer to auto-fix issues
| `--json` | Output results as machine-readable JSON |
| `--spotlight` | Only show warnings and failures (hide passing checks) |
| `--fix` | Offer to auto-fix issues with known remedies |
| `--target` | Select an iOS application target by name or object ID |

## Checks

| Check | Category | What it verifies |
| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Authentication token | Authentication | Credential store has a stored token |
| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) |
| Account credentials | Authentication | Credential store has a session or a Platform API key is configured |
| Token validity | Authentication | OAuth access is verified through `/oauth/userinfo` or an account-scoped application-list fallback; Platform API-key access uses the same read-only application-list request |
| Project linkage | Project | Current directory is linked to a Clerk app |
| Linked application | Project | Linked application ID is accessible via the API |
| Instances | Project | Configured dev/prod instance IDs match the application's instances |
| Environment variables | Environment | .env.local or .env has Clerk keys |
| Environment variables | Environment | Non-iOS projects have Clerk keys in `.env.local` or `.env` |
| CLI configuration | Configuration | CLI config file exists and parses |
| Shell completion | Configuration | Shell autocompletion is installed for the detected shell |
| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake; warns on an unreadable client config (skipped when nothing is installed; warns, never fails) |

### iOS projects

When the current directory contains an Xcode project or `--target` is provided,
doctor replaces the web `.env` check with the same semantic Xcode, Swift, and
entitlements inspection used by `clerk init`. It reports separate results for:

- application-target selection;
- ClerkKit and ClerkKitUI product linkage;
- `Clerk.configure` and, for direct literal configuration, the selected target's effective development key;
- SwiftUI environment injection and authentication-flow evidence;
- AuthView's enabled methods and required local Apple capability;
- Associated Domains and the optional Sign in with Apple entitlement;
- Native API state and the exact Bundle ID registration on the linked
development instance; and
- the Clerk Apple connection when the selected target already declares the
native Apple entitlement.

iOS diagnostics never require a secret key in the Xcode project or an env
file. A direct literal publishable key is compared with the linked development
application using only redacted Frontend API host metadata. For a single
startup `Clerk.configure` call that uses a custom publishable-key source,
Doctor verifies that the call exists but does not inspect its value. Once the
project is linked, Doctor uses the explicitly selected development application
for read-only AuthView, Native Application, Associated Domains, and Apple
checks; this does not prove that the custom publishable key belongs to that
application. Keys, provider credentials, and raw remote config are not included
in human or JSON output. AuthView, Native Application, and Apple remote checks
are GET-only. Their remedies point back to `clerk init`; `doctor --fix` never
enables an auth strategy or changes Native Application state.

`clerk doctor` inspects configuration and remote Clerk state without invoking
Xcode package resolution, builds, or Simulator execution. Build and runtime
verification remain with Xcode and the project's existing test workflow.

### Accountless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand Down Expand Up @@ -117,8 +153,13 @@ Exit code 1 signals one or more checks failed.

## API Endpoints

| Method | Endpoint | Description |
| ------ | ----------------------------------- | ------------------------------------------------------------------- |
| `GET` | `/oauth/userinfo` | Validates the stored auth token |
| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist |
| `GET` | `/v1/instance` | Names the accountless application (best-effort, via its secret key) |
| Method | Endpoint | Description |
| ------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `GET` | `/oauth/userinfo` | Validates the stored auth token |
| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist |
| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_settings` | Verifies Native API state for iOS projects |
| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_applications/ios` | Verifies the exact iOS Bundle ID registration |
| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Audits the Apple connection when native Apple is relevant |
| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/config/schema` | Determines whether an unhealthy Apple connection can be safely reconciled by init |
| `GET` | `https://{fapiHost}/v1/environment` | Verifies whether AuthView currently offers native Apple sign-in |
| `GET` | `/v1/instance` | Names the accountless application (best-effort, via its secret key) |
94 changes: 84 additions & 10 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import { join } from "node:path";
import { homedir } from "node:os";
import { getConfigFile } from "../../lib/config.ts";
import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { errorMessage, isAuthError, PlapiError } from "../../lib/errors.ts";
import { CliError, ERROR_CODE, errorMessage, isAuthError, PlapiError } from "../../lib/errors.ts";
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
import { parseEnvFile } from "../../lib/dotenv.ts";
import { hasAccountCredentials } from "../../lib/credential-store.ts";
import type { KeylessTarget } from "../../lib/keyless-target.ts";
import { CURRENT_VERSION, IS_DEV_BUILD } from "../../lib/version.ts";
import {
Expand Down Expand Up @@ -93,15 +92,33 @@ async function claimHint(ctx: DoctorContext): Promise<string> {

export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
const check = defineCheck("Logged in", ctx.fixes.login);
const token = await ctx.getToken();

// Malformed-key detection is a side effect of resolving the keyless target
// (see getKeylessKeyError), so resolve it before any early return — a
// stored account token must not hide a broken local CLERK_SECRET_KEY that
// other commands still prefer over the account session.
// Platform API key or stored account token must not hide a broken local
// CLERK_SECRET_KEY that other commands still prefer over account credentials.
const keyless = await ctx.getKeylessTarget();
const keyError = await ctx.getKeylessKeyError();

if (ctx.hasPlatformAPIKey()) {
if (keyError) {
return check.warn(
`Platform API key configured, but the local secret key is unusable: ${keyError.message}`,
{
remedy:
"Fix or remove the malformed secret key — some commands prefer it over account credentials.",
fixable: false,
},
);
}
return check.pass("Platform API key configured");
}

// Only consult OAuth credential storage when a Platform API key is not
// configured. The Platform key is sufficient for Doctor, and an unreadable
// fallback credential store must not make an otherwise valid setup fail.
const token = await ctx.getToken();

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand Down Expand Up @@ -158,8 +175,36 @@ export async function checkHostExecution(): Promise<CheckResult> {

export async function checkTokenValid(ctx: DoctorContext): Promise<CheckResult> {
const check = defineCheck("Authentication valid", ctx.fixes.login);
if (ctx.hasPlatformAPIKey()) {
try {
await ctx.verifyAccountAccess();
return check.pass("Platform API key access verified");
} catch (error) {
if (
isAuthError(error) ||
(error instanceof CliError && error.code === ERROR_CODE.INVALID_KEY_FORMAT)
) {
return check.fail("Platform API key is invalid or lacks applications:read access", {
remedy:
"Replace CLERK_PLATFORM_API_KEY with a valid key that has applications:read access, then rerun `clerk doctor`.",
fixable: false,
});
}
return check.warn("Could not reach Clerk to verify the Platform API key", {
detail: errorMessage(error),
remedy: "Check your network connection and rerun `clerk doctor`.",
fixable: false,
});
}
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.warn("Account credentials are configured but could not be verified", {
remedy: "Check your Clerk authentication and rerun `clerk doctor`.",
fixable: false,
});
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this accountless application")
Expand All @@ -173,6 +218,37 @@ export async function checkTokenValid(ctx: DoctorContext): Promise<CheckResult>
return check.pass(`Authenticated as ${userInfo.email}`);
} catch (error) {
if (isAuthError(error)) {
// The OAuth userinfo surface is not available in every environment that
// can accept the same account credential through PLAPI. Verify it with
// an account-scoped application-list request: unlike getApplication(),
// this does not depend on the current directory being linked or its
// linked application continuing to exist.
try {
await ctx.verifyAccountAccess();
return check.pass("Account access verified through the Clerk API");
} catch (verificationError) {
if (!isAuthError(verificationError)) {
if (verificationError instanceof PlapiError) {
const unavailable = verificationError.status === 404 ? "endpoint" : "API";
return check.warn(
`Could not verify authentication — Clerk ${unavailable} unavailable`,
{
detail: errorMessage(verificationError),
remedy:
"Check the Clerk environment and service status, then rerun `clerk doctor`.",
fixable: false,
},
);
}

return check.warn("Could not reach Clerk to verify authentication — network issue", {
detail: errorMessage(verificationError),
remedy: "Check your network connection, then rerun `clerk doctor`.",
fixable: false,
});
}
}

// Same fallback whoami uses: an expired session doesn't strand a keyless
// project, so don't tell the user their setup is broken.
const keyless = await ctx.getKeylessTarget();
Expand Down Expand Up @@ -229,7 +305,7 @@ export async function checkProjectLinked(ctx: DoctorContext): Promise<CheckResul

// Someone with an account who hasn't linked this directory *could* reach
// the full account configuration — say so, unlike the fully unclaimed case.
if (await hasAccountCredentials()) {
if (await ctx.hasAccountCredentials()) {
return check.warn(
`Not linked — using the accountless application ${label}, which covers fewer settings`,
{
Expand All @@ -251,8 +327,7 @@ export async function checkProjectLinked(ctx: DoctorContext): Promise<CheckResul

export async function checkLinkedAppExists(ctx: DoctorContext): Promise<CheckResult> {
const check = defineCheck("Application reachable", ctx.fixes.link);
const token = await ctx.getToken();
if (!token) {
if (!(await ctx.hasAccountCredentials())) {
// This check is account-only — the Platform API application record has no
// keyless equivalent — so an unclaimed keyless project has nothing to skip
// *over*, just nothing to verify.
Expand Down Expand Up @@ -286,8 +361,7 @@ export async function checkLinkedAppExists(ctx: DoctorContext): Promise<CheckRes

export async function checkInstances(ctx: DoctorContext): Promise<CheckResult> {
const check = defineCheck("Instance IDs", ctx.fixes.link);
const token = await ctx.getToken();
if (!token) {
if (!(await ctx.hasAccountCredentials())) {
// A linked profile's dev/prod instance IDs are an account-only concept —
// the secret key on disk already addresses its one instance directly.
const keyless = await ctx.getKeylessTarget();
Expand Down
49 changes: 48 additions & 1 deletion packages/cli-core/src/commands/doctor/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ mock.module("../../lib/bapi.ts", () => ({
}));

// stubFetch instead of mock.module for plapi — mock.module leaks globally in Bun
let mockAppResponse: Application | null = null;
let mockAppResponse: Application | Application[] | null = null;
let mockAppError: Error | null = null;
const mockFetch = mock();

Expand Down Expand Up @@ -95,6 +95,37 @@ describe("createDoctorContext", () => {
});
});

describe("verifyAccountAccess", () => {
test("performs one memoized read-only application-list request, including for an empty list", async () => {
mockAppResponse = [];

const ctx = createDoctorContext();
const p1 = ctx.verifyAccountAccess();
const p2 = ctx.verifyAccountAccess();

expect(p1).toBe(p2);
await expect(p1).resolves.toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(String(mockFetch.mock.calls[0]?.[0])).toBe(
"https://api.clerk.com/v1/platform/applications",
);
expect(mockFetch.mock.calls[0]?.[1]).toMatchObject({ method: "GET" });
});

test("memoizes a failed verification request", async () => {
mockAppError = new TypeError("fetch failed");

const ctx = createDoctorContext();
const p1 = ctx.verifyAccountAccess();
const p2 = ctx.verifyAccountAccess();

expect(p1).toBe(p2);
await expect(p1).rejects.toThrow("fetch failed");
await expect(p2).rejects.toThrow("fetch failed");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});

describe("getProfile", () => {
test("returns the same promise on repeated calls", async () => {
const profile = {
Expand Down Expand Up @@ -135,6 +166,7 @@ describe("createDoctorContext", () => {
});

test("returns null when no token", async () => {
delete process.env.CLERK_PLATFORM_API_KEY;
mockGetToken.mockResolvedValue(null);

const ctx = createDoctorContext();
Expand All @@ -144,6 +176,21 @@ describe("createDoctorContext", () => {
expect(mockFetch).not.toHaveBeenCalled();
});

test("fetches the public application shape with a Platform API key", async () => {
mockGetToken.mockResolvedValue(null);
mockResolveProfile.mockResolvedValue({
path: "github.com/org/repo",
profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } },
resolvedVia: "remote" as const,
});
mockAppResponse = { application_id: "app_1", name: "My App", instances: [] };

const ctx = createDoctorContext();
expect(await ctx.getApplication()).toEqual(mockAppResponse);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("include_secret_keys");
});

test("returns null when no profile", async () => {
mockGetToken.mockResolvedValue("test_token");
mockResolveProfile.mockResolvedValue(undefined);
Expand Down
Loading
Loading