Skip to content

Commit 1ef48b4

Browse files
committed
feat(doctor): add iOS diagnostics
1 parent ae6581e commit 1ef48b4

26 files changed

Lines changed: 4296 additions & 68 deletions

.changeset/ios-aware-doctor.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": minor
3+
---
4+
5+
Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`.

packages/cli-core/src/commands/doctor/README.md

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# Doctor Command
22

33
Runs a series of diagnostic checks on your Clerk CLI setup and reports
4-
the status of each check. The command is read-only and never modifies
5-
any state (unless `--fix` is used).
4+
the status of each check. The command is read-only by default. `--fix` and the
5+
explicit Xcode execution flags are the only modes which can change local state.
66

77
## Usage
88

@@ -12,31 +12,86 @@ clerk doctor --verbose # Show detailed output
1212
clerk doctor --json # Output results as JSON
1313
clerk doctor --spotlight # Only show warnings and failures
1414
clerk doctor --fix # Offer to auto-fix issues
15+
clerk doctor --target MyApp
16+
clerk doctor --target MyApp --build
17+
clerk doctor --target MyApp --resolve-packages --build
18+
clerk doctor --target MyApp --simulator --device <udid>
1519
```
1620

1721
## Options
1822

19-
| Flag | Description |
20-
| ------------- | ----------------------------------------------------- |
21-
| `--verbose` | Show detailed diagnostic info for each check |
22-
| `--json` | Output results as machine-readable JSON |
23-
| `--spotlight` | Only show warnings and failures (hide passing checks) |
24-
| `--fix` | Offer to auto-fix issues with known remedies |
23+
| Flag | Description |
24+
| -------------------- | ------------------------------------------------------------------------------ |
25+
| `--verbose` | Show detailed diagnostic info for each check |
26+
| `--json` | Output results as machine-readable JSON |
27+
| `--spotlight` | Only show warnings and failures (hide passing checks) |
28+
| `--fix` | Offer to auto-fix issues with known remedies |
29+
| `--target` | Select an iOS application target by name or object ID |
30+
| `--xcode-container` | Select an inspected `.xcodeproj` or `.xcworkspace` for execution checks |
31+
| `--scheme` | Select an Xcode scheme for execution checks |
32+
| `--resolve-packages` | Explicitly allow Xcode to resolve Swift packages and update `Package.resolved` |
33+
| `--build` | Build the selected iOS app for Simulator in an isolated directory |
34+
| `--simulator` | Build, install, and launch the selected app in Simulator |
35+
| `--device` | Simulator UDID or exact device name (requires `--simulator`) |
2536

2637
## Checks
2738

2839
| Check | Category | What it verifies |
2940
| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
30-
| Authentication token | Authentication | Credential store has a stored token |
31-
| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) |
41+
| Account credentials | Authentication | Credential store has a session or a Platform API key is configured |
42+
| Token validity | Authentication | OAuth token is still valid (calls `/oauth/userinfo`); Platform API-key access is verified by endpoint checks |
3243
| Project linkage | Project | Current directory is linked to a Clerk app |
3344
| Linked application | Project | Linked application ID is accessible via the API |
3445
| Instances | Project | Configured dev/prod instance IDs match the application's instances |
35-
| Environment variables | Environment | .env.local or .env has Clerk keys |
46+
| Environment variables | Environment | Non-iOS projects have Clerk keys in `.env.local` or `.env` |
3647
| CLI configuration | Configuration | CLI config file exists and parses |
3748
| Shell completion | Configuration | Shell autocompletion is installed for the detected shell |
3849
| 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) |
3950

51+
### iOS projects
52+
53+
When the current directory contains an Xcode project or `--target` is provided,
54+
doctor replaces the web `.env` check with the same semantic Xcode, Swift, and
55+
entitlements inspection used by `clerk init`. It reports separate results for:
56+
57+
- application-target selection;
58+
- ClerkKit and ClerkKitUI product linkage;
59+
- `Clerk.configure` and the selected target's effective development key;
60+
- SwiftUI environment injection and authentication-flow evidence;
61+
- AuthView's enabled methods and required local Apple capability;
62+
- Associated Domains and the optional Sign in with Apple entitlement;
63+
- Native API state and the exact Bundle ID registration on the linked
64+
development instance; and
65+
- the Clerk Apple connection when the selected target already declares the
66+
native Apple entitlement.
67+
68+
iOS diagnostics never require a secret key in the Xcode project or an env
69+
file. The linked development publishable key is used only to compare redacted
70+
Frontend API host metadata; keys, provider credentials, and raw remote config
71+
are not included in human or JSON output. AuthView, Native Application, and
72+
Apple remote checks are GET-only. Their remedies point back to `clerk init`;
73+
`doctor --fix` never enables an auth strategy or changes Native Application
74+
state.
75+
76+
Plain `clerk doctor` remains read-only and does not invoke Xcode. The execution
77+
flags are deliberately opt-in because Xcode can run package manifests, plugins,
78+
macros, and project build scripts:
79+
80+
- `--resolve-packages` is the only mode allowed to create or update the
81+
selected container's shared `Package.resolved`.
82+
- `--build` requires a locked remote package graph, verifies the chosen scheme
83+
belongs to the selected target, disables signing, filters Clerk credentials
84+
from the child environment, and builds with temporary DerivedData and package
85+
checkouts.
86+
- `--simulator` additionally installs and launches that isolated build. It
87+
never guesses among multiple devices; agent mode requires `--device`.
88+
89+
A successful build or launch is not a successful authentication test. Doctor
90+
still asks the developer to verify sign-in, sign-out, relaunch, and any redirect
91+
methods in the app. Projects which load their publishable key only through an
92+
Xcode Run-scheme environment variable are built but must be launched from Xcode,
93+
because `simctl launch` does not reproduce arbitrary scheme environment state.
94+
4095
### Keyless applications
4196

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

133+
`--fix` cannot be combined with Xcode execution flags. This prevents the
134+
post-fix verification pass from resolving, building, or launching a project a
135+
second time.
136+
78137
Fixable issues:
79138

80139
| Issue | Fix action |
@@ -117,8 +176,13 @@ Exit code 1 signals one or more checks failed.
117176

118177
## API Endpoints
119178

120-
| Method | Endpoint | Description |
121-
| ------ | ----------------------------------- | --------------------------------------------------------------- |
122-
| `GET` | `/oauth/userinfo` | Validates the stored auth token |
123-
| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist |
124-
| `GET` | `/v1/instance` | Names the keyless application (best-effort, via its secret key) |
179+
| Method | Endpoint | Description |
180+
| ------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
181+
| `GET` | `/oauth/userinfo` | Validates the stored auth token |
182+
| `GET` | `/v1/platform/applications/{appId}` | Verifies the linked app and its instances exist |
183+
| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_settings` | Verifies Native API state for iOS projects |
184+
| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/native_applications/ios` | Verifies the exact iOS Bundle ID registration |
185+
| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/config` | Audits the Apple connection when native Apple is relevant |
186+
| `GET` | `/v1/platform/applications/{appId}/instances/{instanceId}/config/schema` | Determines whether an unhealthy Apple connection can be safely reconciled by init |
187+
| `GET` | `https://{fapiHost}/v1/environment` | Verifies whether AuthView currently offers native Apple sign-in |
188+
| `GET` | `/v1/instance` | Names the keyless application (best-effort, via its secret key) |

packages/cli-core/src/commands/doctor/checks.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { fetchUserInfo } from "../../lib/token-exchange.ts";
55
import { errorMessage, isAuthError, PlapiError } from "../../lib/errors.ts";
66
import { detectPublishableKeyName, detectSecretKeyName } from "../../lib/framework.ts";
77
import { parseEnvFile } from "../../lib/dotenv.ts";
8-
import { hasAccountCredentials } from "../../lib/credential-store.ts";
98
import type { KeylessTarget } from "../../lib/keyless-target.ts";
109
import { CURRENT_VERSION, IS_DEV_BUILD } from "../../lib/version.ts";
1110
import {
@@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise<CheckResult> {
102101
const keyless = await ctx.getKeylessTarget();
103102
const keyError = await ctx.getKeylessKeyError();
104103

104+
if (ctx.hasPlatformAPIKey()) {
105+
if (keyError) {
106+
return check.warn(
107+
`Platform API key configured, but the local secret key is unusable: ${keyError.message}`,
108+
{
109+
remedy:
110+
"Fix or remove the malformed secret key — some commands prefer it over account credentials.",
111+
fixable: false,
112+
},
113+
);
114+
}
115+
return check.pass("Authenticated with a Platform API key");
116+
}
117+
105118
if (token) {
106119
if (keyError) {
107120
return check.warn(`Logged in, but the local secret key is unusable: ${keyError.message}`, {
@@ -158,8 +171,14 @@ export async function checkHostExecution(): Promise<CheckResult> {
158171

159172
export async function checkTokenValid(ctx: DoctorContext): Promise<CheckResult> {
160173
const check = defineCheck("Authentication valid", ctx.fixes.login);
174+
if (ctx.hasPlatformAPIKey()) {
175+
return check.pass("Platform API key configured; access is verified by API checks");
176+
}
161177
const storedToken = await ctx.getToken();
162178
if (!storedToken) {
179+
if (await ctx.hasAccountCredentials()) {
180+
return check.pass("Platform API key configured; access is verified by API checks");
181+
}
163182
const keyless = await ctx.getKeylessTarget();
164183
return keyless
165184
? check.pass("No account session — not required for this keyless application")
@@ -173,6 +192,23 @@ export async function checkTokenValid(ctx: DoctorContext): Promise<CheckResult>
173192
return check.pass(`Authenticated as ${userInfo.email}`);
174193
} catch (error) {
175194
if (isAuthError(error)) {
195+
// The OAuth userinfo surface is not available in every environment that
196+
// can accept the same account credential through PLAPI. When a linked
197+
// application is reachable, that authenticated request is stronger
198+
// evidence for the CLI than a userinfo rejection. `getApplication()` is
199+
// cached by the real context, so the later application check reuses this
200+
// request. A genuinely expired hosted session still falls through: PLAPI
201+
// rejects the same token (or token refresh) too.
202+
try {
203+
const app = await ctx.getApplication();
204+
if (app) {
205+
return check.pass("Account access verified through the Clerk API");
206+
}
207+
} catch {
208+
// Preserve the existing expired-session diagnosis below. The
209+
// application check reports its own endpoint-specific failure later.
210+
}
211+
176212
// Same fallback whoami uses: an expired session doesn't strand a keyless
177213
// project, so don't tell the user their setup is broken.
178214
const keyless = await ctx.getKeylessTarget();
@@ -229,7 +265,7 @@ export async function checkProjectLinked(ctx: DoctorContext): Promise<CheckResul
229265

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

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

287322
export async function checkInstances(ctx: DoctorContext): Promise<CheckResult> {
288323
const check = defineCheck("Instance IDs", ctx.fixes.link);
289-
const token = await ctx.getToken();
290-
if (!token) {
324+
if (!(await ctx.hasAccountCredentials())) {
291325
// A linked profile's dev/prod instance IDs are an account-only concept —
292326
// the secret key on disk already addresses its one instance directly.
293327
const keyless = await ctx.getKeylessTarget();

packages/cli-core/src/commands/doctor/context.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ describe("createDoctorContext", () => {
135135
});
136136

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

140141
const ctx = createDoctorContext();
@@ -144,6 +145,21 @@ describe("createDoctorContext", () => {
144145
expect(mockFetch).not.toHaveBeenCalled();
145146
});
146147

148+
test("fetches the public application shape with a Platform API key", async () => {
149+
mockGetToken.mockResolvedValue(null);
150+
mockResolveProfile.mockResolvedValue({
151+
path: "github.com/org/repo",
152+
profile: { workspaceId: "org_1", appId: "app_1", instances: { development: "ins_dev" } },
153+
resolvedVia: "remote" as const,
154+
});
155+
mockAppResponse = { application_id: "app_1", name: "My App", instances: [] };
156+
157+
const ctx = createDoctorContext();
158+
expect(await ctx.getApplication()).toEqual(mockAppResponse);
159+
expect(mockFetch).toHaveBeenCalledTimes(1);
160+
expect(String(mockFetch.mock.calls[0]?.[0])).not.toContain("include_secret_keys");
161+
});
162+
147163
test("returns null when no profile", async () => {
148164
mockGetToken.mockResolvedValue("test_token");
149165
mockResolveProfile.mockResolvedValue(undefined);

packages/cli-core/src/commands/doctor/context.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getToken, getValidToken } from "../../lib/credential-store.ts";
1+
import { getToken, getValidToken, hasAccountCredentials } from "../../lib/credential-store.ts";
22
import { resolveProfile } from "../../lib/config.ts";
33
import { fetchApplication, type Application } from "../../lib/plapi.ts";
44
import { resolveKeylessTarget, type KeylessTarget } from "../../lib/keyless-target.ts";
@@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type
1717

1818
export function createDoctorContext(): DoctorContext {
1919
let tokenPromise: Promise<string | null> | undefined;
20+
let accountCredentialsPromise: Promise<boolean> | undefined;
2021
let validTokenPromise: Promise<string | null> | undefined;
2122
let profilePromise: Promise<ResolvedProfile | undefined> | undefined;
2223
let appPromise: Promise<Application | null> | undefined;
@@ -26,6 +27,17 @@ export function createDoctorContext(): DoctorContext {
2627
let keylessKeyError: CliError | undefined;
2728

2829
const ctx: DoctorContext = {
30+
hasPlatformAPIKey() {
31+
return Boolean(process.env.CLERK_PLATFORM_API_KEY);
32+
},
33+
34+
hasAccountCredentials() {
35+
if (!accountCredentialsPromise) {
36+
accountCredentialsPromise = hasAccountCredentials();
37+
}
38+
return accountCredentialsPromise;
39+
},
40+
2941
getToken() {
3042
if (!tokenPromise) {
3143
tokenPromise = getToken();
@@ -50,11 +62,14 @@ export function createDoctorContext(): DoctorContext {
5062
getApplication() {
5163
if (!appPromise) {
5264
appPromise = (async () => {
53-
const token = await ctx.getToken();
54-
if (!token) return null;
65+
if (!(await ctx.hasAccountCredentials())) return null;
5566
const resolved = await ctx.getProfile();
5667
if (!resolved) return null;
57-
return fetchApplication(resolved.profile.appId);
68+
// Doctor only needs application and instance identity. Keeping
69+
// secret keys out of this long-lived, shared diagnostic context
70+
// prevents unrelated checks from retaining credentials they never
71+
// use (including the iOS checks below).
72+
return fetchApplication(resolved.profile.appId, { includeSecretKeys: false });
5873
})();
5974
}
6075
return appPromise;

0 commit comments

Comments
 (0)