Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.
96 changes: 80 additions & 16 deletions packages/cli-core/src/commands/doctor/README.md
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
# Doctor Command

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).
the status of each check. The command is read-only by default. `--fix` and the
explicit Xcode execution flags are the only modes which can change local state.

## Usage

Expand All@@ -12,31 +12,86 @@ 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
clerk doctor --target MyApp --build
clerk doctor --target MyApp --resolve-packages --build
clerk doctor --target MyApp --simulator --device <udid>
```

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| Flag | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
| `--scheme` | Select an Xcode scheme for execution checks |
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
| `--simulator` | Build, install, and launch the selected app in Simulator |
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |

## 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 token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
| 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 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. The linked development publishable key is used only to compare redacted
Frontend API host metadata; 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.

Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
flags are deliberately opt-in because Xcode can run package manifests, plugins,
macros, and project build scripts:

- `--resolve-packages` is the only mode allowed to create or update the
selected container's shared `Package.resolved`.
- `--build` requires a locked remote package graph, verifies the chosen scheme
belongs to the selected target, disables signing, filters Clerk credentials
from the child environment, and builds with temporary DerivedData and package
checkouts.
- `--simulator` additionally installs and launches that isolated build. It
never guesses among multiple devices; agent mode requires `--device`.

A successful build or launch is not a successful authentication test. Doctor
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
methods in the app. Projects which load their publishable key only through an
Xcode Run-scheme environment variable are built but must be launched from Xcode,
because `simctl launch` does not reproduce arbitrary scheme environment state.

### Keyless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand DownExpand Up@@ -75,6 +130,10 @@ re-run to verify the results.
interactive (`clerk auth login` opens a browser, `clerk link` shows a
picker). It is ignored in `--json` mode and agent mode.

`--fix` cannot be combined with Xcode execution flags. This prevents the
post-fix verification pass from resolving, building, or launching a project a
second time.

Fixable issues:

| Issue | Fix action |
Expand DownExpand Up@@ -117,8 +176,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 keyless 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 keyless application (best-effort, via its secret key) |
46 changes: 40 additions & 6 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { 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 DownExpand Up@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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("Authenticated with a Platform API key");
}

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand DownExpand Up@@ -158,8 +171,14 @@ 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()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this keyless application")
Expand All@@ -173,6 +192,23 @@ 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. When a linked
// application is reachable, that authenticated request is stronger
// evidence for the CLI than a userinfo rejection. `getApplication()` is
// cached by the real context, so the later application check reuses this
// request. A genuinely expired hosted session still falls through: PLAPI
// rejects the same token (or token refresh) too.
try {
const app = await ctx.getApplication();
if (app) {
return check.pass("Account access verified through the Clerk API");
}
} catch {
// Preserve the existing expired-session diagnosis below. The
// application check reports its own endpoint-specific failure later.
}

// 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 DownExpand Up@@ -229,7 +265,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 keyless application ${label}, which covers fewer settings`,
{
Expand All@@ -251,8 +287,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 DownExpand Up@@ -286,8 +321,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
16 changes: 16 additions & 0 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,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 +145,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
23 changes: 19 additions & 4 deletions packages/cli-core/src/commands/doctor/context.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { getToken, getValidToken } from "../../lib/credential-store.ts";
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
import { resolveProfile } from "../../lib/config.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
Expand All@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type

export function createDoctorContext(): DoctorContext {
let tokenPromise: Promise<string | null> | undefined;
let accountCredentialsPromise: Promise<boolean> | undefined;
let validTokenPromise: Promise<string | null> | undefined;
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
let appPromise: Promise<Application | null> | undefined;
Expand All@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
let keylessKeyError: CliError | undefined;

const ctx: DoctorContext = {
hasPlatformAPIKey() {
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
},

hasAccountCredentials() {
if (!accountCredentialsPromise) {
accountCredentialsPromise = hasAccountCredentials();
}
return accountCredentialsPromise;
},

getToken() {
if (!tokenPromise) {
tokenPromise = getToken();
Expand All@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
getApplication() {
if (!appPromise) {
appPromise = (async () => {
const token = await ctx.getToken();
if (!token) return null;
if (!(await ctx.hasAccountCredentials())) return null;
const resolved = await ctx.getProfile();
if (!resolved) return null;
return fetchApplication(resolved.profile.appId);
// Doctor only needs application and instance identity. Keeping
// secret keys out of this long-lived, shared diagnostic context
// prevents unrelated checks from retaining credentials they never
// use (including the iOS checks below).
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
})();
}
return appPromise;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
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;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} 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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.
96 changes: 80 additions & 16 deletions packages/cli-core/src/commands/doctor/README.md
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
# Doctor Command

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).
the status of each check. The command is read-only by default. `--fix` and the
explicit Xcode execution flags are the only modes which can change local state.

## Usage

Expand All@@ -12,31 +12,86 @@ 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
clerk doctor --target MyApp --build
clerk doctor --target MyApp --resolve-packages --build
clerk doctor --target MyApp --simulator --device <udid>
```

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| Flag | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
| `--scheme` | Select an Xcode scheme for execution checks |
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
| `--simulator` | Build, install, and launch the selected app in Simulator |
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |

## 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 token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
| 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 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. The linked development publishable key is used only to compare redacted
Frontend API host metadata; 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.

Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
flags are deliberately opt-in because Xcode can run package manifests, plugins,
macros, and project build scripts:

- `--resolve-packages` is the only mode allowed to create or update the
selected container's shared `Package.resolved`.
- `--build` requires a locked remote package graph, verifies the chosen scheme
belongs to the selected target, disables signing, filters Clerk credentials
from the child environment, and builds with temporary DerivedData and package
checkouts.
- `--simulator` additionally installs and launches that isolated build. It
never guesses among multiple devices; agent mode requires `--device`.

A successful build or launch is not a successful authentication test. Doctor
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
methods in the app. Projects which load their publishable key only through an
Xcode Run-scheme environment variable are built but must be launched from Xcode,
because `simctl launch` does not reproduce arbitrary scheme environment state.

### Keyless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand DownExpand Up@@ -75,6 +130,10 @@ re-run to verify the results.
interactive (`clerk auth login` opens a browser, `clerk link` shows a
picker). It is ignored in `--json` mode and agent mode.

`--fix` cannot be combined with Xcode execution flags. This prevents the
post-fix verification pass from resolving, building, or launching a project a
second time.

Fixable issues:

| Issue | Fix action |
Expand DownExpand Up@@ -117,8 +176,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 keyless 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 keyless application (best-effort, via its secret key) |
46 changes: 40 additions & 6 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { 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 DownExpand Up@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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("Authenticated with a Platform API key");
}

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand DownExpand Up@@ -158,8 +171,14 @@ 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()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this keyless application")
Expand All@@ -173,6 +192,23 @@ 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. When a linked
// application is reachable, that authenticated request is stronger
// evidence for the CLI than a userinfo rejection. `getApplication()` is
// cached by the real context, so the later application check reuses this
// request. A genuinely expired hosted session still falls through: PLAPI
// rejects the same token (or token refresh) too.
try {
const app = await ctx.getApplication();
if (app) {
return check.pass("Account access verified through the Clerk API");
}
} catch {
// Preserve the existing expired-session diagnosis below. The
// application check reports its own endpoint-specific failure later.
}

// 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 DownExpand Up@@ -229,7 +265,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 keyless application ${label}, which covers fewer settings`,
{
Expand All@@ -251,8 +287,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 DownExpand Up@@ -286,8 +321,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
16 changes: 16 additions & 0 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,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 +145,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
23 changes: 19 additions & 4 deletions packages/cli-core/src/commands/doctor/context.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { getToken, getValidToken } from "../../lib/credential-store.ts";
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
import { resolveProfile } from "../../lib/config.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
Expand All@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type

export function createDoctorContext(): DoctorContext {
let tokenPromise: Promise<string | null> | undefined;
let accountCredentialsPromise: Promise<boolean> | undefined;
let validTokenPromise: Promise<string | null> | undefined;
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
let appPromise: Promise<Application | null> | undefined;
Expand All@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
let keylessKeyError: CliError | undefined;

const ctx: DoctorContext = {
hasPlatformAPIKey() {
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
},

hasAccountCredentials() {
if (!accountCredentialsPromise) {
accountCredentialsPromise = hasAccountCredentials();
}
return accountCredentialsPromise;
},

getToken() {
if (!tokenPromise) {
tokenPromise = getToken();
Expand All@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
getApplication() {
if (!appPromise) {
appPromise = (async () => {
const token = await ctx.getToken();
if (!token) return null;
if (!(await ctx.hasAccountCredentials())) return null;
const resolved = await ctx.getProfile();
if (!resolved) return null;
return fetchApplication(resolved.profile.appId);
// Doctor only needs application and instance identity. Keeping
// secret keys out of this long-lived, shared diagnostic context
// prevents unrelated checks from retaining credentials they never
// use (including the iOS checks below).
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
})();
}
return appPromise;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.
96 changes: 80 additions & 16 deletions packages/cli-core/src/commands/doctor/README.md
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
# Doctor Command

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).
the status of each check. The command is read-only by default. `--fix` and the
explicit Xcode execution flags are the only modes which can change local state.

## Usage

Expand All@@ -12,31 +12,86 @@ 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
clerk doctor --target MyApp --build
clerk doctor --target MyApp --resolve-packages --build
clerk doctor --target MyApp --simulator --device <udid>
```

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| Flag | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
| `--scheme` | Select an Xcode scheme for execution checks |
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
| `--simulator` | Build, install, and launch the selected app in Simulator |
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |

## 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 token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
| 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 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. The linked development publishable key is used only to compare redacted
Frontend API host metadata; 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.

Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
flags are deliberately opt-in because Xcode can run package manifests, plugins,
macros, and project build scripts:

- `--resolve-packages` is the only mode allowed to create or update the
selected container's shared `Package.resolved`.
- `--build` requires a locked remote package graph, verifies the chosen scheme
belongs to the selected target, disables signing, filters Clerk credentials
from the child environment, and builds with temporary DerivedData and package
checkouts.
- `--simulator` additionally installs and launches that isolated build. It
never guesses among multiple devices; agent mode requires `--device`.

A successful build or launch is not a successful authentication test. Doctor
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
methods in the app. Projects which load their publishable key only through an
Xcode Run-scheme environment variable are built but must be launched from Xcode,
because `simctl launch` does not reproduce arbitrary scheme environment state.

### Keyless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand DownExpand Up@@ -75,6 +130,10 @@ re-run to verify the results.
interactive (`clerk auth login` opens a browser, `clerk link` shows a
picker). It is ignored in `--json` mode and agent mode.

`--fix` cannot be combined with Xcode execution flags. This prevents the
post-fix verification pass from resolving, building, or launching a project a
second time.

Fixable issues:

| Issue | Fix action |
Expand DownExpand Up@@ -117,8 +176,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 keyless 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 keyless application (best-effort, via its secret key) |
46 changes: 40 additions & 6 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { 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 DownExpand Up@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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("Authenticated with a Platform API key");
}

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand DownExpand Up@@ -158,8 +171,14 @@ 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()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this keyless application")
Expand All@@ -173,6 +192,23 @@ 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. When a linked
// application is reachable, that authenticated request is stronger
// evidence for the CLI than a userinfo rejection. `getApplication()` is
// cached by the real context, so the later application check reuses this
// request. A genuinely expired hosted session still falls through: PLAPI
// rejects the same token (or token refresh) too.
try {
const app = await ctx.getApplication();
if (app) {
return check.pass("Account access verified through the Clerk API");
}
} catch {
// Preserve the existing expired-session diagnosis below. The
// application check reports its own endpoint-specific failure later.
}

// 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 DownExpand Up@@ -229,7 +265,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 keyless application ${label}, which covers fewer settings`,
{
Expand All@@ -251,8 +287,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 DownExpand Up@@ -286,8 +321,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
16 changes: 16 additions & 0 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,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 +145,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
23 changes: 19 additions & 4 deletions packages/cli-core/src/commands/doctor/context.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { getToken, getValidToken } from "../../lib/credential-store.ts";
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
import { resolveProfile } from "../../lib/config.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
Expand All@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type

export function createDoctorContext(): DoctorContext {
let tokenPromise: Promise<string | null> | undefined;
let accountCredentialsPromise: Promise<boolean> | undefined;
let validTokenPromise: Promise<string | null> | undefined;
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
let appPromise: Promise<Application | null> | undefined;
Expand All@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
let keylessKeyError: CliError | undefined;

const ctx: DoctorContext = {
hasPlatformAPIKey() {
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
},

hasAccountCredentials() {
if (!accountCredentialsPromise) {
accountCredentialsPromise = hasAccountCredentials();
}
return accountCredentialsPromise;
},

getToken() {
if (!tokenPromise) {
tokenPromise = getToken();
Expand All@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
getApplication() {
if (!appPromise) {
appPromise = (async () => {
const token = await ctx.getToken();
if (!token) return null;
if (!(await ctx.hasAccountCredentials())) return null;
const resolved = await ctx.getProfile();
if (!resolved) return null;
return fetchApplication(resolved.profile.appId);
// Doctor only needs application and instance identity. Keeping
// secret keys out of this long-lived, shared diagnostic context
// prevents unrelated checks from retaining credentials they never
// use (including the iOS checks below).
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
})();
}
return appPromise;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.
96 changes: 80 additions & 16 deletions packages/cli-core/src/commands/doctor/README.md
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
# Doctor Command

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).
the status of each check. The command is read-only by default. `--fix` and the
explicit Xcode execution flags are the only modes which can change local state.

## Usage

Expand All@@ -12,31 +12,86 @@ 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
clerk doctor --target MyApp --build
clerk doctor --target MyApp --resolve-packages --build
clerk doctor --target MyApp --simulator --device <udid>
```

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| Flag | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
| `--scheme` | Select an Xcode scheme for execution checks |
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
| `--simulator` | Build, install, and launch the selected app in Simulator |
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |

## 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 token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
| 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 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. The linked development publishable key is used only to compare redacted
Frontend API host metadata; 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.

Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
flags are deliberately opt-in because Xcode can run package manifests, plugins,
macros, and project build scripts:

- `--resolve-packages` is the only mode allowed to create or update the
selected container's shared `Package.resolved`.
- `--build` requires a locked remote package graph, verifies the chosen scheme
belongs to the selected target, disables signing, filters Clerk credentials
from the child environment, and builds with temporary DerivedData and package
checkouts.
- `--simulator` additionally installs and launches that isolated build. It
never guesses among multiple devices; agent mode requires `--device`.

A successful build or launch is not a successful authentication test. Doctor
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
methods in the app. Projects which load their publishable key only through an
Xcode Run-scheme environment variable are built but must be launched from Xcode,
because `simctl launch` does not reproduce arbitrary scheme environment state.

### Keyless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand DownExpand Up@@ -75,6 +130,10 @@ re-run to verify the results.
interactive (`clerk auth login` opens a browser, `clerk link` shows a
picker). It is ignored in `--json` mode and agent mode.

`--fix` cannot be combined with Xcode execution flags. This prevents the
post-fix verification pass from resolving, building, or launching a project a
second time.

Fixable issues:

| Issue | Fix action |
Expand DownExpand Up@@ -117,8 +176,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 keyless 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 keyless application (best-effort, via its secret key) |
46 changes: 40 additions & 6 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { 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 DownExpand Up@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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("Authenticated with a Platform API key");
}

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand DownExpand Up@@ -158,8 +171,14 @@ 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()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this keyless application")
Expand All@@ -173,6 +192,23 @@ 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. When a linked
// application is reachable, that authenticated request is stronger
// evidence for the CLI than a userinfo rejection. `getApplication()` is
// cached by the real context, so the later application check reuses this
// request. A genuinely expired hosted session still falls through: PLAPI
// rejects the same token (or token refresh) too.
try {
const app = await ctx.getApplication();
if (app) {
return check.pass("Account access verified through the Clerk API");
}
} catch {
// Preserve the existing expired-session diagnosis below. The
// application check reports its own endpoint-specific failure later.
}

// 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 DownExpand Up@@ -229,7 +265,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 keyless application ${label}, which covers fewer settings`,
{
Expand All@@ -251,8 +287,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 DownExpand Up@@ -286,8 +321,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
16 changes: 16 additions & 0 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,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 +145,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
23 changes: 19 additions & 4 deletions packages/cli-core/src/commands/doctor/context.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { getToken, getValidToken } from "../../lib/credential-store.ts";
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
import { resolveProfile } from "../../lib/config.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
Expand All@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type

export function createDoctorContext(): DoctorContext {
let tokenPromise: Promise<string | null> | undefined;
let accountCredentialsPromise: Promise<boolean> | undefined;
let validTokenPromise: Promise<string | null> | undefined;
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
let appPromise: Promise<Application | null> | undefined;
Expand All@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
let keylessKeyError: CliError | undefined;

const ctx: DoctorContext = {
hasPlatformAPIKey() {
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
},

hasAccountCredentials() {
if (!accountCredentialsPromise) {
accountCredentialsPromise = hasAccountCredentials();
}
return accountCredentialsPromise;
},

getToken() {
if (!tokenPromise) {
tokenPromise = getToken();
Expand All@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
getApplication() {
if (!appPromise) {
appPromise = (async () => {
const token = await ctx.getToken();
if (!token) return null;
if (!(await ctx.hasAccountCredentials())) return null;
const resolved = await ctx.getProfile();
if (!resolved) return null;
return fetchApplication(resolved.profile.appId);
// Doctor only needs application and instance identity. Keeping
// secret keys out of this long-lived, shared diagnostic context
// prevents unrelated checks from retaining credentials they never
// use (including the iOS checks below).
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
})();
}
return appPromise;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } 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
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.
96 changes: 80 additions & 16 deletions packages/cli-core/src/commands/doctor/README.md
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
# Doctor Command

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).
the status of each check. The command is read-only by default. `--fix` and the
explicit Xcode execution flags are the only modes which can change local state.

## Usage

Expand All@@ -12,31 +12,86 @@ 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
clerk doctor --target MyApp --build
clerk doctor --target MyApp --resolve-packages --build
clerk doctor --target MyApp --simulator --device <udid>
```

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| Flag | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
| `--scheme` | Select an Xcode scheme for execution checks |
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
| `--simulator` | Build, install, and launch the selected app in Simulator |
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |

## 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 token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
| 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 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. The linked development publishable key is used only to compare redacted
Frontend API host metadata; 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.

Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
flags are deliberately opt-in because Xcode can run package manifests, plugins,
macros, and project build scripts:

- `--resolve-packages` is the only mode allowed to create or update the
selected container's shared `Package.resolved`.
- `--build` requires a locked remote package graph, verifies the chosen scheme
belongs to the selected target, disables signing, filters Clerk credentials
from the child environment, and builds with temporary DerivedData and package
checkouts.
- `--simulator` additionally installs and launches that isolated build. It
never guesses among multiple devices; agent mode requires `--device`.

A successful build or launch is not a successful authentication test. Doctor
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
methods in the app. Projects which load their publishable key only through an
Xcode Run-scheme environment variable are built but must be launched from Xcode,
because `simctl launch` does not reproduce arbitrary scheme environment state.

### Keyless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand DownExpand Up@@ -75,6 +130,10 @@ re-run to verify the results.
interactive (`clerk auth login` opens a browser, `clerk link` shows a
picker). It is ignored in `--json` mode and agent mode.

`--fix` cannot be combined with Xcode execution flags. This prevents the
post-fix verification pass from resolving, building, or launching a project a
second time.

Fixable issues:

| Issue | Fix action |
Expand DownExpand Up@@ -117,8 +176,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 keyless 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 keyless application (best-effort, via its secret key) |
46 changes: 40 additions & 6 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { 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 DownExpand Up@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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("Authenticated with a Platform API key");
}

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand DownExpand Up@@ -158,8 +171,14 @@ 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()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this keyless application")
Expand All@@ -173,6 +192,23 @@ 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. When a linked
// application is reachable, that authenticated request is stronger
// evidence for the CLI than a userinfo rejection. `getApplication()` is
// cached by the real context, so the later application check reuses this
// request. A genuinely expired hosted session still falls through: PLAPI
// rejects the same token (or token refresh) too.
try {
const app = await ctx.getApplication();
if (app) {
return check.pass("Account access verified through the Clerk API");
}
} catch {
// Preserve the existing expired-session diagnosis below. The
// application check reports its own endpoint-specific failure later.
}

// 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 DownExpand Up@@ -229,7 +265,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 keyless application ${label}, which covers fewer settings`,
{
Expand All@@ -251,8 +287,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 DownExpand Up@@ -286,8 +321,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
16 changes: 16 additions & 0 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,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 +145,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
23 changes: 19 additions & 4 deletions packages/cli-core/src/commands/doctor/context.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { getToken, getValidToken } from "../../lib/credential-store.ts";
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
import { resolveProfile } from "../../lib/config.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
Expand All@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type

export function createDoctorContext(): DoctorContext {
let tokenPromise: Promise<string | null> | undefined;
let accountCredentialsPromise: Promise<boolean> | undefined;
let validTokenPromise: Promise<string | null> | undefined;
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
let appPromise: Promise<Application | null> | undefined;
Expand All@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
let keylessKeyError: CliError | undefined;

const ctx: DoctorContext = {
hasPlatformAPIKey() {
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
},

hasAccountCredentials() {
if (!accountCredentialsPromise) {
accountCredentialsPromise = hasAccountCredentials();
}
return accountCredentialsPromise;
},

getToken() {
if (!tokenPromise) {
tokenPromise = getToken();
Expand All@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
getApplication() {
if (!appPromise) {
appPromise = (async () => {
const token = await ctx.getToken();
if (!token) return null;
if (!(await ctx.hasAccountCredentials())) return null;
const resolved = await ctx.getProfile();
if (!resolved) return null;
return fetchApplication(resolved.profile.appId);
// Doctor only needs application and instance identity. Keeping
// secret keys out of this long-lived, shared diagnostic context
// prevents unrelated checks from retaining credentials they never
// use (including the iOS checks below).
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
})();
}
return appPromise;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.
96 changes: 80 additions & 16 deletions packages/cli-core/src/commands/doctor/README.md
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
# Doctor Command

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).
the status of each check. The command is read-only by default. `--fix` and the
explicit Xcode execution flags are the only modes which can change local state.

## Usage

Expand All@@ -12,31 +12,86 @@ 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
clerk doctor --target MyApp --build
clerk doctor --target MyApp --resolve-packages --build
clerk doctor --target MyApp --simulator --device <udid>
```

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| Flag | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
| `--scheme` | Select an Xcode scheme for execution checks |
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
| `--simulator` | Build, install, and launch the selected app in Simulator |
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |

## 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 token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
| 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 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. The linked development publishable key is used only to compare redacted
Frontend API host metadata; 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.

Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
flags are deliberately opt-in because Xcode can run package manifests, plugins,
macros, and project build scripts:

- `--resolve-packages` is the only mode allowed to create or update the
selected container's shared `Package.resolved`.
- `--build` requires a locked remote package graph, verifies the chosen scheme
belongs to the selected target, disables signing, filters Clerk credentials
from the child environment, and builds with temporary DerivedData and package
checkouts.
- `--simulator` additionally installs and launches that isolated build. It
never guesses among multiple devices; agent mode requires `--device`.

A successful build or launch is not a successful authentication test. Doctor
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
methods in the app. Projects which load their publishable key only through an
Xcode Run-scheme environment variable are built but must be launched from Xcode,
because `simctl launch` does not reproduce arbitrary scheme environment state.

### Keyless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand DownExpand Up@@ -75,6 +130,10 @@ re-run to verify the results.
interactive (`clerk auth login` opens a browser, `clerk link` shows a
picker). It is ignored in `--json` mode and agent mode.

`--fix` cannot be combined with Xcode execution flags. This prevents the
post-fix verification pass from resolving, building, or launching a project a
second time.

Fixable issues:

| Issue | Fix action |
Expand DownExpand Up@@ -117,8 +176,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 keyless 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 keyless application (best-effort, via its secret key) |
46 changes: 40 additions & 6 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { 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 DownExpand Up@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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("Authenticated with a Platform API key");
}

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand DownExpand Up@@ -158,8 +171,14 @@ 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()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this keyless application")
Expand All@@ -173,6 +192,23 @@ 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. When a linked
// application is reachable, that authenticated request is stronger
// evidence for the CLI than a userinfo rejection. `getApplication()` is
// cached by the real context, so the later application check reuses this
// request. A genuinely expired hosted session still falls through: PLAPI
// rejects the same token (or token refresh) too.
try {
const app = await ctx.getApplication();
if (app) {
return check.pass("Account access verified through the Clerk API");
}
} catch {
// Preserve the existing expired-session diagnosis below. The
// application check reports its own endpoint-specific failure later.
}

// 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 DownExpand Up@@ -229,7 +265,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 keyless application ${label}, which covers fewer settings`,
{
Expand All@@ -251,8 +287,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 DownExpand Up@@ -286,8 +321,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
16 changes: 16 additions & 0 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,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 +145,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
23 changes: 19 additions & 4 deletions packages/cli-core/src/commands/doctor/context.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { getToken, getValidToken } from "../../lib/credential-store.ts";
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
import { resolveProfile } from "../../lib/config.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
Expand All@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type

export function createDoctorContext(): DoctorContext {
let tokenPromise: Promise<string | null> | undefined;
let accountCredentialsPromise: Promise<boolean> | undefined;
let validTokenPromise: Promise<string | null> | undefined;
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
let appPromise: Promise<Application | null> | undefined;
Expand All@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
let keylessKeyError: CliError | undefined;

const ctx: DoctorContext = {
hasPlatformAPIKey() {
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
},

hasAccountCredentials() {
if (!accountCredentialsPromise) {
accountCredentialsPromise = hasAccountCredentials();
}
return accountCredentialsPromise;
},

getToken() {
if (!tokenPromise) {
tokenPromise = getToken();
Expand All@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
getApplication() {
if (!appPromise) {
appPromise = (async () => {
const token = await ctx.getToken();
if (!token) return null;
if (!(await ctx.hasAccountCredentials())) return null;
const resolved = await ctx.getProfile();
if (!resolved) return null;
return fetchApplication(resolved.profile.appId);
// Doctor only needs application and instance identity. Keeping
// secret keys out of this long-lived, shared diagnostic context
// prevents unrelated checks from retaining credentials they never
// use (including the iOS checks below).
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
})();
}
return appPromise;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.
96 changes: 80 additions & 16 deletions packages/cli-core/src/commands/doctor/README.md
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
# Doctor Command

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).
the status of each check. The command is read-only by default. `--fix` and the
explicit Xcode execution flags are the only modes which can change local state.

## Usage

Expand All@@ -12,31 +12,86 @@ 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
clerk doctor --target MyApp --build
clerk doctor --target MyApp --resolve-packages --build
clerk doctor --target MyApp --simulator --device <udid>
```

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| Flag | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
| `--scheme` | Select an Xcode scheme for execution checks |
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
| `--simulator` | Build, install, and launch the selected app in Simulator |
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |

## 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 token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
| 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 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. The linked development publishable key is used only to compare redacted
Frontend API host metadata; 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.

Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
flags are deliberately opt-in because Xcode can run package manifests, plugins,
macros, and project build scripts:

- `--resolve-packages` is the only mode allowed to create or update the
selected container's shared `Package.resolved`.
- `--build` requires a locked remote package graph, verifies the chosen scheme
belongs to the selected target, disables signing, filters Clerk credentials
from the child environment, and builds with temporary DerivedData and package
checkouts.
- `--simulator` additionally installs and launches that isolated build. It
never guesses among multiple devices; agent mode requires `--device`.

A successful build or launch is not a successful authentication test. Doctor
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
methods in the app. Projects which load their publishable key only through an
Xcode Run-scheme environment variable are built but must be launched from Xcode,
because `simctl launch` does not reproduce arbitrary scheme environment state.

### Keyless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand DownExpand Up@@ -75,6 +130,10 @@ re-run to verify the results.
interactive (`clerk auth login` opens a browser, `clerk link` shows a
picker). It is ignored in `--json` mode and agent mode.

`--fix` cannot be combined with Xcode execution flags. This prevents the
post-fix verification pass from resolving, building, or launching a project a
second time.

Fixable issues:

| Issue | Fix action |
Expand DownExpand Up@@ -117,8 +176,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 keyless 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 keyless application (best-effort, via its secret key) |
46 changes: 40 additions & 6 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { 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 DownExpand Up@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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("Authenticated with a Platform API key");
}

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand DownExpand Up@@ -158,8 +171,14 @@ 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()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this keyless application")
Expand All@@ -173,6 +192,23 @@ 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. When a linked
// application is reachable, that authenticated request is stronger
// evidence for the CLI than a userinfo rejection. `getApplication()` is
// cached by the real context, so the later application check reuses this
// request. A genuinely expired hosted session still falls through: PLAPI
// rejects the same token (or token refresh) too.
try {
const app = await ctx.getApplication();
if (app) {
return check.pass("Account access verified through the Clerk API");
}
} catch {
// Preserve the existing expired-session diagnosis below. The
// application check reports its own endpoint-specific failure later.
}

// 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 DownExpand Up@@ -229,7 +265,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 keyless application ${label}, which covers fewer settings`,
{
Expand All@@ -251,8 +287,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 DownExpand Up@@ -286,8 +321,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
16 changes: 16 additions & 0 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,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 +145,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
23 changes: 19 additions & 4 deletions packages/cli-core/src/commands/doctor/context.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { getToken, getValidToken } from "../../lib/credential-store.ts";
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
import { resolveProfile } from "../../lib/config.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
Expand All@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type

export function createDoctorContext(): DoctorContext {
let tokenPromise: Promise<string | null> | undefined;
let accountCredentialsPromise: Promise<boolean> | undefined;
let validTokenPromise: Promise<string | null> | undefined;
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
let appPromise: Promise<Application | null> | undefined;
Expand All@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
let keylessKeyError: CliError | undefined;

const ctx: DoctorContext = {
hasPlatformAPIKey() {
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
},

hasAccountCredentials() {
if (!accountCredentialsPromise) {
accountCredentialsPromise = hasAccountCredentials();
}
return accountCredentialsPromise;
},

getToken() {
if (!tokenPromise) {
tokenPromise = getToken();
Expand All@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
getApplication() {
if (!appPromise) {
appPromise = (async () => {
const token = await ctx.getToken();
if (!token) return null;
if (!(await ctx.hasAccountCredentials())) return null;
const resolved = await ctx.getProfile();
if (!resolved) return null;
return fetchApplication(resolved.profile.appId);
// Doctor only needs application and instance identity. Keeping
// secret keys out of this long-lived, shared diagnostic context
// prevents unrelated checks from retaining credentials they never
// use (including the iOS checks below).
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
})();
}
return appPromise;
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.
96 changes: 80 additions & 16 deletions packages/cli-core/src/commands/doctor/README.md
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
# Doctor Command

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).
the status of each check. The command is read-only by default. `--fix` and the
explicit Xcode execution flags are the only modes which can change local state.

## Usage

Expand All@@ -12,31 +12,86 @@ 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
clerk doctor --target MyApp --build
clerk doctor --target MyApp --resolve-packages --build
clerk doctor --target MyApp --simulator --device <udid>
```

## Options

| Flag | Description |
| ------------- | ----------------------------------------------------- |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| Flag | Description |
| -------------------- | ------------------------------------------------------------------------------ |
| `--verbose` | Show detailed diagnostic info for each check |
| `--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 |
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
| `--scheme` | Select an Xcode scheme for execution checks |
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
| `--simulator` | Build, install, and launch the selected app in Simulator |
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |

## 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 token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
| 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 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. The linked development publishable key is used only to compare redacted
Frontend API host metadata; 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.

Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
flags are deliberately opt-in because Xcode can run package manifests, plugins,
macros, and project build scripts:

- `--resolve-packages` is the only mode allowed to create or update the
selected container's shared `Package.resolved`.
- `--build` requires a locked remote package graph, verifies the chosen scheme
belongs to the selected target, disables signing, filters Clerk credentials
from the child environment, and builds with temporary DerivedData and package
checkouts.
- `--simulator` additionally installs and launches that isolated build. It
never guesses among multiple devices; agent mode requires `--device`.

A successful build or launch is not a successful authentication test. Doctor
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
methods in the app. Projects which load their publishable key only through an
Xcode Run-scheme environment variable are built but must be launched from Xcode,
because `simctl launch` does not reproduce arbitrary scheme environment state.

### Keyless applications

The Authentication token, Token validity, and Project linkage checks resolve
Expand DownExpand Up@@ -75,6 +130,10 @@ re-run to verify the results.
interactive (`clerk auth login` opens a browser, `clerk link` shows a
picker). It is ignored in `--json` mode and agent mode.

`--fix` cannot be combined with Xcode execution flags. This prevents the
post-fix verification pass from resolving, building, or launching a project a
second time.

Fixable issues:

| Issue | Fix action |
Expand DownExpand Up@@ -117,8 +176,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 keyless 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 keyless application (best-effort, via its secret key) |
46 changes: 40 additions & 6 deletions packages/cli-core/src/commands/doctor/checks.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
import { 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 DownExpand Up@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
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("Authenticated with a Platform API key");
}

if (token) {
if (keyError) {
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
Expand DownExpand Up@@ -158,8 +171,14 @@ 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()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const storedToken = await ctx.getToken();
if (!storedToken) {
if (await ctx.hasAccountCredentials()) {
return check.pass("Platform API key configured; access is verified by API checks");
}
const keyless = await ctx.getKeylessTarget();
return keyless
? check.pass("No account session — not required for this keyless application")
Expand All@@ -173,6 +192,23 @@ 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. When a linked
// application is reachable, that authenticated request is stronger
// evidence for the CLI than a userinfo rejection. `getApplication()` is
// cached by the real context, so the later application check reuses this
// request. A genuinely expired hosted session still falls through: PLAPI
// rejects the same token (or token refresh) too.
try {
const app = await ctx.getApplication();
if (app) {
return check.pass("Account access verified through the Clerk API");
}
} catch {
// Preserve the existing expired-session diagnosis below. The
// application check reports its own endpoint-specific failure later.
}

// 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 DownExpand Up@@ -229,7 +265,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 keyless application ${label}, which covers fewer settings`,
{
Expand All@@ -251,8 +287,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 DownExpand Up@@ -286,8 +321,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
16 changes: 16 additions & 0 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -135,6 +135,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 +145,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
23 changes: 19 additions & 4 deletions packages/cli-core/src/commands/doctor/context.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
import { getToken, getValidToken } from "../../lib/credential-store.ts";
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
import { resolveProfile } from "../../lib/config.ts";
import { fetchApplication, type Application } from "../../lib/plapi.ts";
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
Expand All@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type

export function createDoctorContext(): DoctorContext {
let tokenPromise: Promise<string | null> | undefined;
let accountCredentialsPromise: Promise<boolean> | undefined;
let validTokenPromise: Promise<string | null> | undefined;
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
let appPromise: Promise<Application | null> | undefined;
Expand All@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
let keylessKeyError: CliError | undefined;

const ctx: DoctorContext = {
hasPlatformAPIKey() {
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
},

hasAccountCredentials() {
if (!accountCredentialsPromise) {
accountCredentialsPromise = hasAccountCredentials();
}
return accountCredentialsPromise;
},

getToken() {
if (!tokenPromise) {
tokenPromise = getToken();
Expand All@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
getApplication() {
if (!appPromise) {
appPromise = (async () => {
const token = await ctx.getToken();
if (!token) return null;
if (!(await ctx.hasAccountCredentials())) return null;
const resolved = await ctx.getProfile();
if (!resolved) return null;
return fetchApplication(resolved.profile.appId);
// Doctor only needs application and instance identity. Keeping
// secret keys out of this long-lived, shared diagnostic context
// prevents unrelated checks from retaining credentials they never
// use (including the iOS checks below).
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
})();
}
return appPromise;
Expand Down
Loading