From 8ef7aafc284ac3ea1cfa7ecd269d339e8328ed20 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 20 Aug 2026 21:14:47 -0400 Subject: [PATCH] feat(doctor): add iOS diagnostics --- .changeset/ios-aware-doctor.md | 5 + .../cli-core/src/commands/doctor/README.md | 96 +- .../cli-core/src/commands/doctor/checks.ts | 46 +- .../src/commands/doctor/context.test.ts | 16 + .../cli-core/src/commands/doctor/context.ts | 23 +- .../src/commands/doctor/doctor.test.ts | 79 +- .../src/commands/doctor/index.test.ts | 30 + .../cli-core/src/commands/doctor/index.ts | 118 +- .../src/commands/doctor/ios-xcode.test.ts | 658 +++++++ .../cli-core/src/commands/doctor/ios-xcode.ts | 1588 +++++++++++++++++ .../cli-core/src/commands/doctor/ios.test.ts | 559 ++++++ packages/cli-core/src/commands/doctor/ios.ts | 542 ++++++ .../cli-core/src/commands/doctor/types.ts | 18 + .../commands/init/ios/build-settings.test.ts | 1 + .../src/commands/init/ios/inspect.test.ts | 1 + .../cli-core/src/commands/init/ios/inspect.ts | 1 + .../commands/init/ios/native-apple.test.ts | 140 ++ .../src/commands/init/ios/native-apple.ts | 184 +- .../commands/init/ios/native-remote.test.ts | 62 + .../src/commands/init/ios/native-remote.ts | 70 +- .../src/commands/init/ios/products.test.ts | 1 + .../src/commands/init/ios/swift.test.ts | 3 + .../cli-core/src/commands/init/ios/swift.ts | 11 +- .../cli-core/src/commands/init/ios/types.ts | 3 + .../cli-core/src/lib/credential-store.test.ts | 82 + packages/cli-core/src/lib/credential-store.ts | 27 +- 26 files changed, 4296 insertions(+), 68 deletions(-) create mode 100644 .changeset/ios-aware-doctor.md create mode 100644 packages/cli-core/src/commands/doctor/index.test.ts create mode 100644 packages/cli-core/src/commands/doctor/ios-xcode.test.ts create mode 100644 packages/cli-core/src/commands/doctor/ios-xcode.ts create mode 100644 packages/cli-core/src/commands/doctor/ios.test.ts create mode 100644 packages/cli-core/src/commands/doctor/ios.ts diff --git a/.changeset/ios-aware-doctor.md b/.changeset/ios-aware-doctor.md new file mode 100644 index 000000000..1407dc615 --- /dev/null +++ b/.changeset/ios-aware-doctor.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add native iOS project diagnostics and opt-in Xcode build and Simulator checks to `clerk doctor`. diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 443043cc7..229b3ae7a 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -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 @@ -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 ``` ## 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 @@ -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 | @@ -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) | diff --git a/packages/cli-core/src/commands/doctor/checks.ts b/packages/cli-core/src/commands/doctor/checks.ts index a7cf51c62..383928f1b 100644 --- a/packages/cli-core/src/commands/doctor/checks.ts +++ b/packages/cli-core/src/commands/doctor/checks.ts @@ -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 { @@ -102,6 +101,20 @@ export async function checkLoggedIn(ctx: DoctorContext): Promise { 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}`, { @@ -158,8 +171,14 @@ export async function checkHostExecution(): Promise { export async function checkTokenValid(ctx: DoctorContext): Promise { 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") @@ -173,6 +192,23 @@ export async function checkTokenValid(ctx: DoctorContext): Promise 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(); @@ -229,7 +265,7 @@ export async function checkProjectLinked(ctx: DoctorContext): Promise { 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. @@ -286,8 +321,7 @@ export async function checkLinkedAppExists(ctx: DoctorContext): Promise { 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(); diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index 878aa731f..4faa6c32e 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -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(); @@ -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); diff --git a/packages/cli-core/src/commands/doctor/context.ts b/packages/cli-core/src/commands/doctor/context.ts index 0e82432cc..053b2e6ab 100644 --- a/packages/cli-core/src/commands/doctor/context.ts +++ b/packages/cli-core/src/commands/doctor/context.ts @@ -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"; @@ -17,6 +17,7 @@ import type { DoctorContext, KeylessInstanceInfo, ResolvedProfile } from "./type export function createDoctorContext(): DoctorContext { let tokenPromise: Promise | undefined; + let accountCredentialsPromise: Promise | undefined; let validTokenPromise: Promise | undefined; let profilePromise: Promise | undefined; let appPromise: Promise | undefined; @@ -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(); @@ -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; diff --git a/packages/cli-core/src/commands/doctor/doctor.test.ts b/packages/cli-core/src/commands/doctor/doctor.test.ts index 60c213fb9..291ed0356 100644 --- a/packages/cli-core/src/commands/doctor/doctor.test.ts +++ b/packages/cli-core/src/commands/doctor/doctor.test.ts @@ -109,9 +109,13 @@ function createMockContext( keylessInstance?: KeylessInstanceInfo | null; claimBreadcrumb?: boolean; keylessKeyError?: CliError; + accountCredentials?: boolean; + platformAPIKey?: boolean; } = {}, ): DoctorContext { return { + hasPlatformAPIKey: () => overrides.platformAPIKey ?? false, + hasAccountCredentials: async () => overrides.accountCredentials ?? overrides.token != null, getToken: async () => overrides.token ?? null, getValidToken: async () => { if (overrides.validToken instanceof Error) throw overrides.validToken; @@ -212,6 +216,20 @@ describe("checkLoggedIn", () => { expectCheck(result, { name: "Logged in", status: "pass", message: "Logged in" }); }); + test("pass when a Platform API key is configured without an OAuth token", async () => { + const ctx = createMockContext({ + token: null, + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkLoggedIn(ctx); + expectCheck(result, { + name: "Logged in", + status: "pass", + message: "Platform API key", + }); + }); + test("fail when no token", async () => { const ctx = createMockContext({ token: null }); const result = await checkLoggedIn(ctx); @@ -317,6 +335,35 @@ describe("checkHostExecution", () => { }); describe("checkTokenValid", () => { + test("passes Platform API-key auth to the endpoint checks", async () => { + const ctx = createMockContext({ + token: null, + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "Platform API key configured", + }); + }); + + test("does not validate stale OAuth when PLAPI will use a Platform API key", async () => { + const ctx = createMockContext({ + token: "stale-oauth-token", + validToken: new AuthError({ reason: "session_expired" }), + accountCredentials: true, + platformAPIKey: true, + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "Platform API key configured", + }); + }); + test("pass with valid token", async () => { mockUserInfo = { userId: "user_1", email: "dev@example.com" }; const ctx = createMockContext({ token: "test_token" }); @@ -341,6 +388,33 @@ describe("checkTokenValid", () => { }); }); + test("passes when userinfo rejects a credential that the linked application API accepts", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ token: "local_token", application: mockApplication }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "pass", + message: "verified through the Clerk API", + }); + }); + + test("still fails when both userinfo and the linked application API reject an expired token", async () => { + mockUserInfoError = new ApiError(401, "Unauthorized"); + const ctx = createMockContext({ + token: "expired_token", + applicationError: new ApiError(401, "Unauthorized"), + }); + const result = await checkTokenValid(ctx); + expectCheck(result, { + name: "Authentication valid", + status: "fail", + message: "expired or invalid", + remedy: "clerk auth login", + fix: true, + }); + }); + test("fail when refreshing the stored session requires re-authentication", async () => { const ctx = createMockContext({ token: "expired_token", @@ -440,9 +514,10 @@ describe("checkProjectLinked", () => { }); test("warn (not fail) when signed in but unlinked, falling back to a keyless application", async () => { - // beforeEach sets CLERK_PLATFORM_API_KEY, so hasAccountCredentials() is true here — - // the directory *could* reach the full account configuration by linking. + // The directory has account credentials, so it could reach the full + // account configuration by linking. const ctx = createMockContext({ + accountCredentials: true, keylessTarget: mockKeylessTarget, keylessInstance: mockKeylessInstance, }); diff --git a/packages/cli-core/src/commands/doctor/index.test.ts b/packages/cli-core/src/commands/doctor/index.test.ts new file mode 100644 index 000000000..927b9cfeb --- /dev/null +++ b/packages/cli-core/src/commands/doctor/index.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { checkEnvVars } from "./checks.ts"; +import { getDoctorChecks, validateDoctorOptions } from "./index.ts"; + +describe("getDoctorChecks", () => { + test("replaces the web environment check for iOS projects", () => { + expect(getDoctorChecks(true)).not.toContain(checkEnvVars); + expect(getDoctorChecks(false)).toContain(checkEnvVars); + }); +}); + +describe("validateDoctorOptions", () => { + test("rejects device selection without simulator launch", () => { + expect(() => validateDoctorOptions({ device: "SIM-UDID" })).toThrow( + "--device can only be used with --simulator", + ); + }); + + test("rejects scheme selection when no Xcode phase was requested", () => { + expect(() => validateDoctorOptions({ scheme: "MyApp" })).toThrow( + "--xcode-container and --scheme require", + ); + }); + + test("prevents auto-fix from executing Xcode twice", () => { + expect(() => validateDoctorOptions({ fix: true, build: true })).toThrow( + "--fix cannot be combined with Xcode execution flags", + ); + }); +}); diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index 813de75ac..2da35e342 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -1,8 +1,9 @@ import type { Program } from "../../cli-program.ts"; import { isAgent, isHuman } from "../../mode.ts"; import { bold, green, red } from "../../lib/color.ts"; +import { detectFramework } from "../../lib/framework.ts"; import { log } from "../../lib/log.ts"; -import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; import { intro, outro, bar, withSpinner } from "../../lib/spinner.ts"; import { createDoctorContext } from "./context.ts"; import { @@ -19,28 +20,37 @@ import { } from "./checks.ts"; import { checkMcp } from "./check-mcp.ts"; import { formatCheckResult, formatJson } from "./format.ts"; +import { runIOSDoctorChecks } from "./ios.ts"; +import { runIOSXcodeVerification } from "./ios-xcode.ts"; import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts"; -const BASE_CHECKS: CheckFn[] = [ +const ACCOUNT_CHECKS: CheckFn[] = [ checkCliVersion, checkLoggedIn, checkTokenValid, checkProjectLinked, checkLinkedAppExists, checkInstances, - checkEnvVars, - checkConfigFile, - checkShellCompletion, - checkMcp, ]; -function getChecks(): CheckFn[] { - return isAgent() ? [checkHostExecution, ...BASE_CHECKS] : BASE_CHECKS; +const CONFIGURATION_CHECKS: CheckFn[] = [checkConfigFile, checkShellCompletion, checkMcp]; + +export function getDoctorChecks(ios: boolean): CheckFn[] { + const checks = [...ACCOUNT_CHECKS, ...(ios ? [] : [checkEnvVars]), ...CONFIGURATION_CHECKS]; + return isAgent() ? [checkHostExecution, ...checks] : checks; } -async function runChecks(ctx: DoctorContext): Promise { - return Promise.all( - getChecks().map(async (check) => { +async function runChecks(ctx: DoctorContext, options: DoctorOptions): Promise { + const explicitlyRequestsIOS = + options.target != null || + options.xcodeContainer != null || + options.resolvePackages === true || + options.build === true || + options.simulator === true; + const framework = explicitlyRequestsIOS ? { dep: "ios" } : await detectFramework(process.cwd()); + const ios = framework?.dep === "ios"; + const common = await Promise.all( + getDoctorChecks(ios).map(async (check) => { try { return await check(ctx); } catch (error) { @@ -52,6 +62,48 @@ async function runChecks(ctx: DoctorContext): Promise { } }), ); + + const executesXcode = + options.resolvePackages || options.build || options.simulator || options.device != null; + if (!ios) { + return executesXcode + ? [ + ...common, + { + name: "Xcode verification", + status: "fail", + message: "Xcode verification requires an iOS project", + remedy: "Run from an Xcode project root, or remove the Xcode execution flags.", + }, + ] + : common; + } + try { + const iosChecks = await runIOSDoctorChecks(ctx, { + root: process.cwd(), + ...(options.target ? { target: options.target } : {}), + }); + const xcodeChecks = await runIOSXcodeVerification(iosChecks.inspection, { + ...(options.xcodeContainer ? { container: options.xcodeContainer } : {}), + ...(options.scheme ? { scheme: options.scheme } : {}), + ...(options.resolvePackages ? { resolvePackages: true } : {}), + ...(options.build ? { build: true } : {}), + ...(options.simulator ? { simulator: true } : {}), + ...(options.device ? { device: options.device } : {}), + }); + return [...common, ...iosChecks.results, ...xcodeChecks]; + } catch { + return [ + ...common, + { + name: "iOS inspection", + status: "fail", + message: "iOS project inspection failed", + detail: "The semantic Xcode inspection did not complete safely.", + remedy: "Run from the Xcode project root and pass `--target ` if needed.", + }, + ]; + } } function printResults(results: CheckResult[], options: DoctorOptions): void { @@ -63,13 +115,38 @@ function printResults(results: CheckResult[], options: DoctorOptions): void { log.blank(); } +export function validateDoctorOptions(options: DoctorOptions): void { + if (options.device && !options.simulator) { + throwUsageError("--device can only be used with --simulator."); + } + const executesXcode = + options.resolvePackages || options.build || options.simulator || options.device != null; + if ((options.xcodeContainer || options.scheme) && !executesXcode) { + throwUsageError( + "--xcode-container and --scheme require --resolve-packages, --build, or --simulator.", + ); + } + if (options.simulator && isAgent() && !options.device) { + throwUsageError("--simulator requires --device in agent mode."); + } + if (options.fix && executesXcode) { + throwUsageError( + "--fix cannot be combined with Xcode execution flags. Run fixes first, then rerun doctor with the build option.", + ); + } +} + export async function doctor(options: DoctorOptions = {}): Promise { + validateDoctorOptions(options); + if (!options.json) { intro("Running diagnostics"); } const ctx = createDoctorContext(); - const allResults = await withSpinner("Running diagnostics...", async () => runChecks(ctx)); + const allResults = await withSpinner("Running diagnostics...", async () => + runChecks(ctx, options), + ); if (!options.json) { printResults(allResults, options); @@ -120,7 +197,7 @@ export async function doctor(options: DoctorOptions = {}): Promise { const verifyCtx = createDoctorContext(); const verifyResults = await withSpinner("Verifying fixes...", async () => - runChecks(verifyCtx), + runChecks(verifyCtx, options), ); printResults(verifyResults, { ...options, fix: false, spotlight: false }); @@ -152,12 +229,27 @@ export function registerDoctor(program: Program): void { .option("--json", "Output results as JSON") .option("--spotlight", "Only show warnings and failures") .option("--fix", "Attempt to auto-fix issues") + .option("--target ", "Select an iOS application target") + .option("--xcode-container ", "Use an explicit Xcode project or workspace") + .option("--scheme ", "Use an explicit Xcode scheme for execution checks") + .option("--resolve-packages", "Allow Xcode to resolve Swift package dependencies") + .option("--build", "Build the selected iOS scheme for the simulator") + .option("--simulator", "Build, install, and launch in an iOS Simulator") + .option("--device ", "Simulator UDID or exact device name") .setExamples([ { command: "clerk doctor", description: "Run all health checks" }, { command: "clerk doctor --verbose", description: "Show detailed output for each check" }, { command: "clerk doctor --json", description: "Output results as machine-readable JSON" }, { command: "clerk doctor --fix", description: "Auto-fix detected issues" }, { command: "clerk doctor --spotlight", description: "Only show warnings and failures" }, + { + command: "clerk doctor --target MyApp", + description: "Audit a specific iOS application target", + }, + { + command: "clerk doctor --target MyApp --build", + description: "Audit and compile an iOS application target", + }, ]) .action(doctor); } diff --git a/packages/cli-core/src/commands/doctor/ios-xcode.test.ts b/packages/cli-core/src/commands/doctor/ios-xcode.test.ts new file mode 100644 index 000000000..56dadbb87 --- /dev/null +++ b/packages/cli-core/src/commands/doctor/ios-xcode.test.ts @@ -0,0 +1,658 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectIOSProject } from "../init/ios/inspect.ts"; +import { createIOSFixture } from "../init/ios/test-helpers.ts"; +import { + createIOSXcodeChildEnvironment, + runIOSXcodeCommand, + runIOSXcodeVerification, + sanitizeIOSXcodeDiagnostic, + type IOSXcodeCommandOptions, + type IOSXcodeCommandResult, + type IOSXcodeCommandRunner, +} from "./ios-xcode.ts"; + +interface Invocation { + argv: string[]; + options: IOSXcodeCommandOptions; +} + +const success = (stdout = "", stderr = ""): IOSXcodeCommandResult => ({ + exitCode: 0, + stdout, + stderr, + timedOut: false, + truncated: false, +}); + +const failure = (stderr: string): IOSXcodeCommandResult => ({ + exitCode: 65, + stdout: "", + stderr, + timedOut: false, + truncated: false, +}); + +let root: string; +let temporaryBuildRoot: string; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "clerk-doctor-ios-xcode-test-")); + temporaryBuildRoot = join(root, ".doctor-build"); + await mkdir(temporaryBuildRoot, { recursive: true }); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +function packageResolvedContents(): string { + return JSON.stringify({ + version: 3, + pins: [ + { + identity: "clerk-ios", + kind: "remoteSourceControl", + location: "https://github.com/clerk/clerk-ios.git", + state: { revision: "abc", version: "1.0.0" }, + }, + ], + }); +} + +async function writeProjectPackageResolved(projectName = "MyApp"): Promise { + const path = join( + root, + `${projectName}.xcodeproj`, + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved", + ); + await mkdir(join(path, ".."), { recursive: true }); + await Bun.write(path, packageResolvedContents()); + return path; +} + +async function writeWorkspacePackageResolved(workspaceName = "MyApp"): Promise { + const path = join( + root, + `${workspaceName}.xcworkspace`, + "xcshareddata", + "swiftpm", + "Package.resolved", + ); + await mkdir(join(path, ".."), { recursive: true }); + await Bun.write(path, packageResolvedContents()); + return path; +} + +function buildSettingsOutput( + options: { + projectPath?: string; + targetName?: string; + productType?: string; + bundleIdentifier?: string; + } = {}, +): string { + const targetBuildDir = join( + temporaryBuildRoot, + "DerivedData", + "Build", + "Products", + "Debug-iphonesimulator", + ); + return JSON.stringify([ + { + target: options.targetName ?? "MyApp", + buildSettings: { + TARGET_NAME: options.targetName ?? "MyApp", + PROJECT_FILE_PATH: options.projectPath ?? join(root, "MyApp.xcodeproj"), + PRODUCT_TYPE: options.productType ?? "com.apple.product-type.application", + TARGET_BUILD_DIR: targetBuildDir, + FULL_PRODUCT_NAME: "MyApp.app", + PRODUCT_BUNDLE_IDENTIFIER: options.bundleIdentifier ?? "com.example.MyApp", + }, + }, + ]); +} + +function successfulXcodeRunner( + invocations: Invocation[], + options: { + schemes?: string[]; + workspace?: boolean; + buildFailure?: string; + createApp?: boolean; + simulatorDevices?: unknown; + } = {}, +): IOSXcodeCommandRunner { + return async (argv, commandOptions) => { + const args = [...argv]; + invocations.push({ argv: args, options: commandOptions }); + if (args.includes("-version")) return success("Xcode 26.0\nBuild version 1A1\n"); + if (args.includes("-list") && args.includes("xcodebuild")) { + return success( + JSON.stringify({ + [options.workspace ? "workspace" : "project"]: { + schemes: options.schemes ?? ["MyApp"], + targets: ["MyApp"], + configurations: ["Debug", "Release"], + }, + }), + ); + } + if (args.includes("-showBuildSettings")) return success(buildSettingsOutput()); + if (args.includes("build") && args.includes("xcodebuild")) { + if (options.buildFailure) return failure(options.buildFailure); + if (options.createApp) { + await mkdir( + join( + temporaryBuildRoot, + "DerivedData", + "Build", + "Products", + "Debug-iphonesimulator", + "MyApp.app", + ), + { recursive: true }, + ); + } + return success(); + } + if (args.includes("simctl") && args.includes("list")) { + return success(JSON.stringify(options.simulatorDevices)); + } + if (args.includes("simctl")) return success(); + return failure(`Unexpected command: ${args.join(" ")}`); + }; +} + +function dependencies(runner: IOSXcodeCommandRunner) { + return { + runner, + platform: "darwin" as const, + xcrunPath: "/usr/bin/xcrun", + environment: { + PATH: "/usr/bin:/bin", + HOME: root, + USER: "tester", + CLERK_PLATFORM_API_KEY: "ak_test_must_not_escape", + CLERK_SECRET_KEY: "sk_test_must_not_escape", + GITHUB_TOKEN: "github_must_not_escape", + }, + makeTemporaryDirectory: async () => temporaryBuildRoot, + removeTemporaryDirectory: async () => {}, + }; +} + +describe("runIOSXcodeVerification", () => { + test("uses a verified auto-created scheme for a frozen isolated build", async () => { + await createIOSFixture(root); + await writeProjectPackageResolved(); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { build: true }, + dependencies(successfulXcodeRunner(invocations)), + ); + + expect(results.every((result) => result.status === "pass")).toBe(true); + expect(results.map((result) => result.name)).toEqual([ + "Xcode container", + "Xcode toolchain", + "Swift packages", + "Xcode scheme", + "Xcode build", + ]); + + const build = invocations.find( + (invocation) => invocation.argv.includes("xcodebuild") && invocation.argv.includes("build"), + ); + expect(build?.argv).toContain("-project"); + expect(build?.argv).toContain(join(root, "MyApp.xcodeproj")); + expect(build?.argv).toContain("-scheme"); + expect(build?.argv).toContain("MyApp"); + expect(build?.argv).toContain("generic/platform=iOS Simulator"); + expect(build?.argv).toContain("-disableAutomaticPackageResolution"); + expect(build?.argv).toContain("-onlyUsePackageVersionsFromResolvedFile"); + expect(build?.argv).toContain("-skipPackageUpdates"); + expect(build?.argv).toContain("CODE_SIGNING_ALLOWED=NO"); + expect(build?.argv).toContain(join(temporaryBuildRoot, "DerivedData")); + expect(build?.argv).toContain(join(temporaryBuildRoot, "SourcePackages")); + + for (const invocation of invocations) { + expect(invocation.options.env.CLERK_PLATFORM_API_KEY).toBeUndefined(); + expect(invocation.options.env.CLERK_SECRET_KEY).toBeUndefined(); + expect(invocation.options.env.GITHUB_TOKEN).toBeUndefined(); + expect(JSON.stringify(invocation)).not.toContain("must_not_escape"); + } + }); + + test("requires explicit package resolution before a remote-package build", async () => { + await createIOSFixture(root); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { build: true }, + dependencies(successfulXcodeRunner(invocations)), + ); + + expect(results.at(-1)).toMatchObject({ name: "Swift packages", status: "fail" }); + expect(results.at(-1)?.remedy).toContain("--resolve-packages --build"); + expect(invocations.some((invocation) => invocation.argv.includes("-list"))).toBe(false); + expect(invocations.some((invocation) => invocation.argv.includes("build"))).toBe(false); + }); + + test("explicitly resolves packages, reports lockfile creation, then builds frozen", async () => { + await createIOSFixture(root); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + const baseRunner = successfulXcodeRunner(invocations); + const runner: IOSXcodeCommandRunner = async (argv, commandOptions) => { + if (argv.includes("-resolvePackageDependencies")) { + invocations.push({ argv: [...argv], options: commandOptions }); + await writeProjectPackageResolved(); + return success(); + } + return baseRunner(argv, commandOptions); + }; + + const results = await runIOSXcodeVerification( + inspection, + { resolvePackages: true, build: true }, + dependencies(runner), + ); + + expect(results.every((result) => result.status === "pass")).toBe(true); + expect(results.find((result) => result.name === "Swift packages")?.message).toContain( + "created", + ); + const resolutionIndex = invocations.findIndex((invocation) => + invocation.argv.includes("-resolvePackageDependencies"), + ); + const buildIndex = invocations.findIndex( + (invocation) => invocation.argv.includes("xcodebuild") && invocation.argv.includes("build"), + ); + expect(resolutionIndex).toBeGreaterThan(-1); + expect(buildIndex).toBeGreaterThan(resolutionIndex); + }); + + test("does not run Xcode when Package.resolved is a symbolic link", async () => { + await createIOSFixture(root); + const lockPath = join( + root, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved", + ); + await mkdir(join(lockPath, ".."), { recursive: true }); + await symlink(join(tmpdir(), "clerk-doctor-external-Package.resolved"), lockPath); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { resolvePackages: true }, + dependencies(successfulXcodeRunner(invocations)), + ); + + expect(results.at(-1)).toMatchObject({ name: "Swift packages", status: "fail" }); + expect(results.at(-1)?.message).toContain("unsafe to inspect or update"); + expect(invocations).toEqual([]); + }); + + test("does not run Xcode when a missing package lock has a symbolic-link ancestor", async () => { + await createIOSFixture(root); + const externalDirectory = await mkdtemp(join(tmpdir(), "clerk-doctor-lock-redirect-")); + try { + const workspacePath = join(root, "MyApp.xcodeproj", "project.xcworkspace"); + await mkdir(workspacePath, { recursive: true }); + await symlink(externalDirectory, join(workspacePath, "xcshareddata"), "dir"); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { resolvePackages: true }, + dependencies(successfulXcodeRunner(invocations)), + ); + + expect(results.at(-1)).toMatchObject({ name: "Swift packages", status: "fail" }); + expect(results.at(-1)?.message).toContain("unsafe to inspect or update"); + expect(invocations).toEqual([]); + } finally { + await rm(externalDirectory, { recursive: true, force: true }); + } + }); + + test("does not read or run a frozen build through an external lockfile symlink", async () => { + await createIOSFixture(root); + const externalDirectory = await mkdtemp(join(tmpdir(), "clerk-doctor-lock-leaf-")); + try { + const externalLock = join(externalDirectory, "Package.resolved"); + await Bun.write(externalLock, packageResolvedContents()); + const lockPath = join( + root, + "MyApp.xcodeproj", + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved", + ); + await mkdir(join(lockPath, ".."), { recursive: true }); + await symlink(externalLock, lockPath); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { build: true }, + dependencies(successfulXcodeRunner(invocations)), + ); + + expect(results.at(-1)).toMatchObject({ name: "Swift packages", status: "fail" }); + expect(results.at(-1)?.message).toContain("unsafe to inspect or update"); + expect(invocations).toEqual([]); + } finally { + await rm(externalDirectory, { recursive: true, force: true }); + } + }); + + test("does not read or run a frozen build through an external lock parent", async () => { + await createIOSFixture(root); + const externalDirectory = await mkdtemp(join(tmpdir(), "clerk-doctor-lock-parent-")); + try { + await mkdir(join(externalDirectory, "swiftpm"), { recursive: true }); + await Bun.write( + join(externalDirectory, "swiftpm", "Package.resolved"), + packageResolvedContents(), + ); + const workspacePath = join(root, "MyApp.xcodeproj", "project.xcworkspace"); + await mkdir(workspacePath, { recursive: true }); + await symlink(externalDirectory, join(workspacePath, "xcshareddata"), "dir"); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { build: true }, + dependencies(successfulXcodeRunner(invocations)), + ); + + expect(results.at(-1)).toMatchObject({ name: "Swift packages", status: "fail" }); + expect(results.at(-1)?.detail).toContain("outside the inspected project root"); + expect(invocations).toEqual([]); + } finally { + await rm(externalDirectory, { recursive: true, force: true }); + } + }); + + test("refuses to guess among unrelated schemes", async () => { + await createIOSFixture(root); + await writeProjectPackageResolved(); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { build: true }, + dependencies(successfulXcodeRunner(invocations, { schemes: ["Alpha", "Beta"] })), + ); + + expect(results.at(-1)).toMatchObject({ name: "Xcode scheme", status: "fail" }); + expect(results.at(-1)?.remedy).toContain("--scheme"); + expect(invocations.some((invocation) => invocation.argv.includes("-showBuildSettings"))).toBe( + false, + ); + }); + + test("uses the single containing workspace and its lockfile", async () => { + await createIOSFixture(root, { workspace: true }); + await writeWorkspacePackageResolved(); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { build: true }, + dependencies(successfulXcodeRunner(invocations, { workspace: true })), + ); + + expect(results.every((result) => result.status === "pass")).toBe(true); + const build = invocations.find( + (invocation) => invocation.argv.includes("xcodebuild") && invocation.argv.includes("build"), + ); + expect(build?.argv).toContain("-workspace"); + expect(build?.argv).toContain(join(root, "MyApp.xcworkspace")); + }); + + test("sanitizes bounded Xcode failure diagnostics", async () => { + await createIOSFixture(root); + await writeProjectPackageResolved(); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + const sensitive = + "error: pk_test_publishable Bearer ak_test_platform CLERK_SECRET_KEY=sk_test_backend"; + + const results = await runIOSXcodeVerification( + inspection, + { build: true }, + dependencies(successfulXcodeRunner(invocations, { buildFailure: sensitive })), + ); + + const output = JSON.stringify(results); + expect(results.at(-1)).toMatchObject({ name: "Xcode build", status: "fail" }); + expect(output).not.toContain("pk_test_publishable"); + expect(output).not.toContain("ak_test_platform"); + expect(output).not.toContain("sk_test_backend"); + expect(output).toContain(""); + }); + + test("builds, installs, and launches on the single booted iOS simulator", async () => { + await createIOSFixture(root, { clerkSDK: false }); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const invocations: Invocation[] = []; + const devices = { + devices: { + "com.apple.CoreSimulator.SimRuntime.iOS-26-5": [ + { + name: "iPhone 17 Pro", + udid: "B926551C-01F4-4D5D-8CA8-90F2DF97C48A", + state: "Booted", + isAvailable: true, + }, + ], + }, + }; + + const results = await runIOSXcodeVerification( + inspection, + { simulator: true }, + dependencies( + successfulXcodeRunner(invocations, { + createApp: true, + simulatorDevices: devices, + }), + ), + ); + + expect(results.every((result) => result.status === "pass")).toBe(true); + expect(results.at(-1)?.message).toContain("iPhone 17 Pro"); + const simulatorCommands = invocations + .filter((invocation) => invocation.argv.includes("simctl")) + .map((invocation) => invocation.argv); + expect(simulatorCommands.map((argv) => argv[2])).toEqual([ + "list", + "bootstatus", + "install", + "launch", + ]); + expect(simulatorCommands.flat()).not.toContain("--terminate-running-process"); + expect(simulatorCommands.flat()).not.toContain("--console"); + }); + + test("builds but blocks simctl launch for a Run-scheme publishable key", async () => { + await createIOSFixture(root, { clerkSDK: false }); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const target = inspection.appTargets.find((candidate) => candidate.name === "MyApp")!; + target.swift.configureCalls.push({ + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "process-info-environment", + startupBinding: "app-init", + }); + const invocations: Invocation[] = []; + + const results = await runIOSXcodeVerification( + inspection, + { simulator: true }, + dependencies(successfulXcodeRunner(invocations, { createApp: true })), + ); + + expect(results.find((result) => result.name === "Xcode build")?.status).toBe("pass"); + expect(results.at(-1)).toMatchObject({ name: "iOS Simulator", status: "fail" }); + expect(results.at(-1)?.message).toContain("Run-scheme"); + expect(invocations.some((invocation) => invocation.argv.includes("simctl"))).toBe(false); + }); + + test("rejects --device unless simulator launch is requested", async () => { + await createIOSFixture(root, { clerkSDK: false }); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + + const results = await runIOSXcodeVerification(inspection, { + build: true, + device: "B926551C-01F4-4D5D-8CA8-90F2DF97C48A", + }); + + expect(results).toEqual([expect.objectContaining({ name: "iOS Simulator", status: "fail" })]); + }); +}); + +describe("Xcode subprocess safety", () => { + test("redacts Clerk credentials, bearer tokens, and terminal controls", () => { + const output = sanitizeIOSXcodeDiagnostic( + `${String.fromCharCode(27)}[31merror${String.fromCharCode(27)}[0m ` + + "pk_test_public sk_live_backend Bearer ak_test_platform PASSWORD=hunter2", + ); + + expect(output).toContain("error"); + expect(output).not.toContain("pk_test_public"); + expect(output).not.toContain("sk_live_backend"); + expect(output).not.toContain("ak_test_platform"); + expect(output).not.toContain("hunter2"); + expect(output).not.toContain(String.fromCharCode(27)); + }); + + test("redacts credentials embedded in HTTPS and SSH repository URLs", () => { + const output = sanitizeIOSXcodeDiagnostic( + [ + "https://alice:https-secret@part@github.com/acme/private.git", + "ssh://deploy:ssh-secret@git.example.com/acme/private.git", + "git+ssh://builder:encoded%2Dsecret@git.example.com/acme/private.git", + "machine:scp-secret@git.example.com:acme/private.git", + ].join("\n"), + ); + + expect(output).toContain("https://@github.com/acme/private.git"); + expect(output).toContain("ssh://@git.example.com/acme/private.git"); + expect(output).toContain("git+ssh://@git.example.com/acme/private.git"); + expect(output).toContain("@git.example.com:acme/private.git"); + expect(output).not.toContain("alice"); + expect(output).not.toContain("https-secret"); + expect(output).not.toContain("part"); + expect(output).not.toContain("ssh-secret"); + expect(output).not.toContain("encoded%2Dsecret"); + expect(output).not.toContain("scp-secret"); + }); + + test("removes URL query and fragment secrets and Basic authorization payloads", () => { + const output = sanitizeIOSXcodeDiagnostic( + [ + "error cloning https://example.com/repo.git?key=supersecret", + "fallback (ssh://git.example.com/acme/private.git#token=ssh-fragment).", + "mirror git+ssh://git.example.com/acme/private.git?access_token=mirror-secret", + "legacy git://git.example.com/acme/private.git#legacy-secret", + "Authorization: Basic dXNlcjpwYXNz", + ].join("\n"), + ); + + expect(output).toContain("https://example.com/repo.git"); + expect(output).toContain("(ssh://git.example.com/acme/private.git)."); + expect(output).toContain("git+ssh://git.example.com/acme/private.git"); + expect(output).toContain("git://git.example.com/acme/private.git"); + expect(output).toContain("Authorization: Basic "); + expect(output).not.toContain("?key="); + expect(output).not.toContain("#token="); + expect(output).not.toContain("supersecret"); + expect(output).not.toContain("ssh-fragment"); + expect(output).not.toContain("mirror-secret"); + expect(output).not.toContain("legacy-secret"); + expect(output).not.toContain("dXNlcjpwYXNz"); + }); + + test("passes only ordinary toolchain and locale environment values", () => { + const env = createIOSXcodeChildEnvironment({ + PATH: "/usr/bin", + HOME: "/tmp/home", + LANG: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + DEVELOPER_DIR: "/Applications/Xcode.app", + CLERK_PLATFORM_API_KEY: "ak_test_secret", + CLERK_SECRET_KEY: "sk_test_secret", + GITHUB_TOKEN: "github_secret", + AWS_SECRET_ACCESS_KEY: "aws_secret", + SIMCTL_CHILD_CLERK_PUBLISHABLE_KEY: "pk_test_secret", + LC_API_KEY: "locale_api_secret", + LC_TOKEN: "locale_token_secret", + }); + + expect(env).toEqual({ + PATH: "/usr/bin", + HOME: "/tmp/home", + LANG: "en_US.UTF-8", + LC_MESSAGES: "en_US.UTF-8", + DEVELOPER_DIR: "/Applications/Xcode.app", + }); + expect(env.LC_API_KEY).toBeUndefined(); + expect(env.LC_TOKEN).toBeUndefined(); + }); + + test("bounds subprocess output while continuing to drain it", async () => { + const script = join(root, "large-output.ts"); + await Bun.write(script, 'process.stdout.write("x".repeat(4096));\n'); + + const result = await runIOSXcodeCommand([process.execPath, script], { + cwd: root, + env: createIOSXcodeChildEnvironment(process.env), + timeoutMs: 5_000, + maxOutputBytes: 128, + }); + + expect(result.exitCode).toBe(0); + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(128); + }); + + test("terminates a subprocess after its hard timeout", async () => { + const script = join(root, "hang.ts"); + await Bun.write(script, "setInterval(() => {}, 1000);\n"); + + const result = await runIOSXcodeCommand([process.execPath, script], { + cwd: root, + env: createIOSXcodeChildEnvironment(process.env), + timeoutMs: 20, + maxOutputBytes: 128, + }); + + expect(result.timedOut).toBe(true); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/packages/cli-core/src/commands/doctor/ios-xcode.ts b/packages/cli-core/src/commands/doctor/ios-xcode.ts new file mode 100644 index 000000000..4725e76ad --- /dev/null +++ b/packages/cli-core/src/commands/doctor/ios-xcode.ts @@ -0,0 +1,1588 @@ +import { lstat, mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { errorMessage } from "../../lib/errors.ts"; +import { isRecord } from "../../lib/objects.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "../init/ios/discovery.ts"; +import type { + IOSAppTarget, + IOSProjectInspectionResult, + IOSWorkspaceInspection, +} from "../init/ios/types.ts"; +import type { CheckResult } from "./types.ts"; + +const XCRUN = "/usr/bin/xcrun"; +const TOOLCHAIN_TIMEOUT_MS = 10_000; +const DISCOVERY_TIMEOUT_MS = 30_000; +const RESOLUTION_TIMEOUT_MS = 5 * 60_000; +const BUILD_TIMEOUT_MS = 10 * 60_000; +const SIMULATOR_LIST_TIMEOUT_MS = 15_000; +const SIMULATOR_BOOT_TIMEOUT_MS = 2 * 60_000; +const SIMULATOR_OPERATION_TIMEOUT_MS = 60_000; +const FORCE_KILL_DELAY_MS = 2_000; +const DEFAULT_OUTPUT_LIMIT_BYTES = 1024 * 1024; +const JSON_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024; +const MAX_PACKAGE_RESOLVED_BYTES = 2 * 1024 * 1024; +const MAX_SCHEME_BYTES = 2 * 1024 * 1024; +const APP_PRODUCT_TYPE = "com.apple.product-type.application"; + +export interface IOSXcodeVerificationOptions { + /** Project-root-relative or absolute `.xcodeproj`/`.xcworkspace` path. */ + container?: string; + /** Scheme to verify instead of selecting one from exact target evidence. */ + scheme?: string; + /** Explicitly allow Xcode to create or update Package.resolved. */ + resolvePackages?: boolean; + /** Run a frozen, code-signing-disabled iOS Simulator build. */ + build?: boolean; + /** Install and launch the built app in a simulator. Implies `build`. */ + simulator?: boolean; + /** Exact simulator UDID or exact device name. Only valid with `simulator`. */ + device?: string; +} + +export interface IOSXcodeCommandOptions { + cwd: string; + env: Record; + timeoutMs: number; + maxOutputBytes?: number; +} + +export interface IOSXcodeCommandResult { + exitCode: number | null; + stdout: string; + stderr: string; + timedOut: boolean; + truncated: boolean; + spawnError?: string; +} + +export type IOSXcodeCommandRunner = ( + argv: readonly string[], + options: IOSXcodeCommandOptions, +) => Promise; + +export interface IOSXcodeVerificationDependencies { + runner?: IOSXcodeCommandRunner; + platform?: NodeJS.Platform; + xcrunPath?: string; + environment?: NodeJS.ProcessEnv; + makeTemporaryDirectory?: () => Promise; + removeTemporaryDirectory?: (path: string) => Promise; +} + +interface BoundedOutput { + text: string; + truncated: boolean; +} + +interface SelectedContainer { + kind: "project" | "workspace"; + flag: "-project" | "-workspace"; + absolutePath: string; + relativePath: string; + workspace?: IOSWorkspaceInspection; +} + +interface PackageResolvedSnapshot { + status: "missing" | "valid" | "invalid"; + path: string; + hash?: string; +} + +interface PackageResolvedPathSafety { + safe: boolean; + exists?: boolean; + detail?: string; +} + +interface SafePackageResolvedSnapshot { + snapshot?: PackageResolvedSnapshot; + detail?: string; +} + +interface VerifiedBuildSettings { + targetBuildDir?: string; + fullProductName?: string; + bundleIdentifier?: string; +} + +interface SimulatorDevice { + name: string; + udid: string; + state: string; + runtime: string; +} + +function pass(name: string, message: string, detail?: string): CheckResult { + return { + name, + status: "pass", + message: sanitizeInline(message), + ...(detail ? { detail: sanitizeIOSXcodeDiagnostic(detail) } : {}), + }; +} + +function warn(name: string, message: string, remedy?: string, detail?: string): CheckResult { + return { + name, + status: "warn", + message: sanitizeInline(message), + ...(detail ? { detail: sanitizeIOSXcodeDiagnostic(detail) } : {}), + ...(remedy ? { remedy: sanitizeIOSXcodeDiagnostic(remedy) } : {}), + }; +} + +function fail(name: string, message: string, remedy?: string, detail?: string): CheckResult { + return { + name, + status: "fail", + message: sanitizeInline(message), + ...(detail ? { detail: sanitizeIOSXcodeDiagnostic(detail) } : {}), + ...(remedy ? { remedy: sanitizeIOSXcodeDiagnostic(remedy) } : {}), + }; +} + +function sanitizeSupportedURLTokens(value: string): string { + return value.replace(/\b(?:https?|ssh|git(?:\+ssh)?):\/\/[^\s"'`<>]+/gi, (token) => { + const queryIndex = token.indexOf("?"); + const fragmentIndex = token.indexOf("#"); + const secretIndex = [queryIndex, fragmentIndex] + .filter((index) => index >= 0) + .reduce((lowest, index) => Math.min(lowest, index), token.length); + const removed = token.slice(secretIndex); + const structuralSuffix = removed.match(/[\])},.;]+$/)?.[0] ?? ""; + return `${token.slice(0, secretIndex).replace(/^((?:https?|ssh|git(?:\+ssh)?):\/\/)[^/\s]+@/i, "$1@")}${structuralSuffix}`; + }); +} + +/** + * Removes terminal control sequences and credentials before subprocess output + * reaches human, verbose, debug, or JSON doctor output. + */ +export function sanitizeIOSXcodeDiagnostic(value: string): string { + const escape = String.fromCharCode(27); + const ansiPattern = new RegExp(`${escape}\\[[0-?]*[ -/]*[@-~]`, "g"); + const withoutAnsi = value.replace(ansiPattern, ""); + let withoutControls = ""; + for (const char of withoutAnsi) { + const code = char.codePointAt(0)!; + if (char === "\n" || char === "\r" || char === "\t" || (code >= 0x20 && code !== 0x7f)) { + if (code < 0x80 || code > 0x9f) withoutControls += char; + } + } + + return sanitizeSupportedURLTokens(withoutControls) + .replace( + /(^|[\s("'`=])[A-Za-z0-9._~%!$&'()*+,;=:+-]+@((?:\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+):[^\s"'`<>]+)/gm, + "$1@$2", + ) + .replace(/\bBasic\s+[A-Za-z0-9+/_=-]{4,}/gi, "Basic ") + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer ") + .replace(/\b(?:pk|sk|ak)_[A-Za-z0-9._~+/=-]+/gi, "") + .replace( + /(\b[A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|CREDENTIAL|PRIVATE_KEY|API_KEY|PUBLISHABLE_KEY)[A-Z0-9_]*\b\s*[=:]\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s\r\n]+)/gi, + "$1", + ) + .replaceAll("\r\n", "\n") + .replaceAll("\r", "\n") + .trim(); +} + +function sanitizeInline(value: string): string { + return sanitizeIOSXcodeDiagnostic(value).replace(/\s+/g, " ").slice(0, 500); +} + +function diagnosticDetail(result: IOSXcodeCommandResult): string | undefined { + const raw = result.stderr.trim() || result.stdout.trim() || result.spawnError || ""; + const sanitized = sanitizeIOSXcodeDiagnostic(raw); + if (!sanitized) return result.truncated ? "Subprocess output was truncated." : undefined; + const lines = sanitized.split("\n"); + const detail = lines + .slice(Math.max(0, lines.length - 40)) + .join("\n") + .slice(-8_000); + return result.truncated ? `${detail}\n[output truncated]` : detail; +} + +function commandFailure( + name: string, + action: string, + result: IOSXcodeCommandResult, + remedy: string, +): CheckResult { + const reason = result.timedOut + ? `${action} timed out` + : result.spawnError + ? `${action} could not start` + : `${action} exited with code ${result.exitCode ?? "unknown"}`; + return fail(name, reason, remedy, diagnosticDetail(result)); +} + +function isCommandSuccess(result: IOSXcodeCommandResult): boolean { + return !result.timedOut && !result.spawnError && result.exitCode === 0; +} + +async function readBoundedStream( + stream: ReadableStream, + limit: number, +): Promise { + const reader = stream.getReader(); + let retained = new Uint8Array(0); + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (value.byteLength >= limit) { + retained = value.slice(value.byteLength - limit); + continue; + } + const overflow = Math.max(0, retained.byteLength + value.byteLength - limit); + const previous = overflow > 0 ? retained.slice(overflow) : retained; + const next = new Uint8Array(previous.byteLength + value.byteLength); + next.set(previous); + next.set(value, previous.byteLength); + retained = next; + } + } finally { + reader.releaseLock(); + } + return { + text: new TextDecoder().decode(retained), + truncated: total > limit, + }; +} + +/** Default bounded, non-interactive command runner used by iOS doctor. */ +export const runIOSXcodeCommand: IOSXcodeCommandRunner = async (argv, options) => { + let process: ReturnType; + try { + process = Bun.spawn([...argv], { + cwd: options.cwd, + env: options.env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + } catch (error) { + return { + exitCode: null, + stdout: "", + stderr: "", + timedOut: false, + truncated: false, + spawnError: sanitizeIOSXcodeDiagnostic(errorMessage(error)), + }; + } + + let timedOut = false; + let forceKillTimer: ReturnType | undefined; + const timeout = setTimeout(() => { + timedOut = true; + try { + process.kill("SIGTERM"); + } catch {} + forceKillTimer = setTimeout(() => { + try { + process.kill("SIGKILL"); + } catch {} + }, FORCE_KILL_DELAY_MS); + }, options.timeoutMs); + + const outputLimit = options.maxOutputBytes ?? DEFAULT_OUTPUT_LIMIT_BYTES; + try { + const [exitCode, stdout, stderr] = await Promise.all([ + process.exited, + readBoundedStream(process.stdout as ReadableStream, outputLimit), + readBoundedStream(process.stderr as ReadableStream, outputLimit), + ]); + return { + exitCode, + stdout: stdout.text, + stderr: stderr.text, + timedOut, + truncated: stdout.truncated || stderr.truncated, + }; + } catch (error) { + return { + exitCode: null, + stdout: "", + stderr: "", + timedOut, + truncated: false, + spawnError: sanitizeIOSXcodeDiagnostic(errorMessage(error)), + }; + } finally { + clearTimeout(timeout); + if (forceKillTimer) clearTimeout(forceKillTimer); + } +}; + +/** + * Xcode build phases receive the parent's environment. Keep only ordinary + * toolchain/locale values so CLI API keys and unrelated host credentials are + * not inherited by project-controlled scripts. + */ +export function createIOSXcodeChildEnvironment( + source: NodeJS.ProcessEnv = process.env, +): Record { + const allowed = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TMPDIR", + "LANG", + "LC_ALL", + "LC_COLLATE", + "LC_CTYPE", + "LC_MESSAGES", + "LC_MONETARY", + "LC_NUMERIC", + "LC_TIME", + "TERM", + "DEVELOPER_DIR", + "__CF_USER_TEXT_ENCODING", + ]); + const env: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (typeof value !== "string") continue; + if (allowed.has(key)) env[key] = value; + } + env.PATH ??= "/usr/bin:/bin:/usr/sbin:/sbin"; + return env; +} + +function targetFromInspection(inspection: IOSProjectInspectionResult): { + target?: IOSAppTarget; + result?: CheckResult; +} { + const selection = inspection.selection; + if (selection.state !== "selected") { + return { + result: fail( + "Xcode target", + "No unambiguous iOS application target is selected", + "Rerun with --target .", + ), + }; + } + const target = inspection.appTargets.find( + (candidate) => + candidate.id === selection.targetId && candidate.projectPath === selection.projectPath, + ); + if (!target) { + return { + result: fail( + "Xcode target", + "The selected iOS target is no longer present in the inspection", + "Rerun clerk doctor so the Xcode project can be inspected again.", + ), + }; + } + return { target }; +} + +async function isSafeDirectory(root: string, path: string): Promise { + if (!(await pathIsSafelyWithinIOSRoot(root, path))) return false; + try { + const info = await lstat(path); + return info.isDirectory() && !info.isSymbolicLink(); + } catch { + return false; + } +} + +async function selectContainer( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, + requested: string | undefined, +): Promise<{ container?: SelectedContainer; result?: CheckResult }> { + const root = inspection.root; + const selectedProject = resolve(root, target.projectPath); + + if (requested) { + const absolutePath = isAbsolute(requested) ? resolve(requested) : resolve(root, requested); + const extension = extname(absolutePath); + if (extension !== ".xcodeproj" && extension !== ".xcworkspace") { + return { + result: fail( + "Xcode container", + "The requested Xcode container is not a .xcodeproj or .xcworkspace", + "Pass --xcode-container with the selected project or a workspace containing it.", + ), + }; + } + if (!(await isSafeDirectory(root, absolutePath))) { + return { + result: fail( + "Xcode container", + "The requested Xcode container is missing, external, or unsafe", + "Choose an inspected Xcode project or workspace inside the project root.", + ), + }; + } + const relativePath = relativeIOSPath(root, absolutePath); + if (extension === ".xcodeproj") { + if (relativePath !== target.projectPath) { + return { + result: fail( + "Xcode container", + "The requested project does not own the selected target", + `Use ${target.projectPath}, or select a target owned by the requested project.`, + ), + }; + } + return { + container: { + kind: "project", + flag: "-project", + absolutePath, + relativePath, + }, + }; + } + + const workspace = inspection.workspaces.find((candidate) => candidate.path === relativePath); + if (!workspace?.projectPaths.includes(target.projectPath)) { + return { + result: fail( + "Xcode container", + "The requested workspace does not contain the selected target's project", + "Choose an inspected workspace that contains the selected project.", + ), + }; + } + return { + container: { + kind: "workspace", + flag: "-workspace", + absolutePath, + relativePath, + workspace, + }, + }; + } + + const containingWorkspaces = inspection.workspaces.filter((workspace) => + workspace.projectPaths.includes(target.projectPath), + ); + if (containingWorkspaces.length > 1) { + return { + result: fail( + "Xcode container", + "More than one workspace contains the selected iOS project", + "Pass --xcode-container to select one explicitly.", + containingWorkspaces.map((workspace) => workspace.path).join("\n"), + ), + }; + } + const workspace = containingWorkspaces[0]; + if (workspace) { + const absolutePath = resolve(root, workspace.path); + if (!(await isSafeDirectory(root, absolutePath))) { + return { + result: fail( + "Xcode container", + "The inspected workspace is no longer a safe local directory", + "Rerun clerk doctor or pass the selected .xcodeproj explicitly.", + ), + }; + } + return { + container: { + kind: "workspace", + flag: "-workspace", + absolutePath, + relativePath: workspace.path, + workspace, + }, + }; + } + + if (!(await isSafeDirectory(root, selectedProject))) { + return { + result: fail( + "Xcode container", + "The selected target's project is no longer a safe local directory", + "Rerun clerk doctor after restoring the Xcode project.", + ), + }; + } + return { + container: { + kind: "project", + flag: "-project", + absolutePath: selectedProject, + relativePath: target.projectPath, + }, + }; +} + +function containerProjectPaths(container: SelectedContainer, target: IOSAppTarget): string[] { + return container.kind === "workspace" + ? (container.workspace?.projectPaths ?? []) + : [target.projectPath]; +} + +function containerHasRemotePackages( + inspection: IOSProjectInspectionResult, + container: SelectedContainer, + target: IOSAppTarget, +): boolean { + const projects = new Set(containerProjectPaths(container, target)); + return inspection.projects.some( + (project) => + projects.has(project.path) && + project.packages.some((reference) => reference.kind === "remote"), + ); +} + +function packageResolvedPath(container: SelectedContainer): string { + return container.kind === "workspace" + ? join(container.absolutePath, "xcshareddata", "swiftpm", "Package.resolved") + : join( + container.absolutePath, + "project.xcworkspace", + "xcshareddata", + "swiftpm", + "Package.resolved", + ); +} + +async function readPackageResolvedSnapshot(path: string): Promise { + let bytes: Uint8Array; + try { + const info = await lstat(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_PACKAGE_RESOLVED_BYTES) { + return { status: "invalid", path }; + } + bytes = await readFile(path); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return { status: "missing", path }; + } + return { status: "invalid", path }; + } + + try { + const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes)); + if (!isRecord(parsed)) return { status: "invalid", path }; + const legacy = isRecord(parsed.object) ? parsed.object : undefined; + const pins = Array.isArray(parsed.pins) + ? parsed.pins + : Array.isArray(legacy?.pins) + ? legacy.pins + : undefined; + if (!pins) return { status: "invalid", path }; + return { + status: "valid", + path, + hash: new Bun.CryptoHasher("sha256").update(bytes).digest("hex"), + }; + } catch { + return { status: "invalid", path }; + } +} + +function isMissingPathError(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +/** + * Before reading or allowing Xcode to update Package.resolved, prove every + * existing component below the inspected root is a real directory rather than + * a symlink or another file type. This is deliberately stricter than ordinary + * read-path containment, including for symlinks whose destinations remain in + * the project root. + */ +async function validatePackageResolvedPath( + inspectionRoot: string, + containerPath: string, + resolvedPath: string, +): Promise { + const root = resolve(inspectionRoot); + const container = resolve(containerPath); + const candidate = resolve(resolvedPath); + if (!isWithin(root, container) || !isWithin(container, candidate)) { + return { safe: false, detail: "The package lock path is outside the selected container." }; + } + if (!(await pathIsSafelyWithinIOSRoot(root, candidate))) { + return { + safe: false, + detail: "The package lock path resolves outside the inspected project root.", + }; + } + + const segments = relative(root, candidate).split(sep).filter(Boolean); + let current = root; + for (const [index, segment] of segments.entries()) { + current = join(current, segment); + let info; + try { + info = await lstat(current); + } catch (error) { + if (isMissingPathError(error)) return { safe: true, exists: false }; + return { + safe: false, + detail: `An existing package lock parent could not be inspected: ${relativeIOSPath(root, current)}.`, + }; + } + + if (info.isSymbolicLink()) { + return { + safe: false, + detail: `The package lock path contains a symbolic link: ${relativeIOSPath(root, current)}.`, + }; + } + const isLeaf = index === segments.length - 1; + if (!isLeaf && !info.isDirectory()) { + return { + safe: false, + detail: `A package lock parent is not a directory: ${relativeIOSPath(root, current)}.`, + }; + } + if (isLeaf) { + return info.isFile() + ? { safe: true, exists: true } + : { + safe: false, + detail: "Package.resolved is not a regular file.", + }; + } + } + + return { safe: false, detail: "The package lock path could not be verified." }; +} + +async function readSafePackageResolvedSnapshot( + inspectionRoot: string, + containerPath: string, + resolvedPath: string, +): Promise { + const before = await validatePackageResolvedPath(inspectionRoot, containerPath, resolvedPath); + if (!before.safe) return { detail: before.detail }; + if (!before.exists) { + return { snapshot: { status: "missing", path: resolvedPath } }; + } + + const snapshot = await readPackageResolvedSnapshot(resolvedPath); + const after = await validatePackageResolvedPath(inspectionRoot, containerPath, resolvedPath); + if (!after.safe) return { detail: after.detail }; + if (!after.exists || snapshot.status === "missing") { + return { detail: "Package.resolved disappeared while it was being inspected." }; + } + return { snapshot }; +} + +function packageSnapshotChange( + before: PackageResolvedSnapshot, + after: PackageResolvedSnapshot, +): "created" | "updated" | "unchanged" | "removed" | "invalid" { + if (after.status === "invalid") return "invalid"; + if (before.status === "missing" && after.status === "valid") return "created"; + if (before.status === "valid" && after.status === "missing") return "removed"; + if (before.status === "valid" && after.status === "valid") { + return before.hash === after.hash ? "unchanged" : "updated"; + } + if (before.status === after.status) return "unchanged"; + return "invalid"; +} + +function packageSafetyArgs(sourcePackagesPath: string, requireResolvedVersions: boolean): string[] { + return [ + "-clonedSourcePackagesDirPath", + sourcePackagesPath, + "-disableAutomaticPackageResolution", + ...(requireResolvedVersions ? ["-onlyUsePackageVersionsFromResolvedFile"] : []), + "-skipPackageUpdates", + ]; +} + +function parseSchemeNames(output: string, kind: SelectedContainer["kind"]): string[] | undefined { + try { + const parsed: unknown = JSON.parse(output); + if (!isRecord(parsed)) return undefined; + const section = parsed[kind]; + if (!isRecord(section) || !Array.isArray(section.schemes)) return undefined; + const schemes = section.schemes.filter( + (scheme): scheme is string => typeof scheme === "string" && scheme.trim().length > 0, + ); + return [...new Set(schemes)].sort((a, b) => a.localeCompare(b)); + } catch { + return undefined; + } +} + +function decodeXMLAttribute(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function xmlAttribute(source: string, name: string): string | undefined { + const match = source.match(new RegExp(`\\b${name}\\s*=\\s*(["'])(.*?)\\1`, "s")); + return match?.[2] == null ? undefined : decodeXMLAttribute(match[2]); +} + +function referenceMatchesTarget(attributes: string, target: IOSAppTarget): boolean { + if (xmlAttribute(attributes, "BlueprintIdentifier") !== target.id) return false; + const referencedContainer = xmlAttribute(attributes, "ReferencedContainer") + ?.replace(/^container:/, "") + .replaceAll("\\", "/"); + return ( + referencedContainer == null || + target.projectPath.endsWith(referencedContainer) || + referencedContainer.endsWith(target.projectPath) || + basename(referencedContainer) === basename(target.projectPath) + ); +} + +async function sharedSchemesReferencingTarget( + inspection: IOSProjectInspectionResult, + container: SelectedContainer, + target: IOSAppTarget, +): Promise> { + const directories = new Set([ + join(container.absolutePath, "xcshareddata", "xcschemes"), + join(resolve(inspection.root, target.projectPath), "xcshareddata", "xcschemes"), + ]); + const schemes = new Set(); + for (const directory of directories) { + if (!(await pathIsSafelyWithinIOSRoot(inspection.root, directory))) continue; + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries.slice(0, 100)) { + if (!entry.isFile() || !entry.name.endsWith(".xcscheme")) continue; + const path = join(directory, entry.name); + let info; + try { + info = await lstat(path); + } catch { + continue; + } + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_SCHEME_BYTES) continue; + let xml: string; + try { + xml = await readFile(path, "utf8"); + } catch { + continue; + } + const buildActions = [...xml.matchAll(/]*>([\s\S]*?)<\/BuildAction>/g)]; + const referencesTarget = buildActions.some((buildAction) => + [...(buildAction[1] ?? "").matchAll(/]*)>/g)].some((reference) => + referenceMatchesTarget(reference[1] ?? "", target), + ), + ); + if (referencesTarget) schemes.add(basename(entry.name, ".xcscheme")); + } + } + return schemes; +} + +async function chooseScheme( + inspection: IOSProjectInspectionResult, + container: SelectedContainer, + target: IOSAppTarget, + requested: string | undefined, + available: string[], +): Promise< + { scheme: string; blueprintProven: boolean; result: CheckResult } | { result: CheckResult } +> { + const shared = await sharedSchemesReferencingTarget(inspection, container, target); + if (requested) { + if (!available.includes(requested)) { + return { + result: fail( + "Xcode scheme", + `Scheme ${requested} is not available in ${container.relativePath}`, + "Pass --scheme with one of the schemes reported by Xcode.", + available.slice(0, 30).join("\n"), + ), + }; + } + return { + scheme: requested, + blueprintProven: shared.has(requested), + result: pass("Xcode scheme", `Selected ${requested}`), + }; + } + + const targetNameIsUnique = + inspection.appTargets.filter( + (candidate) => candidate.projectPath === target.projectPath && candidate.name === target.name, + ).length === 1; + if (targetNameIsUnique && available.includes(target.name)) { + return { + scheme: target.name, + blueprintProven: shared.has(target.name), + result: pass("Xcode scheme", `Selected ${target.name}`), + }; + } + + const provenAvailable = available.filter((scheme) => shared.has(scheme)); + if (provenAvailable.length === 1) { + return { + scheme: provenAvailable[0]!, + blueprintProven: true, + result: pass("Xcode scheme", `Selected ${provenAvailable[0]}`), + }; + } + if (available.length === 1 && targetNameIsUnique) { + return { + scheme: available[0]!, + blueprintProven: shared.has(available[0]!), + result: pass("Xcode scheme", `Selected ${available[0]}`), + }; + } + + return { + result: fail( + "Xcode scheme", + "No single build scheme can be selected safely for the iOS target", + "Pass --scheme after confirming which scheme builds the selected application target.", + available.slice(0, 30).join("\n"), + ), + }; +} + +function stringValue(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +async function verifyBuildSettings( + output: string, + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, + blueprintProven: boolean, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return undefined; + } + if (!Array.isArray(parsed)) return undefined; + + const duplicateTargetName = + inspection.appTargets.filter( + (candidate) => candidate.projectPath === target.projectPath && candidate.name === target.name, + ).length > 1; + if (duplicateTargetName && !blueprintProven) return undefined; + + const expectedProject = resolve(inspection.root, target.projectPath); + let expectedRealProject = expectedProject; + try { + expectedRealProject = await realpath(expectedProject); + } catch {} + + const matches: Record[] = []; + for (const entry of parsed) { + if (!isRecord(entry) || !isRecord(entry.buildSettings)) continue; + const settings = entry.buildSettings; + const targetName = stringValue(settings, "TARGET_NAME") ?? stringValue(entry, "target"); + const projectFile = stringValue(settings, "PROJECT_FILE_PATH"); + const productType = stringValue(settings, "PRODUCT_TYPE"); + if (targetName !== target.name || productType !== APP_PRODUCT_TYPE || !projectFile) continue; + let actualProject = resolve(projectFile); + try { + actualProject = await realpath(actualProject); + } catch {} + if (actualProject !== expectedRealProject) continue; + matches.push(settings); + } + if (matches.length !== 1) return undefined; + const settings = matches[0]!; + return { + targetBuildDir: stringValue(settings, "TARGET_BUILD_DIR"), + fullProductName: stringValue(settings, "FULL_PRODUCT_NAME"), + bundleIdentifier: stringValue(settings, "PRODUCT_BUNDLE_IDENTIFIER"), + }; +} + +function parseSimulatorDevices(output: string): SimulatorDevice[] | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return undefined; + } + if (!isRecord(parsed) || !isRecord(parsed.devices)) return undefined; + const devices: SimulatorDevice[] = []; + for (const [runtimeIdentifier, values] of Object.entries(parsed.devices)) { + if (!runtimeIdentifier.includes(".SimRuntime.iOS-") || !Array.isArray(values)) continue; + const runtimeVersion = runtimeIdentifier.split(".SimRuntime.iOS-")[1]?.replaceAll("-", "."); + const runtime = runtimeVersion ? `iOS ${runtimeVersion}` : "iOS"; + for (const value of values) { + if (!isRecord(value) || value.isAvailable === false) continue; + const name = stringValue(value, "name"); + const udid = stringValue(value, "udid"); + const state = stringValue(value, "state"); + if (name && udid && state) devices.push({ name, udid, state, runtime }); + } + } + return devices.sort((a, b) => a.runtime.localeCompare(b.runtime) || a.name.localeCompare(b.name)); +} + +function selectSimulatorDevice( + devices: SimulatorDevice[], + requested: string | undefined, +): { device?: SimulatorDevice; result?: CheckResult } { + if (requested?.trim()) { + const value = requested.trim(); + const udidMatches = devices.filter( + (device) => device.udid.toUpperCase() === value.toUpperCase(), + ); + const matches = + udidMatches.length > 0 ? udidMatches : devices.filter((device) => device.name === value); + if (matches.length === 1) return { device: matches[0] }; + if (matches.length > 1) { + return { + result: fail( + "iOS Simulator", + `More than one available simulator is named ${value}`, + "Pass --device with the exact simulator UDID.", + matches.map((device) => `${device.name} (${device.runtime}) ${device.udid}`).join("\n"), + ), + }; + } + return { + result: fail( + "iOS Simulator", + `No available iOS simulator matches ${value}`, + "Pass --device with an available simulator UDID from `xcrun simctl list devices available`.", + ), + }; + } + + const booted = devices.filter((device) => device.state === "Booted"); + if (booted.length === 1) return { device: booted[0] }; + return { + result: fail( + "iOS Simulator", + booted.length === 0 + ? "No single booted iOS simulator can be selected safely" + : "More than one iOS simulator is booted", + "Pass --device with the exact simulator UDID.", + booted.map((device) => `${device.name} (${device.runtime}) ${device.udid}`).join("\n"), + ), + }; +} + +function isWithin(root: string, path: string): boolean { + const rel = relative(resolve(root), resolve(path)); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +async function validateBuiltApplication( + derivedDataPath: string, + settings: VerifiedBuildSettings, +): Promise<{ appPath?: string; bundleIdentifier?: string; result?: CheckResult }> { + if (!settings.targetBuildDir || !settings.fullProductName || !settings.bundleIdentifier) { + return { + result: fail( + "iOS Simulator", + "Xcode did not provide an unambiguous application path and Bundle ID", + "Run the verified scheme from Xcode and inspect its build settings.", + ), + }; + } + const appPath = resolve(settings.targetBuildDir, settings.fullProductName); + if (!settings.fullProductName.endsWith(".app") || !isWithin(derivedDataPath, appPath)) { + return { + result: fail( + "iOS Simulator", + "The built application path is outside the isolated doctor build directory", + "Run the selected scheme directly in Xcode; doctor will not install this product.", + ), + }; + } + try { + const info = await lstat(appPath); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("not a local app directory"); + const [realDerivedData, realApp] = await Promise.all([ + realpath(derivedDataPath), + realpath(appPath), + ]); + if (!isWithin(realDerivedData, realApp)) throw new Error("external app directory"); + } catch { + return { + result: fail( + "iOS Simulator", + "The expected simulator application was not produced safely", + "Open Xcode's build log for the selected scheme.", + ), + }; + } + return { appPath, bundleIdentifier: settings.bundleIdentifier }; +} + +function resolvedTargetBundleIdentifiers(target: IOSAppTarget): string[] { + return [ + ...new Set( + target.configurations.flatMap((configuration) => + configuration.bundleIdentifier.state === "resolved" + ? [configuration.bundleIdentifier.value] + : [], + ), + ), + ].sort(); +} + +async function makeDefaultTemporaryDirectory(): Promise { + return mkdtemp(join(tmpdir(), "clerk-doctor-ios-")); +} + +async function removeDefaultTemporaryDirectory(path: string): Promise { + await rm(path, { recursive: true, force: true }); +} + +/** + * Runs only the explicitly requested Xcode verification phases. The supplied + * inspection must already have one selected application target; no project, + * package, scheme, simulator, or signing state is guessed or repaired here. + */ +export async function runIOSXcodeVerification( + inspection: IOSProjectInspectionResult, + options: IOSXcodeVerificationOptions, + dependencies: IOSXcodeVerificationDependencies = {}, +): Promise { + const requested = + options.resolvePackages === true || options.build === true || options.simulator === true; + if (!requested) return []; + + if (options.device && !options.simulator) { + return [ + fail( + "iOS Simulator", + "--device requires --simulator", + "Drop --device, or add --simulator to install and launch the verified build.", + ), + ]; + } + + const targetResolution = targetFromInspection(inspection); + if (!targetResolution.target) return [targetResolution.result!]; + const target = targetResolution.target; + + const containerResolution = await selectContainer(inspection, target, options.container); + if (!containerResolution.container) return [containerResolution.result!]; + const container = containerResolution.container; + const results: CheckResult[] = [pass("Xcode container", `Using ${container.relativePath}`)]; + const hasRemotePackages = containerHasRemotePackages(inspection, container, target); + const resolvedPath = packageResolvedPath(container); + const initialLockRead = await readSafePackageResolvedSnapshot( + inspection.root, + container.absolutePath, + resolvedPath, + ); + if (!initialLockRead.snapshot) { + results.push( + fail( + "Swift packages", + "The shared Package.resolved path is unsafe to inspect or update", + "Replace symbolic links in the package lock path with regular directories before running Xcode verification.", + initialLockRead.detail, + ), + ); + return results; + } + let lockSnapshot = initialLockRead.snapshot; + if (lockSnapshot.status === "invalid") { + results.push( + fail( + "Swift packages", + "The existing shared Package.resolved is invalid or unsafe to use", + "Replace it with a regular valid Package.resolved, or remove it manually after reviewing the package graph, then rerun the command.", + relativeIOSPath(inspection.root, resolvedPath), + ), + ); + return results; + } + if (!options.resolvePackages && hasRemotePackages && lockSnapshot.status === "missing") { + results.push( + fail( + "Swift packages", + "Remote Swift packages are not recorded in a shared Package.resolved", + "Rerun with --resolve-packages --build after reviewing the package requirements.", + ), + ); + return results; + } + + if ((dependencies.platform ?? process.platform) !== "darwin") { + results.push( + fail( + "Xcode toolchain", + "Xcode verification requires macOS", + "Run this command on a Mac with Xcode installed.", + ), + ); + return results; + } + + const runner = dependencies.runner ?? runIOSXcodeCommand; + const xcrun = dependencies.xcrunPath ?? XCRUN; + const env = createIOSXcodeChildEnvironment(dependencies.environment ?? process.env); + const run = async (argv: readonly string[], timeoutMs: number, maxOutputBytes?: number) => + runner(argv, { + cwd: inspection.root, + env, + timeoutMs, + ...(maxOutputBytes ? { maxOutputBytes } : {}), + }); + + const toolchain = await run([xcrun, "xcodebuild", "-version"], TOOLCHAIN_TIMEOUT_MS); + if (!isCommandSuccess(toolchain)) { + results.push( + commandFailure( + "Xcode toolchain", + "Xcode discovery", + toolchain, + "Install Xcode, accept its license, and select it with xcode-select.", + ), + ); + return results; + } + const version = sanitizeIOSXcodeDiagnostic(toolchain.stdout).split("\n")[0]; + results.push(pass("Xcode toolchain", version || "Xcode is available")); + + const customTemp = dependencies.makeTemporaryDirectory != null; + const makeTemporaryDirectory = + dependencies.makeTemporaryDirectory ?? makeDefaultTemporaryDirectory; + const removeTemporaryDirectory = + dependencies.removeTemporaryDirectory ?? + (customTemp ? async () => {} : removeDefaultTemporaryDirectory); + let temporaryDirectory: string; + try { + temporaryDirectory = await makeTemporaryDirectory(); + } catch (error) { + results.push( + fail( + "Xcode temporary files", + "Could not create an isolated Xcode build directory", + "Check the system temporary-directory permissions.", + errorMessage(error), + ), + ); + return results; + } + + const sourcePackagesPath = join(temporaryDirectory, "SourcePackages"); + const derivedDataPath = join(temporaryDirectory, "DerivedData"); + const containerArgs = [container.flag, container.absolutePath]; + + try { + if (options.resolvePackages) { + const before = lockSnapshot; + const resolution = await run( + [ + xcrun, + "xcodebuild", + ...containerArgs, + "-resolvePackageDependencies", + "-clonedSourcePackagesDirPath", + sourcePackagesPath, + "-quiet", + ], + RESOLUTION_TIMEOUT_MS, + ); + if (!isCommandSuccess(resolution)) { + results.push( + commandFailure( + "Swift packages", + "Swift package resolution", + resolution, + "Resolve packages in Xcode, then rerun clerk doctor.", + ), + ); + return results; + } + const resolvedLockRead = await readSafePackageResolvedSnapshot( + inspection.root, + container.absolutePath, + resolvedPath, + ); + if (!resolvedLockRead.snapshot) { + results.push( + fail( + "Swift packages", + "Xcode left the shared Package.resolved path unsafe", + "Inspect the package lock path without following symbolic links before continuing.", + resolvedLockRead.detail, + ), + ); + return results; + } + lockSnapshot = resolvedLockRead.snapshot; + if (hasRemotePackages && lockSnapshot.status !== "valid") { + results.push( + fail( + "Swift packages", + "Xcode completed without producing a valid shared Package.resolved", + "Open the selected container in Xcode and resolve its package graph.", + ), + ); + return results; + } + const change = packageSnapshotChange(before, lockSnapshot); + if (change === "invalid" || change === "removed") { + results.push( + fail( + "Swift packages", + `Package.resolved became ${change} during resolution`, + "Inspect the package-resolution changes before building.", + ), + ); + return results; + } + results.push( + pass( + "Swift packages", + `Package resolution completed; Package.resolved ${change}`, + relativeIOSPath(inspection.root, resolvedPath), + ), + ); + } else if (hasRemotePackages && lockSnapshot.status !== "valid") { + results.push( + fail( + "Swift packages", + lockSnapshot.status === "missing" + ? "Remote Swift packages are not recorded in a shared Package.resolved" + : "The shared Package.resolved is not safe to use", + "Rerun with --resolve-packages --build after reviewing the package requirements.", + ), + ); + return results; + } else if (hasRemotePackages) { + results.push(pass("Swift packages", "Remote Swift packages are locked")); + } else { + results.push(pass("Swift packages", "No remote Swift package lock is required")); + } + + const buildRequested = options.build === true || options.simulator === true; + if (!buildRequested) return results; + + const safetyArgs = packageSafetyArgs(sourcePackagesPath, hasRemotePackages); + const listResult = await run( + [xcrun, "xcodebuild", ...containerArgs, "-list", "-json", ...safetyArgs], + DISCOVERY_TIMEOUT_MS, + JSON_OUTPUT_LIMIT_BYTES, + ); + if (!isCommandSuccess(listResult) || listResult.truncated) { + results.push( + commandFailure( + "Xcode scheme", + "Scheme discovery", + listResult, + "Open the selected container in Xcode and confirm its shared or automatic schemes.", + ), + ); + return results; + } + const availableSchemes = parseSchemeNames(listResult.stdout, container.kind); + if (!availableSchemes || availableSchemes.length === 0) { + results.push( + fail( + "Xcode scheme", + "Xcode did not report any build schemes for the selected container", + "Share the application scheme or enable automatic scheme creation in Xcode.", + ), + ); + return results; + } + const schemeResolution = await chooseScheme( + inspection, + container, + target, + options.scheme, + availableSchemes, + ); + if (!("scheme" in schemeResolution)) { + results.push(schemeResolution.result); + return results; + } + const scheme = schemeResolution.scheme; + + const destination = "generic/platform=iOS Simulator"; + const buildBaseArgs = [ + ...containerArgs, + "-scheme", + scheme, + "-destination", + destination, + "-derivedDataPath", + derivedDataPath, + ...safetyArgs, + "CODE_SIGNING_ALLOWED=NO", + ]; + const settingsResult = await run( + [xcrun, "xcodebuild", ...buildBaseArgs, "-showBuildSettings", "-json"], + DISCOVERY_TIMEOUT_MS, + JSON_OUTPUT_LIMIT_BYTES, + ); + if (!isCommandSuccess(settingsResult) || settingsResult.truncated) { + results.push( + commandFailure( + "Xcode scheme", + "Scheme validation", + settingsResult, + "Open the selected scheme in Xcode and confirm it builds the selected application target.", + ), + ); + return results; + } + const buildSettings = await verifyBuildSettings( + settingsResult.stdout, + inspection, + target, + schemeResolution.blueprintProven, + ); + if (!buildSettings) { + results.push( + fail( + "Xcode scheme", + `Scheme ${scheme} could not be proven to build the selected application target`, + "Pass --scheme with a scheme whose application target matches --target.", + ), + ); + return results; + } + results.push(schemeResolution.result); + + const beforeBuildRead = await readSafePackageResolvedSnapshot( + inspection.root, + container.absolutePath, + resolvedPath, + ); + if (!beforeBuildRead.snapshot) { + results.push( + fail( + "Xcode build", + "The Package.resolved path became unsafe before the frozen build", + "Inspect the package lock path without following symbolic links before continuing.", + beforeBuildRead.detail, + ), + ); + return results; + } + const beforeBuildLock = beforeBuildRead.snapshot; + const preBuildLockChange = packageSnapshotChange(lockSnapshot, beforeBuildLock); + if (preBuildLockChange !== "unchanged") { + results.push( + fail( + "Xcode build", + `Package.resolved became ${preBuildLockChange} before the frozen build`, + "Review the package change and rerun doctor from a stable checkout.", + ), + ); + return results; + } + const buildResult = await run( + [xcrun, "xcodebuild", ...buildBaseArgs, "-quiet", "build"], + BUILD_TIMEOUT_MS, + DEFAULT_OUTPUT_LIMIT_BYTES, + ); + if (!isCommandSuccess(buildResult)) { + results.push( + commandFailure( + "Xcode build", + "iOS Simulator build", + buildResult, + "Open the selected scheme's build log in Xcode and fix the reported compilation error.", + ), + ); + return results; + } + const afterBuildRead = await readSafePackageResolvedSnapshot( + inspection.root, + container.absolutePath, + resolvedPath, + ); + if (!afterBuildRead.snapshot) { + results.push( + fail( + "Xcode build", + "The frozen build left the Package.resolved path unsafe", + "Inspect the package lock path without following symbolic links before continuing.", + afterBuildRead.detail, + ), + ); + return results; + } + const afterBuildLock = afterBuildRead.snapshot; + const lockChange = packageSnapshotChange(beforeBuildLock, afterBuildLock); + if (lockChange !== "unchanged") { + results.push( + fail( + "Xcode build", + `The frozen build unexpectedly left Package.resolved ${lockChange}`, + "Review the package change; doctor will not restore or continue from unexpected Xcode mutations.", + ), + ); + return results; + } + results.push(pass("Xcode build", `Scheme ${scheme} built for iOS Simulator`)); + + if (!options.simulator) return results; + + if ( + target.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "process-info-environment", + ) + ) { + results.push( + fail( + "iOS Simulator", + "The app requires its Xcode Run-scheme Clerk environment variable", + `Run scheme ${scheme} from Xcode. simctl launch does not safely reproduce arbitrary LaunchAction environment settings.`, + ), + ); + return results; + } + + const application = await validateBuiltApplication(derivedDataPath, buildSettings); + if (!application.appPath || !application.bundleIdentifier) { + results.push(application.result!); + return results; + } + const inspectedBundleIdentifiers = resolvedTargetBundleIdentifiers(target); + if ( + inspectedBundleIdentifiers.length === 1 && + inspectedBundleIdentifiers[0] !== application.bundleIdentifier + ) { + results.push( + fail( + "iOS Simulator", + "The built application's Bundle ID differs from the inspected target", + "Review the scheme configuration and target build settings in Xcode.", + ), + ); + return results; + } + + const simulatorList = await run( + [xcrun, "simctl", "list", "devices", "available", "--json"], + SIMULATOR_LIST_TIMEOUT_MS, + JSON_OUTPUT_LIMIT_BYTES, + ); + if (!isCommandSuccess(simulatorList) || simulatorList.truncated) { + results.push( + commandFailure( + "iOS Simulator", + "Simulator discovery", + simulatorList, + "Open Simulator.app and pass --device with an available iOS simulator UDID.", + ), + ); + return results; + } + const devices = parseSimulatorDevices(simulatorList.stdout); + if (!devices) { + results.push( + fail( + "iOS Simulator", + "CoreSimulator returned malformed device information", + "Run `xcrun simctl list devices available` and verify CoreSimulator is healthy.", + ), + ); + return results; + } + const deviceResolution = selectSimulatorDevice(devices, options.device); + if (!deviceResolution.device) { + results.push(deviceResolution.result!); + return results; + } + const device = deviceResolution.device; + + const boot = await run( + [xcrun, "simctl", "bootstatus", device.udid, "-b"], + SIMULATOR_BOOT_TIMEOUT_MS, + ); + if (!isCommandSuccess(boot)) { + results.push( + commandFailure( + "iOS Simulator", + "Simulator boot", + boot, + "Open Simulator.app, boot the selected device, and rerun the command.", + ), + ); + return results; + } + + const install = await run( + [xcrun, "simctl", "install", device.udid, application.appPath], + SIMULATOR_OPERATION_TIMEOUT_MS, + ); + if (!isCommandSuccess(install)) { + results.push( + commandFailure( + "iOS Simulator", + "Application install", + install, + "Inspect the selected simulator and the built app product in Xcode.", + ), + ); + return results; + } + + const launch = await run( + [xcrun, "simctl", "launch", device.udid, application.bundleIdentifier], + SIMULATOR_OPERATION_TIMEOUT_MS, + ); + if (!isCommandSuccess(launch)) { + results.push( + commandFailure( + "iOS Simulator", + "Application launch", + launch, + "Open Simulator.app and launch the installed application manually.", + ), + ); + return results; + } + results.push( + pass( + "iOS Simulator", + `Launched ${application.bundleIdentifier} on ${device.name} (${device.runtime})`, + "Manually verify sign-in, sign-out, app relaunch, and every redirect-based method you enabled.", + ), + ); + return results; + } catch (error) { + results.push( + fail( + "Xcode verification", + "The optional Xcode verification could not complete", + "Rerun with --verbose, or run the selected scheme directly in Xcode.", + errorMessage(error), + ), + ); + return results; + } finally { + try { + await removeTemporaryDirectory(temporaryDirectory); + } catch (error) { + results.push( + warn( + "Xcode temporary files", + "The isolated Xcode build directory could not be removed", + `Remove ${temporaryDirectory} after confirming no build is still running.`, + errorMessage(error), + ), + ); + } + } +} diff --git a/packages/cli-core/src/commands/doctor/ios.test.ts b/packages/cli-core/src/commands/doctor/ios.test.ts new file mode 100644 index 000000000..cdc422a88 --- /dev/null +++ b/packages/cli-core/src/commands/doctor/ios.test.ts @@ -0,0 +1,559 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createIOSFixture } from "../init/ios/test-helpers.ts"; +import { PlapiError } from "../../lib/errors.ts"; +import type { UserSettingsJSON } from "../../lib/fapi.ts"; +import type { Application } from "../../lib/plapi.ts"; +import { auditIOSPrebuiltAuthEnvironment } from "../init/ios/prebuilt-auth-environment.ts"; +import type { DoctorContext, ResolvedProfile } from "./types.ts"; +import { runIOSDoctorChecks, type IOSDoctorDependencies } from "./ios.ts"; + +const roots: string[] = []; + +function publishableKey(host: string, live = false): string { + return `${live ? "pk_live_" : "pk_test_"}${Buffer.from(`${host}$`).toString("base64")}`; +} + +function context(): DoctorContext { + const profile: ResolvedProfile = { + path: "fixture", + resolvedVia: "directory", + profile: { + workspaceId: "org_test", + appId: "app_test", + instances: { development: "ins_test" }, + }, + }; + const noopFix = () => ({ label: "noop", run: async () => {} }); + return { + hasPlatformAPIKey: () => false, + hasAccountCredentials: async () => true, + getToken: async () => "oauth-token", + getValidToken: async () => "oauth-token", + getProfile: async () => profile, + getApplication: async () => null, + getKeylessTarget: async () => undefined, + getKeylessInstance: async () => null, + getKeylessKeyError: async () => undefined, + hasClaimBreadcrumb: async () => false, + fixes: { login: noopFix, link: noopFix, envPull: noopFix }, + }; +} + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-doctor-")); + roots.push(root); + await createIOSFixture(root, options); + return root; +} + +async function addAppleEntitlement(root: string): Promise { + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await readFile(entitlementsPath, "utf8"); + await writeFile( + entitlementsPath, + entitlements.replace( + "", + "com.apple.developer.applesigninDefault", + ), + ); +} + +function dependencies(overrides: Partial = {}): IOSDoctorDependencies { + const application: Application = { + application_id: "app_test", + instances: [ + { + instance_id: "ins_test", + environment_type: "development", + publishable_key: publishableKey("clerk.example.test"), + }, + ], + }; + return { + inspectIOSProject: + overrides.inspectIOSProject ?? + (async (...args) => { + const { inspectIOSProject } = await import("../init/ios/inspect.ts"); + return inspectIOSProject(...args); + }), + fetchApplication: overrides.fetchApplication ?? (async () => application), + getNativeSettings: + overrides.getNativeSettings ?? + (async () => ({ object: "native_settings", api_enabled: true })), + listIOSApplications: + overrides.listIOSApplications ?? + (async () => [ + { + object: "ios_application", + id: "iosapp_test", + app_id_prefix: "LEGACY1234", + bundle_id: "com.example.MyApp", + created_at: 1, + updated_at: 1, + }, + ]), + fetchUserSettings: + overrides.fetchUserSettings ?? (async () => ({ social: {} }) as UserSettingsJSON), + auditIOSPrebuiltAuthEnvironment: + overrides.auditIOSPrebuiltAuthEnvironment ?? auditIOSPrebuiltAuthEnvironment, + planIOSAppleEntitlement: + overrides.planIOSAppleEntitlement ?? + (async () => { + throw new Error("Apple planner should not run without a local entitlement"); + }), + auditIOSNativeAppleHealth: + overrides.auditIOSNativeAppleHealth ?? + (async () => { + throw new Error("Apple remote audit should not run without a local entitlement"); + }), + }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe("runIOSDoctorChecks", () => { + test("uses semantic iOS checks instead of web environment checks", async () => { + const root = await fixture(); + const audit = await runIOSDoctorChecks(context(), { root, target: "MyApp" }, dependencies()); + + expect( + audit.results.some((result) => result.name === "iOS: Select the iOS application target"), + ).toBeTrue(); + expect(audit.results.some((result) => result.name === "Environment variables")).toBeFalse(); + expect(audit.results.find((result) => result.name === "iOS: Native Application")?.status).toBe( + "pass", + ); + }); + + test("reports missing Native API and registration as a fixable init requirement", async () => { + const root = await fixture(); + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + getNativeSettings: async () => ({ object: "native_settings", api_enabled: false }), + listIOSApplications: async () => [], + }), + ); + + const remote = audit.results.find((result) => result.name === "iOS: Native Application"); + expect(remote?.status).toBe("fail"); + expect(remote?.message).toContain("setup required"); + expect(remote?.detail).toContain("Register iOS Bundle ID"); + expect(remote?.remedy).toContain("clerk init"); + }); + + test("does not call remote endpoints until one target is selected", async () => { + const root = await fixture({ secondTarget: true }); + let remoteCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root }, + dependencies({ + getNativeSettings: async () => { + remoteCalls++; + return { object: "native_settings", api_enabled: true }; + }, + listIOSApplications: async () => { + remoteCalls++; + return []; + }, + }), + ); + + expect(remoteCalls).toBe(0); + expect( + audit.results.find((result) => result.name === "iOS: Select the iOS application target") + ?.status, + ).toBe("fail"); + expect( + audit.results.find((result) => result.name === "iOS: Native Application")?.message, + ).toContain("select one iOS target"); + }); + + test("keeps a conflicting local Bundle ID failure when remote state is unavailable", async () => { + const root = await fixture({ conflictingBundle: true }); + let remoteCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + getNativeSettings: async () => { + remoteCalls++; + throw new Error("should not inspect remote state"); + }, + listIOSApplications: async () => { + remoteCalls++; + throw new Error("should not inspect remote state"); + }, + }), + ); + + expect(remoteCalls).toBe(0); + const registration = audit.results.find( + (result) => result.name === "iOS: Register the iOS app in Clerk Dashboard", + ); + expect(registration?.status).toBe("fail"); + expect(registration?.message).toContain("blocked"); + expect(registration?.detail).toContain("single Bundle ID"); + }); + + test("fails locally on conflicting literal App ID Prefix evidence without remote reads", async () => { + const root = await fixture(); + let remoteCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + inspectIOSProject: async (...args) => { + const { inspectIOSProject } = await import("../init/ios/inspect.ts"); + const inspection = await inspectIOSProject(...args); + return { + ...inspection, + appTargets: inspection.appTargets.map((target) => ({ + ...target, + configurations: target.configurations.map((configuration, index) => ({ + ...configuration, + ...(configuration.entitlements + ? { + entitlements: { + ...configuration.entitlements, + literalAppIdentifierPrefix: index === 0 ? "LEGACY1234" : "OTHER12345", + }, + } + : {}), + })), + })), + }; + }, + getNativeSettings: async () => { + remoteCalls++; + throw new Error("should not inspect remote state"); + }, + listIOSApplications: async () => { + remoteCalls++; + throw new Error("should not inspect remote state"); + }, + }), + ); + + expect(remoteCalls).toBe(0); + const prefix = audit.results.find((result) => result.name === "iOS: App ID Prefix evidence"); + expect(prefix?.status).toBe("fail"); + expect(prefix?.message).toContain("conflicting values"); + expect(JSON.stringify(audit.results)).not.toContain("OTHER12345"); + }); + + test("redacts keys while diagnosing a linked development-key mismatch", async () => { + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + const secret = "sk_test_must_never_escape"; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchApplication: async () => ({ + application_id: "app_test", + instances: [ + { + instance_id: "ins_test", + environment_type: "development", + publishable_key: publishableKey("native.clerk.example"), + secret_key: secret, + }, + ], + }), + }), + ); + + const key = audit.results.find((result) => result.name === "iOS: Linked development key"); + expect(key?.status).toBe("fail"); + expect(key?.message).toContain("different Clerk instance"); + expect(JSON.stringify(audit.results)).not.toContain("pk_"); + expect(JSON.stringify(audit.results)).not.toContain(secret); + }); + + test("does not use an available-only key candidate to inspect AuthView methods", async () => { + const root = await fixture({ complete: true }); + let environmentCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + environmentCalls++; + return { social: {} } as UserSettingsJSON; + }, + }), + ); + + expect(environmentCalls).toBe(0); + const methods = audit.results.find( + (result) => result.name === "iOS: AuthView authentication methods", + ); + expect(methods?.status).toBe("warn"); + expect(methods?.message).toContain("runtime key was not proven"); + }); + + test("fails when AuthView offers Apple but the selected target lacks its entitlement", async () => { + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + let environmentCalls = 0; + let appleHealthCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + environmentCalls++; + return { + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + private_key: "apple-private-material-must-not-escape", + }, + }, + } as unknown as UserSettingsJSON; + }, + auditIOSNativeAppleHealth: async () => { + appleHealthCalls++; + throw new Error("Apple health must wait for the local entitlement"); + }, + }), + ); + + expect(environmentCalls).toBe(1); + expect(appleHealthCalls).toBe(0); + const methods = audit.results.find( + (result) => result.name === "iOS: AuthView authentication methods", + ); + expect(methods?.status).toBe("fail"); + expect(methods?.message).toContain("lacks its entitlement"); + expect(methods?.remedy).toContain("--sign-in-with-apple"); + expect(JSON.stringify(audit.results)).not.toContain("apple-private-material-must-not-escape"); + }); + + test("audits public AuthView methods even before the local project is linked", async () => { + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + let environmentCalls = 0; + let nativeCalls = 0; + const unlinkedContext: DoctorContext = { + ...context(), + getProfile: async () => undefined, + }; + const audit = await runIOSDoctorChecks( + unlinkedContext, + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + environmentCalls++; + return { + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + } as unknown as UserSettingsJSON; + }, + getNativeSettings: async () => { + nativeCalls++; + return { object: "native_settings", api_enabled: true }; + }, + listIOSApplications: async () => { + nativeCalls++; + return []; + }, + }), + ); + + expect(environmentCalls).toBe(1); + expect(nativeCalls).toBe(0); + expect( + audit.results.find((result) => result.name === "iOS: AuthView authentication methods") + ?.status, + ).toBe("fail"); + expect(audit.results.find((result) => result.name === "iOS: Native Application")?.status).toBe( + "warn", + ); + }); + + test("does not infer AuthView from a custom native authentication call", async () => { + const root = await fixture(); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import SwiftUI +import ClerkKit + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Hello") } } + func signIn() async throws { try await Clerk.shared.signInWithApple() } +} +`, + ); + let environmentCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + environmentCalls++; + return { social: {} } as UserSettingsJSON; + }, + }), + ); + + expect(environmentCalls).toBe(0); + expect( + audit.results.some((result) => result.name === "iOS: AuthView authentication methods"), + ).toBeFalse(); + }); + + test("warns without leaking transport details when AuthView settings are unavailable", async () => { + const root = await fixture({ complete: true, includeKey: false, localSecrets: true }); + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + fetchUserSettings: async () => { + throw new TypeError("fetch failed with token authview-transport-secret"); + }, + }), + ); + + const methods = audit.results.find( + (result) => result.name === "iOS: AuthView authentication methods", + ); + expect(methods?.status).toBe("warn"); + expect(methods?.message).toContain("could not be inspected"); + expect(JSON.stringify(audit.results)).not.toContain("authview-transport-secret"); + }); + + test("audits the Clerk Apple connection only when the local entitlement is present", async () => { + const root = await fixture(); + await addAppleEntitlement(root); + let appleCalls = 0; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => { + appleCalls++; + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "satisfied", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + blockers: [], + }, + automation: { status: "supported", configVersion: "v1_12345678", blockers: [] }, + }; + }, + }), + ); + + expect(appleCalls).toBe(1); + expect( + audit.results.find((result) => result.name === "iOS: Sign in with Apple entitlement")?.status, + ).toBe("pass"); + expect( + audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple")?.status, + ).toBe("pass"); + }); + + test("classifies access errors without exposing the API response body", async () => { + const root = await fixture(); + const sensitiveBody = '{"errors":[{"message":"Bearer secret-token-value"}]}'; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + getNativeSettings: async () => { + throw new PlapiError(403, sensitiveBody); + }, + }), + ); + + const remote = audit.results.find((result) => result.name === "iOS: Native Application"); + expect(remote?.status).toBe("fail"); + expect(remote?.message).toContain("not permitted"); + expect(remote?.remedy).toContain("applications:read"); + expect(JSON.stringify(audit.results)).not.toContain("secret-token-value"); + }); + + test("fails when a local Apple entitlement has no matching Clerk connection", async () => { + const root = await fixture(); + await addAppleEntitlement(root); + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async ({ applicationId, instanceId, bundleIdentifier }) => ({ + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId, + instanceId, + bundleIdentifier, + runtime: { + status: "required", + connection: "required", + bundleIdentifierConfiguration: "required", + current: { enabled: true, authenticatable: true }, + blockers: [], + }, + automation: { status: "supported", configVersion: "v1_12345678", blockers: [] }, + }), + }), + ); + + const apple = audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple"); + expect(apple?.status).toBe("fail"); + expect(apple?.message).toContain("not bound to the selected Bundle ID"); + }); + + test("fails Apple permission errors without exposing the API response body", async () => { + const root = await fixture(); + await addAppleEntitlement(root); + const sensitiveBody = '{"errors":[{"message":"Bearer apple-secret-value"}]}'; + const audit = await runIOSDoctorChecks( + context(), + { root, target: "MyApp" }, + dependencies({ + planIOSAppleEntitlement: async (options) => { + const { planIOSAppleEntitlement } = await import("../init/ios/apple-entitlement.ts"); + return planIOSAppleEntitlement(options); + }, + auditIOSNativeAppleHealth: async () => { + throw new PlapiError(403, sensitiveBody); + }, + }), + ); + + const apple = audit.results.find((result) => result.name === "iOS: Clerk Sign in with Apple"); + expect(apple?.status).toBe("fail"); + expect(apple?.message).toContain("not permitted"); + expect(apple?.remedy).toContain("applications:read"); + expect(JSON.stringify(audit.results)).not.toContain("apple-secret-value"); + }); +}); diff --git a/packages/cli-core/src/commands/doctor/ios.ts b/packages/cli-core/src/commands/doctor/ios.ts new file mode 100644 index 000000000..524bea209 --- /dev/null +++ b/packages/cli-core/src/commands/doctor/ios.ts @@ -0,0 +1,542 @@ +import { decodePublishableKey, fetchUserSettings } from "../../lib/fapi.ts"; +import { CliError, ERROR_CODE, FapiError, isAuthError, PlapiError } from "../../lib/errors.ts"; +import { + fetchApplication, + getNativeSettings, + listIOSApplications, + type Application, + type IOSApplication, + type NativeSettings, +} from "../../lib/plapi.ts"; +import { planIOSAppleEntitlement } from "../init/ios/apple-entitlement.ts"; +import { auditIOSPrebuiltAuthEnvironment } from "../init/ios/prebuilt-auth-environment.ts"; +import { inspectIOSProject } from "../init/ios/inspect.ts"; +import { auditIOSNativeAppleHealth } from "../init/ios/native-apple.ts"; +import { buildIOSNativeReadinessAudit } from "../init/ios/native-readiness.ts"; +import { auditIOSNativeRemoteSetup } from "../init/ios/native-remote.ts"; +import { buildIOSSetupPlan } from "../init/ios/plan.ts"; +import type { IOSAppTarget, IOSProjectInspectionResult, IOSSetupStep } from "../init/ios/types.ts"; +import type { CheckResult, DoctorContext } from "./types.ts"; + +const LOCAL_STEP_REMEDY = "Run `clerk init --target ` to safely complete this step."; +const REMOTE_REMEDY = + "Run `clerk init --target ` to preview and apply the missing Native Application setup."; + +export interface IOSDoctorOptions { + root: string; + target?: string; +} + +export interface IOSDoctorDependencies { + inspectIOSProject: typeof inspectIOSProject; + fetchApplication: ( + applicationId: string, + options: { includeSecretKeys: false }, + ) => Promise; + getNativeSettings(applicationId: string, instanceId: string): Promise; + listIOSApplications(applicationId: string, instanceId: string): Promise; + fetchUserSettings: typeof fetchUserSettings; + auditIOSPrebuiltAuthEnvironment: typeof auditIOSPrebuiltAuthEnvironment; + planIOSAppleEntitlement: typeof planIOSAppleEntitlement; + auditIOSNativeAppleHealth: typeof auditIOSNativeAppleHealth; +} + +const defaultDependencies: IOSDoctorDependencies = { + inspectIOSProject, + fetchApplication, + getNativeSettings, + listIOSApplications, + fetchUserSettings, + auditIOSPrebuiltAuthEnvironment, + planIOSAppleEntitlement, + auditIOSNativeAppleHealth, +}; + +function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { + const selection = inspection.selection; + if (selection.state !== "selected") return undefined; + return inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); +} + +async function authViewEnvironmentResult( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, + dependencies: IOSDoctorDependencies, +): Promise { + if (target.swift.authViewReferences.length === 0) return undefined; + + const name = "iOS: AuthView authentication methods"; + const configureStep = buildIOSSetupPlan(inspection).steps.find( + (step) => step.id === "configure-publishable-key", + ); + const fapiHost = inspection.localPublishableKey.frontendApiHost; + if (configureStep?.status !== "satisfied" || !fapiHost) { + return { + name, + status: "warn", + message: "AuthView methods: remote state not inspected (runtime key was not proven)", + remedy: LOCAL_STEP_REMEDY, + }; + } + + try { + const environment = dependencies.auditIOSPrebuiltAuthEnvironment( + await dependencies.fetchUserSettings(fapiHost, {}), + ); + if (environment.apple === "blocked") { + return { + name, + status: "fail", + message: "AuthView methods: Clerk returned an unsupported Apple provider state", + remedy: "Review the Apple connection in Clerk Dashboard, then rerun `clerk doctor`.", + }; + } + if (environment.apple === "not-required") { + return { + name, + status: "pass", + message: "AuthView methods: native Apple sign-in is not currently offered", + }; + } + + const entitlementIsComplete = + target.configurations.length > 0 && + target.configurations.every( + (configuration) => configuration.entitlements?.signInWithApple === true, + ); + return entitlementIsComplete + ? { + name, + status: "pass", + message: "AuthView methods: Apple is enabled and the local entitlement is present", + } + : { + name, + status: "fail", + message: "AuthView offers Apple sign-in but the selected target lacks its entitlement", + remedy: + "Run `clerk init --target --sign-in-with-apple` to preview and safely add the required capability.", + }; + } catch (error) { + const serviceUnavailable = + error instanceof TypeError || (error instanceof FapiError && error.status >= 500); + if (serviceUnavailable) { + return { + name, + status: "warn", + message: "AuthView methods: Frontend API state could not be inspected", + remedy: "Check your network connection, then rerun `clerk doctor`.", + }; + } + const malformed = error instanceof CliError && error.code === ERROR_CODE.FAPI_ERROR; + return { + name, + status: "fail", + message: malformed + ? "AuthView methods: Clerk returned malformed Frontend API settings" + : "AuthView methods: the configured Frontend API environment is unavailable", + remedy: + "Verify the selected target's development publishable key and Clerk instance, then rerun `clerk doctor`.", + }; + } +} + +function localStepResult(step: IOSSetupStep): CheckResult { + const name = `iOS: ${step.title}`; + switch (step.status) { + case "satisfied": + return { + name, + status: "pass", + message: `${step.title}: configured`, + detail: step.description, + }; + case "review": + return { + name, + status: "warn", + message: `${step.title}: review needed`, + detail: step.description, + remedy: step.description, + }; + case "required": + return { + name, + status: "fail", + message: `${step.title}: setup required`, + detail: step.description, + remedy: LOCAL_STEP_REMEDY, + }; + case "blocked": + return { + name, + status: "fail", + message: `${step.title}: blocked`, + detail: step.description, + remedy: step.id === "select-target" ? step.description : LOCAL_STEP_REMEDY, + }; + } +} + +function localResults(inspection: IOSProjectInspectionResult): CheckResult[] { + const plan = buildIOSSetupPlan(inspection); + const results = plan.steps + .filter((step) => step.id !== "register-native-application" || step.status === "blocked") + .map(localStepResult); + const readiness = buildIOSNativeReadinessAudit(inspection); + if ( + readiness.target.status === "selected" && + readiness.target.appIdPrefix.status === "conflicting" + ) { + results.push({ + name: "iOS: App ID Prefix evidence", + status: "fail", + message: "App ID Prefix evidence: conflicting values were found", + detail: + "The selected target's entitlements contain different literal App ID Prefix values across build configurations.", + remedy: + "Make the entitlements consistent and verify the exact App ID Prefix in Apple Developer before rerunning `clerk doctor`.", + }); + } + return results; +} + +async function appleEntitlementResult( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, + dependencies: IOSDoctorDependencies, +): Promise { + const anyAppleEntitlement = target.configurations.some( + (configuration) => configuration.entitlements?.signInWithApple === true, + ); + if (!anyAppleEntitlement) return undefined; + + const plan = await dependencies.planIOSAppleEntitlement({ + root: inspection.root, + projectPath: target.projectPath, + targetId: target.id, + }); + if (plan.status === "satisfied") { + return { + name: "iOS: Sign in with Apple entitlement", + status: "pass", + message: "Sign in with Apple entitlement: configured", + detail: "Every selected-target configuration has the exact native Apple entitlement.", + }; + } + + const detail = + plan.status === "ready" + ? plan.actions.join("\n") + : plan.blockers.map((blocker) => blocker.message).join("\n"); + return { + name: "iOS: Sign in with Apple entitlement", + status: "fail", + message: "Sign in with Apple entitlement: incomplete", + ...(detail ? { detail } : {}), + remedy: LOCAL_STEP_REMEDY, + }; +} + +function linkedDevelopmentKeyResult( + inspection: IOSProjectInspectionResult, + application: Application, + developmentInstanceId: string, +): CheckResult { + const name = "iOS: Linked development key"; + const localHost = inspection.localPublishableKey.frontendApiHost; + if (!localHost) { + return { + name, + status: "fail", + message: "Linked development key: local runtime key was not proven", + remedy: LOCAL_STEP_REMEDY, + }; + } + + const instance = application.instances.find( + (candidate) => candidate.instance_id === developmentInstanceId, + ); + if (!instance) { + return { + name, + status: "fail", + message: "Linked development key: linked instance is stale", + remedy: "Run `clerk link` to select a valid development instance.", + }; + } + + try { + const linked = decodePublishableKey(instance.publishable_key); + if ( + inspection.localPublishableKey.instanceType !== "development" || + linked.instanceType !== "development" || + linked.fapiHost !== localHost + ) { + return { + name, + status: "fail", + message: "Linked development key: the Xcode target points to a different Clerk instance", + detail: `Local Frontend API host: ${localHost}\nLinked Frontend API host: ${linked.fapiHost}`, + remedy: LOCAL_STEP_REMEDY, + }; + } + return { + name, + status: "pass", + message: "Linked development key: matches the selected Xcode target", + detail: `Frontend API host: ${localHost}`, + }; + } catch { + return { + name, + status: "fail", + message: "Linked development key: Clerk returned an invalid publishable key", + remedy: "Relink the project or contact Clerk support before changing the Xcode target.", + }; + } +} + +async function remoteResults( + ctx: DoctorContext, + inspection: IOSProjectInspectionResult, + dependencies: IOSDoctorDependencies, +): Promise { + const readiness = buildIOSNativeReadinessAudit(inspection); + const target = selectedTarget(inspection); + const preliminaryResults: CheckResult[] = []; + if (target) { + const authView = await authViewEnvironmentResult(inspection, target, dependencies); + if (authView) preliminaryResults.push(authView); + } + + if ( + readiness.target.status === "selected" && + (readiness.target.bundleIdentifier.status !== "resolved" || + readiness.target.appIdPrefix.status === "conflicting") + ) { + // These are fully local identity failures. Avoid hiding them behind an + // authentication/network warning or asking the remote API about an + // identity that the selected Xcode target did not prove. + return preliminaryResults; + } + + const profile = await ctx.getProfile(); + if (!profile) { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "warn", + message: "Native Application: remote state not inspected (project is not linked)", + remedy: "Run `clerk link`, then rerun `clerk doctor`.", + }, + ]; + } + + if (readiness.target.status !== "selected") { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "warn", + message: "Native Application: remote state not inspected (select one iOS target)", + remedy: "Rerun with `clerk doctor --target `.", + }, + ]; + } + + const applicationId = profile.profile.appId; + const instanceId = profile.profile.instances.development; + try { + const [application, remotePlan] = await Promise.all([ + dependencies.fetchApplication(applicationId, { includeSecretKeys: false }), + auditIOSNativeRemoteSetup( + { applicationId, instanceId, target: readiness.target }, + { + getNativeSettings: dependencies.getNativeSettings, + listIOSApplications: dependencies.listIOSApplications, + }, + ), + ]); + const configureStep = buildIOSSetupPlan(inspection).steps.find( + (step) => step.id === "configure-publishable-key", + ); + const linkedKeyResult = + configureStep?.status === "satisfied" + ? linkedDevelopmentKeyResult(inspection, application, instanceId) + : undefined; + const results = [...preliminaryResults, ...(linkedKeyResult ? [linkedKeyResult] : [])]; + if (remotePlan.status === "satisfied") { + results.push({ + name: "iOS: Native Application", + status: "pass", + message: "Native API and iOS registration: configured", + detail: remotePlan.bundleIdentifier + ? `Bundle ID: ${remotePlan.bundleIdentifier}` + : undefined, + }); + } else { + const detail = + remotePlan.status === "ready" + ? remotePlan.actions.join("\n") + : remotePlan.blockers.map((blocker) => blocker.message).join("\n"); + results.push({ + name: "iOS: Native Application", + status: "fail", + message: + remotePlan.status === "ready" + ? "Native API or iOS registration: setup required" + : "Native API or iOS registration: blocked", + ...(detail ? { detail } : {}), + remedy: REMOTE_REMEDY, + }); + } + + const bundleIdentifier = readiness.target.bundleIdentifier; + const hasAppleEntitlement = target?.configurations.some( + (configuration) => configuration.entitlements?.signInWithApple === true, + ); + if ( + hasAppleEntitlement && + bundleIdentifier.status === "resolved" && + remotePlan.registration === "satisfied" + ) { + try { + const apple = await dependencies.auditIOSNativeAppleHealth({ + applicationId, + instanceId, + bundleIdentifier: bundleIdentifier.value, + }); + if (apple.runtime.status === "satisfied") { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "pass", + message: "Clerk Sign in with Apple: configured for the selected Bundle ID", + detail: + apple.automation.status === "supported" + ? "The current connection is healthy and can be reconciled by clerk init." + : "The current connection is healthy; automatic repair is unavailable for this instance.", + }); + } else if (apple.runtime.status === "required") { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: + apple.runtime.bundleIdentifierConfiguration === "required" + ? "Clerk Sign in with Apple: the connection is not bound to the selected Bundle ID" + : "Clerk Sign in with Apple: local entitlement is present but the connection is disabled", + remedy: + "Run `clerk init --target --sign-in-with-apple` if this app should offer Apple sign-in.", + }); + } else { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: "Clerk Sign in with Apple: configuration conflict", + detail: apple.runtime.blockers.map((blocker) => blocker.message).join("\n"), + remedy: "Review the Apple connection in Clerk Dashboard, then rerun `clerk doctor`.", + }); + } + } catch (error) { + if (error instanceof PlapiError && error.status === 403) { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: "Clerk Sign in with Apple: application access is not permitted", + remedy: + "Use an account or Platform API key with applications:read access to the linked app.", + }); + } else if (isAuthError(error) || (error instanceof PlapiError && error.status === 401)) { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: "Clerk Sign in with Apple: Clerk authentication is invalid", + remedy: "Run `clerk auth login`, then rerun `clerk doctor`.", + }); + } else if (error instanceof PlapiError && error.status === 404) { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "fail", + message: "Clerk Sign in with Apple: the linked app or instance was not found", + remedy: "Run `clerk link` to refresh this project's application and instance IDs.", + }); + } else { + results.push({ + name: "iOS: Clerk Sign in with Apple", + status: "warn", + message: "Clerk Sign in with Apple: remote state could not be inspected", + remedy: "Check your Clerk authentication and network connection, then rerun doctor.", + }); + } + } + } + return results; + } catch (error) { + if (error instanceof PlapiError && error.status === 403) { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "fail", + message: "Native Application: application access is not permitted", + remedy: + "Use an account or Platform API key with applications:read access to the linked app.", + }, + ]; + } + if (isAuthError(error) || (error instanceof PlapiError && error.status === 401)) { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "fail", + message: "Native Application: Clerk authentication is invalid", + remedy: "Run `clerk auth login`, then rerun `clerk doctor`.", + }, + ]; + } + if (error instanceof PlapiError && error.status === 404) { + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "fail", + message: "Native Application: the linked app or development instance was not found", + remedy: "Run `clerk link` to refresh this project's application and instance IDs.", + }, + ]; + } + return [ + ...preliminaryResults, + { + name: "iOS: Native Application", + status: "warn", + message: "Native Application: remote state could not be inspected", + remedy: + "Check your Clerk authentication and network connection, then rerun `clerk doctor`.", + }, + ]; + } +} + +export async function runIOSDoctorChecks( + ctx: DoctorContext, + options: IOSDoctorOptions, + dependencies: IOSDoctorDependencies = defaultDependencies, +): Promise<{ inspection: IOSProjectInspectionResult; results: CheckResult[] }> { + const inspection = await dependencies.inspectIOSProject(options.root, { + target: options.target, + exhaustiveContainerDiscovery: true, + }); + const results = localResults(inspection); + const target = selectedTarget(inspection); + if (target) { + const apple = await appleEntitlementResult(inspection, target, dependencies); + if (apple) results.splice(Math.max(0, results.length - 1), 0, apple); + } + results.push(...(await remoteResults(ctx, inspection, dependencies))); + return { inspection, results }; +} diff --git a/packages/cli-core/src/commands/doctor/types.ts b/packages/cli-core/src/commands/doctor/types.ts index 1b665b8e8..2c13f3ea3 100644 --- a/packages/cli-core/src/commands/doctor/types.ts +++ b/packages/cli-core/src/commands/doctor/types.ts @@ -28,6 +28,10 @@ export interface KeylessInstanceInfo { } export interface DoctorContext { + /** PLAPI prefers this credential over any stored OAuth session. */ + hasPlatformAPIKey(): boolean; + /** OAuth or Platform API-key presence; does not perform a network request. */ + hasAccountCredentials(): Promise; getToken(): Promise; getValidToken(): Promise; getProfile(): Promise; @@ -68,4 +72,18 @@ export interface DoctorOptions { json?: boolean; spotlight?: boolean; fix?: boolean; + /** Exact Xcode application target name or PBX object ID. */ + target?: string; + /** Explicit Xcode project or workspace used by opt-in execution checks. */ + xcodeContainer?: string; + /** Explicit shared or automatically created Xcode scheme. */ + scheme?: string; + /** Explicitly permit Xcode to resolve and update Swift package locks. */ + resolvePackages?: boolean; + /** Compile the selected iOS scheme for the simulator. */ + build?: boolean; + /** Install and launch the selected app in an iOS Simulator. */ + simulator?: boolean; + /** Simulator UDID or exact device name. Valid only with --simulator. */ + device?: string; } diff --git a/packages/cli-core/src/commands/init/ios/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 2b3895353..e5db676e1 100644 --- a/packages/cli-core/src/commands/init/ios/build-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/build-settings.test.ts @@ -494,6 +494,7 @@ describe("inspectTargetBuildConfigurations", () => { localSecretsRuntimeBindings: [], environmentInjections: [], environmentConsumers: [], + authViewReferences: [], authFlowReferences: [], openURLHandlers: [], status: "absent", diff --git a/packages/cli-core/src/commands/init/ios/inspect.test.ts b/packages/cli-core/src/commands/init/ios/inspect.test.ts index 44b3ad83d..aadf7ccf3 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -506,6 +506,7 @@ struct MyApp: App { const swift = inspection.appTargets[0]?.swift; expect(swift?.sourceFilesScanned).toBe(2); + expect(swift?.authViewReferences).toEqual([{ path: "Synced/Included/Auth.swift" }]); expect(swift?.authFlowReferences).toEqual([{ path: "Synced/Included/Auth.swift" }]); expect(swift?.configureCalls).toEqual([]); }); diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 37157d5c8..023a9c5f2 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -93,6 +93,7 @@ function emptySwiftInspection() { localSecretsRuntimeBindings: [], environmentInjections: [], environmentConsumers: [], + authViewReferences: [], authFlowReferences: [], openURLHandlers: [], status: "absent" as const, diff --git a/packages/cli-core/src/commands/init/ios/native-apple.test.ts b/packages/cli-core/src/commands/init/ios/native-apple.test.ts index 932ed9f6c..975a8db12 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.test.ts @@ -4,11 +4,13 @@ import type { InstanceConfigSchema } from "../../../lib/plapi.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import { applyIOSNativeAppleConnection, + auditIOSNativeAppleHealth, buildIOSNativeApplePlan, prepareIOSNativeAppleConnection, type IOSNativeAppleAPI, type IOSNativeApplePatchOptions, type IOSNativeApplePrompts, + type IOSNativeAppleReadAPI, } from "./native-apple.ts"; const APPLICATION_ID = "app_native_apple"; @@ -206,6 +208,144 @@ function statefulAPI( } describe("native Sign in with Apple remote setup", () => { + test("audits runtime health through a credential-free GET-only projection", async () => { + const calls: string[] = []; + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig(applicationId, instanceId, keys) { + expect([applicationId, instanceId]).toEqual([APPLICATION_ID, INSTANCE_ID]); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET config"); + return config( + connection(true, true, { + bundle_id: BUNDLE_IDENTIFIER, + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + team_id: TEAM_ID, + key_id: KEY_ID, + }), + ); + }, + async fetchInstanceConfigSchema(applicationId, instanceId, keys) { + expect([applicationId, instanceId]).toEqual([APPLICATION_ID, INSTANCE_ID]); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET schema"); + return {}; + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime).toEqual({ + status: "satisfied", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + blockers: [], + }); + expect(result.automation).toMatchObject({ + status: "unsupported", + configVersion: CONFIG_VERSION, + blockers: [expect.objectContaining({ code: "apple-config-unsupported" })], + }); + expect(calls.sort()).toEqual(["GET config", "GET schema"]); + const serialized = JSON.stringify(result); + for (const sensitive of [SERVICES_ID, PRIVATE_KEY, TEAM_ID, KEY_ID]) { + expect(serialized).not.toContain(sensitive); + } + }); + + test("reports repairable runtime state independently from supported automation", async () => { + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + return config(connection(false, true)); + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime).toMatchObject({ + status: "required", + connection: "required", + bundleIdentifierConfiguration: "required", + blockers: [], + }); + expect(result.automation).toEqual({ + status: "supported", + configVersion: CONFIG_VERSION, + blockers: [], + }); + }); + + test("keeps malformed automation metadata from poisoning healthy runtime state", async () => { + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + return config( + connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + `v1_${PRIVATE_KEY}`, + ); + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + const result = await auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ); + + expect(result.runtime.status).toBe("satisfied"); + expect(result.automation).toMatchObject({ + status: "unsupported", + blockers: [expect.objectContaining({ code: "apple-config-invalid" })], + }); + expect(JSON.stringify(result)).not.toContain(PRIVATE_KEY); + }); + + test("preserves GET transport errors for diagnostic classification", async () => { + const transportError = new Error(API_SECRET); + const api: IOSNativeAppleReadAPI = { + async fetchInstanceConfig() { + throw transportError; + }, + async fetchInstanceConfigSchema() { + return appleSchema(); + }, + }; + + await expect( + auditIOSNativeAppleHealth( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + }, + api, + ), + ).rejects.toBe(transportError); + }); + test("builds a narrow redacted plan without retaining web credentials", () => { const sensitiveConnection = connection(false, true, { client_id: SERVICES_ID, diff --git a/packages/cli-core/src/commands/init/ios/native-apple.ts b/packages/cli-core/src/commands/init/ios/native-apple.ts index da77ce87e..d4e5a67e8 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -91,6 +91,43 @@ export interface IOSNativeAppleAPI { ): Promise>; } +/** GET-only Apple connection API surface used by read-only diagnostics. */ +export type IOSNativeAppleReadAPI = Pick< + IOSNativeAppleAPI, + "fetchInstanceConfig" | "fetchInstanceConfigSchema" +>; + +export interface AuditIOSNativeAppleHealthOptions { + applicationId: string; + instanceId: string; + bundleIdentifier: string; +} + +/** + * Credential-free health projection of the current Apple runtime state. The + * ability to automate a repair is reported separately so an unsupported patch + * schema cannot make an already-correct runtime configuration look broken. + */ +export interface IOSNativeAppleHealthAudit { + schemaVersion: 1; + kind: "clerk-ios-native-apple-health"; + applicationId: string; + instanceId: string; + bundleIdentifier: string; + runtime: { + status: "required" | "satisfied" | "blocked"; + connection: "required" | "satisfied" | "blocked"; + bundleIdentifierConfiguration: "required" | "satisfied" | "blocked"; + current?: AppleConnectionState; + blockers: IOSNativeAppleBlocker[]; + }; + automation: { + status: "supported" | "unsupported"; + configVersion?: string; + blockers: IOSNativeAppleBlocker[]; + }; +} + const defaultAPI: IOSNativeAppleAPI = { supportsIfMatch: true, fetchInstanceConfig, @@ -198,6 +235,139 @@ function parseConfigVersion( return { status: "valid", value }; } +function buildIOSNativeAppleHealthAudit( + options: AuditIOSNativeAppleHealthOptions & { + config: Record; + schema: InstanceConfigSchema; + }, +): IOSNativeAppleHealthAudit { + const bundleIdentifier = options.bundleIdentifier.trim(); + const runtimeBlockers: IOSNativeAppleBlocker[] = []; + if (!bundleIdentifier) { + runtimeBlockers.push( + blocker( + "bundle-identifier-unavailable", + "Resolve one Bundle ID for the selected iOS target before verifying native Sign in with Apple.", + ), + ); + } + + const parsed = parseConnection(options.config); + if (parsed.status === "invalid") { + runtimeBlockers.push( + blocker( + "apple-config-invalid", + "The existing Apple connection configuration could not be interpreted safely. Review it in the Clerk Dashboard before continuing.", + ), + ); + } + if ( + parsed.status === "valid" && + parsed.bundleIdentifier && + bundleIdentifier && + parsed.bundleIdentifier !== bundleIdentifier + ) { + runtimeBlockers.push( + blocker( + "apple-bundle-identifier-conflict", + "The existing Apple connection references a different iOS Bundle ID. clerk init will not replace it.", + ), + ); + } + if (parsed.status === "valid" && parsed.value.enabled && !parsed.value.authenticatable) { + runtimeBlockers.push( + blocker( + "apple-authenticatable-conflict", + "Apple is enabled but intentionally unavailable for authentication. clerk init will not override that policy automatically.", + ), + ); + } + + const current = parsed.status === "valid" ? parsed.value : undefined; + const bundleIdentifierConfiguration = + runtimeBlockers.length > 0 + ? "blocked" + : parsed.status !== "valid" + ? "blocked" + : parsed.bundleIdentifier === bundleIdentifier + ? "satisfied" + : "required"; + const connection = + runtimeBlockers.length > 0 + ? "blocked" + : current?.enabled === true && + current.authenticatable === true && + bundleIdentifierConfiguration === "satisfied" + ? "satisfied" + : "required"; + const runtimeStatus = + connection === "blocked" ? "blocked" : connection === "satisfied" ? "satisfied" : "required"; + + const automationBlockers: IOSNativeAppleBlocker[] = []; + if (!schemaSupportsNarrowApplePatch(options.schema)) { + automationBlockers.push( + blocker( + "apple-config-unsupported", + "This Clerk instance does not expose the narrow native Apple connection configuration required by clerk init.", + ), + ); + } + const configVersion = parseConfigVersion(options.config); + if (configVersion.status === "invalid") { + automationBlockers.push( + blocker( + "apple-config-invalid", + "The Apple connection configuration version could not be interpreted safely. Rerun clerk init before making remote changes.", + ), + ); + } + + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-health", + applicationId: options.applicationId, + instanceId: options.instanceId, + bundleIdentifier, + runtime: { + status: runtimeStatus, + connection, + bundleIdentifierConfiguration, + ...(current ? { current } : {}), + blockers: runtimeBlockers, + }, + automation: { + status: automationBlockers.length === 0 ? "supported" : "unsupported", + ...(configVersion.status === "valid" ? { configVersion: configVersion.value } : {}), + blockers: automationBlockers, + }, + }; +} + +async function readIOSNativeAppleState( + applicationId: string, + instanceId: string, + api: IOSNativeAppleReadAPI, +): Promise<{ config: Record; schema: InstanceConfigSchema }> { + const [config, schema] = await Promise.all([ + api.fetchInstanceConfig(applicationId, instanceId, [APPLE_CONNECTION_KEY]), + api.fetchInstanceConfigSchema(applicationId, instanceId, [APPLE_CONNECTION_KEY]), + ]); + return { config, schema }; +} + +/** + * Reads Apple connection configuration without a spinner, prompt, mutation, + * or error wrapping. Raw config and schema responses remain internal; only a + * credential-free health projection is returned. + */ +export async function auditIOSNativeAppleHealth( + options: AuditIOSNativeAppleHealthOptions, + api: IOSNativeAppleReadAPI = defaultAPI, +): Promise { + const state = await readIOSNativeAppleState(options.applicationId, options.instanceId, api); + return buildIOSNativeAppleHealthAudit({ ...options, ...state }); +} + export function buildIOSNativeApplePlan( options: IOSNativeAppleOptions & { config: Record; @@ -325,18 +495,10 @@ export async function auditIOSNativeAppleConnection( let config: Record; let schema: InstanceConfigSchema; try { - [config, schema] = await withSpinner( + ({ config, schema } = await withSpinner( "Auditing Clerk Sign in with Apple settings...", - async () => - Promise.all([ - api.fetchInstanceConfig(options.applicationId, options.instanceId, [ - APPLE_CONNECTION_KEY, - ]), - api.fetchInstanceConfigSchema(options.applicationId, options.instanceId, [ - APPLE_CONNECTION_KEY, - ]), - ]), - ); + async () => readIOSNativeAppleState(options.applicationId, options.instanceId, api), + )); } catch { throw new CliError( "Clerk Sign in with Apple settings could not be inspected safely. No remote Apple connection changes were made; verify application access and rerun clerk init.", diff --git a/packages/cli-core/src/commands/init/ios/native-remote.test.ts b/packages/cli-core/src/commands/init/ios/native-remote.test.ts index 882f9d6d1..9073e26ca 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.test.ts @@ -4,12 +4,14 @@ import { useCaptureLog } from "../../../test/lib/stubs.ts"; import type { IOSNativeReadinessTarget } from "./native-readiness.ts"; import { applyIOSNativeRemoteSetup, + auditIOSNativeRemoteSetup, buildIOSNativeRemotePlan, prepareIOSNativeRemoteSetup, validateAppIdPrefix, type IOSNativeRemoteAPI, type IOSNativeRemotePlan, type IOSNativeRemotePrompts, + type IOSNativeRemoteReadAPI, } from "./native-remote.ts"; import type { IOSApplication, NativeSettings } from "../../../lib/plapi.ts"; @@ -201,6 +203,66 @@ function prompts( } describe("Clerk Native Application remote setup", () => { + test("audits through a GET-only API and returns the redacted remote plan", async () => { + const calls: string[] = []; + const sensitiveUnexpectedField = "pk_test_MUST_NOT_ESCAPE"; + const api: IOSNativeRemoteReadAPI = { + async getNativeSettings(applicationId, instanceId) { + expect([applicationId, instanceId]).toEqual([APPLICATION_ID, INSTANCE_ID]); + calls.push("GET native settings"); + return { + ...nativeSettings(true), + publishable_key: sensitiveUnexpectedField, + } as NativeSettings; + }, + async listIOSApplications(applicationId, instanceId) { + expect([applicationId, instanceId]).toEqual([APPLICATION_ID, INSTANCE_ID]); + calls.push("GET iOS registrations"); + return [registration()]; + }, + }; + + const result = await auditIOSNativeRemoteSetup( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + }, + api, + ); + + expect(result).toMatchObject({ + status: "satisfied", + nativeApi: "satisfied", + registration: "satisfied", + }); + expect(calls.sort()).toEqual(["GET iOS registrations", "GET native settings"]); + expect(JSON.stringify(result)).not.toContain(sensitiveUnexpectedField); + }); + + test("preserves GET transport errors for diagnostic classification", async () => { + const transportError = new Error("native audit transport failure"); + const api: IOSNativeRemoteReadAPI = { + async getNativeSettings() { + throw transportError; + }, + async listIOSApplications() { + return []; + }, + }; + + await expect( + auditIOSNativeRemoteSetup( + { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + }, + api, + ), + ).rejects.toBe(transportError); + }); + test("validates the public App ID Prefix contract without assuming a Team ID shape", () => { expect(validateAppIdPrefix(" legacy.prefix-value ")).toBe("legacy.prefix-value"); expect(validateAppIdPrefix(" ")).toBeUndefined(); diff --git a/packages/cli-core/src/commands/init/ios/native-remote.ts b/packages/cli-core/src/commands/init/ios/native-remote.ts index d1c98beb1..277a67e66 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -62,6 +62,19 @@ export interface IOSNativeRemoteAPI { ): Promise; } +/** GET-only Native Application API surface used by read-only diagnostics. */ +export type IOSNativeRemoteReadAPI = Pick< + IOSNativeRemoteAPI, + "getNativeSettings" | "listIOSApplications" +>; + +export interface AuditIOSNativeRemoteSetupOptions { + applicationId: string; + instanceId: string; + target: IOSNativeReadinessTarget; + requestedAppIdPrefix?: string; +} + const defaultAPI: IOSNativeRemoteAPI = { getNativeSettings, enableNativeApi, @@ -306,7 +319,7 @@ export function buildIOSNativeRemotePlan(options: { async function readRemoteState( applicationId: string, instanceId: string, - api: IOSNativeRemoteAPI, + api: IOSNativeRemoteReadAPI, ): Promise<{ nativeSettings: NativeSettings; registrations: IOSApplication[] }> { const [nativeSettings, registrations] = await Promise.all([ api.getNativeSettings(applicationId, instanceId), @@ -315,6 +328,38 @@ async function readRemoteState( return { nativeSettings, registrations }; } +async function readIOSNativeRemoteAudit( + options: AuditIOSNativeRemoteSetupOptions, + api: IOSNativeRemoteReadAPI, +): Promise<{ + plan: IOSNativeRemotePlan; + state: Awaited>; +}> { + const state = await readRemoteState(options.applicationId, options.instanceId, api); + return { + plan: buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + target: options.target, + requestedAppIdPrefix: options.requestedAppIdPrefix, + ...state, + }), + state, + }; +} + +/** + * Reads Native API and iOS registration state without prompting, mutating, or + * wrapping transport errors. The returned plan is a redacted projection of + * the two GET responses; raw response objects are not exposed. + */ +export async function auditIOSNativeRemoteSetup( + options: AuditIOSNativeRemoteSetupOptions, + api: IOSNativeRemoteReadAPI = defaultAPI, +): Promise { + return (await readIOSNativeRemoteAudit(options, api)).plan; +} + function formatBlockers(plan: IOSNativeRemotePlan): string { return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); } @@ -329,24 +374,27 @@ export async function prepareIOSNativeRemoteSetup( const api = dependencies.api ?? defaultAPI; const prompts = dependencies.prompts ?? defaultPrompts; let state: Awaited>; + let plan: IOSNativeRemotePlan; try { - state = await withSpinner("Auditing Clerk Native Application settings...", async () => - readRemoteState(options.applicationId, options.instanceId, api), + const audit = await withSpinner("Auditing Clerk Native Application settings...", async () => + readIOSNativeRemoteAudit( + { + applicationId: options.applicationId, + instanceId: options.instanceId, + target: options.target, + requestedAppIdPrefix: options.appIdPrefix, + }, + api, + ), ); + state = audit.state; + plan = audit.plan; } catch (error) { log.debug(`Could not inspect Clerk Native Application settings: ${errorMessage(error)}`); throw new CliError( "Clerk Native Application settings could not be inspected. No local or remote setup changes were written; verify your application access and rerun clerk init.", ); } - let plan = buildIOSNativeRemotePlan({ - applicationId: options.applicationId, - instanceId: options.instanceId, - target: options.target, - requestedAppIdPrefix: options.appIdPrefix, - ...state, - }); - const onlyMissingPrefix = plan.status === "blocked" && plan.blockers.length === 1 && diff --git a/packages/cli-core/src/commands/init/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index e2ede8771..b0249d410 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -25,6 +25,7 @@ function target(): IOSAppTarget { localSecretsRuntimeBindings: [], environmentInjections: [], environmentConsumers: [], + authViewReferences: [], authFlowReferences: [], openURLHandlers: [], status: "absent", diff --git a/packages/cli-core/src/commands/init/ios/swift.test.ts b/packages/cli-core/src/commands/init/ios/swift.test.ts index 6c774125e..d17df518e 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -66,6 +66,7 @@ describe("inspectSwiftSources", () => { }, ]); expect(inspection.environmentInjections).toEqual([{ path: "App.swift" }]); + expect(inspection.authViewReferences).toEqual([{ path: "App.swift" }]); expect(inspection.authFlowReferences).toEqual([{ path: "App.swift" }]); expect(inspection.openURLHandlers).toEqual([{ path: "App.swift" }]); expect(JSON.stringify(inspection)).not.toContain("must-not-leak"); @@ -810,6 +811,7 @@ describe("inspectSwiftSources", () => { { path: "Password.swift" }, { path: "SignUp.swift" }, ]); + expect(inspection.authViewReferences).toEqual([]); }); test("marks multiple entry points as ambiguous", async () => { @@ -874,6 +876,7 @@ describe("inspectSwiftSources", () => { { absolutePath: path, relativePath: "ContentView.swift" }, ]); + expect(inspection.authViewReferences).toEqual([]); expect(inspection.authFlowReferences).toEqual([]); }); }); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index ce727d10f..b5a5755d5 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -1657,6 +1657,7 @@ export async function inspectSwiftSources( const localSecretsRuntimeSymbols = new Set(); const environmentInjections: IOSSourceEvidence[] = []; const environmentConsumers: IOSSourceEvidence[] = []; + const authViewReferences: IOSSourceEvidence[] = []; const authFlowReferences: IOSSourceEvidence[] = []; const openURLHandlers: IOSSourceEvidence[] = []; let sourceFilesScanned = 0; @@ -1711,10 +1712,11 @@ export async function inspectSwiftSources( if (importsClerkModule && has(sanitized, /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/)) { environmentConsumers.push(evidence); } - if ( - (importsUI && has(sanitized, /\bAuthView\s*\(/)) || - (importsClerkModule && has(sanitized, CLERK_NATIVE_AUTH_FLOW)) - ) { + const constructsAuthView = importsUI && has(sanitized, /\bAuthView\s*\(/); + if (constructsAuthView) { + authViewReferences.push(evidence); + } + if (constructsAuthView || (importsClerkModule && has(sanitized, CLERK_NATIVE_AUTH_FLOW))) { authFlowReferences.push(evidence); } if (importsClerkModule && hasClerkOpenURLHandler(sanitized)) { @@ -1764,6 +1766,7 @@ export async function inspectSwiftSources( localSecretsRuntimeBindings, environmentInjections, environmentConsumers, + authViewReferences, authFlowReferences, openURLHandlers, status, diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index 98017d765..c230ce745 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -122,6 +122,9 @@ export interface IOSSwiftInspection { localSecretsRuntimeBindings: IOSSourceEvidence[]; environmentInjections: IOSSourceEvidence[]; environmentConsumers: IOSSourceEvidence[]; + /** Selected-target sources that import ClerkKitUI and construct AuthView. */ + authViewReferences: IOSSourceEvidence[]; + /** Selected-target sources that reference AuthView or a custom native Clerk auth flow. */ authFlowReferences: IOSSourceEvidence[]; openURLHandlers: IOSSourceEvidence[]; status: "complete" | "partial" | "absent" | "ambiguous"; diff --git a/packages/cli-core/src/lib/credential-store.test.ts b/packages/cli-core/src/lib/credential-store.test.ts index 8e94c2d63..d7d7b584c 100644 --- a/packages/cli-core/src/lib/credential-store.test.ts +++ b/packages/cli-core/src/lib/credential-store.test.ts @@ -192,6 +192,88 @@ describe("credential-store", () => { }); }); + test("getValidToken coalesces concurrent refreshes in one process", async () => { + const session = { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }; + await storeToken(session); + + let releaseRefresh: + | ((value: { + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + }) => void) + | null = null; + let markRefreshStarted: (() => void) | null = null; + const refreshStarted = new Promise((resolve) => { + markRefreshStarted = resolve; + }); + const refreshResult = new Promise<{ + access_token: string; + token_type: string; + expires_in: number; + refresh_token: string; + }>((resolve) => { + releaseRefresh = resolve; + }); + mockRefreshAccessToken.mockImplementation(() => { + markRefreshStarted!(); + return refreshResult; + }); + + const first = getValidToken(); + const second = getValidToken(); + + await refreshStarted; + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + + releaseRefresh!({ + access_token: "refreshed-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "rotated-refresh-token", + }); + + expect(await Promise.all([first, second])).toEqual([ + "refreshed-access-token", + "refreshed-access-token", + ]); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + }); + + test("concurrent callers share invalid_grant recovery from another process", async () => { + const session = { + accessToken: "expired-access-token", + refreshToken: "refresh-token", + expiresAt: Date.now() - 60_000, + tokenType: "Bearer", + }; + const refreshedSession = { + accessToken: "other-process-access-token", + refreshToken: "other-process-refresh-token", + expiresAt: Date.now() + 60_000, + tokenType: "Bearer", + }; + await storeToken(session); + + mockRefreshAccessToken.mockImplementation(async () => { + await storeToken(refreshedSession); + throw new ApiError(400, "invalid_grant"); + }); + + expect(await Promise.all([getValidToken(), getValidToken()])).toEqual([ + "other-process-access-token", + "other-process-access-token", + ]); + expect(mockRefreshAccessToken).toHaveBeenCalledTimes(1); + expect(await getStoredSession()).toEqual(refreshedSession); + }); + test("getValidToken recovers from a concurrent refresh race when another process completes the refresh first (invalid_grant)", async () => { const session = { accessToken: "expired-access-token", diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index 1cdabf2cc..d22c38706 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -453,6 +453,7 @@ export async function storeToken(value: OAuthSession): Promise { } let tokenOverride: string | null | undefined; +let validTokenPromise: Promise | undefined; /** Test-only: override getToken() result. Pass undefined to clear. */ export function _setTokenOverride(value: string | null | undefined): void { @@ -486,7 +487,7 @@ export async function hasAccountCredentials(): Promise { return hasStoredCredentials(); } -export async function getValidToken(): Promise { +async function resolveValidToken(): Promise { const session = await getStoredSession(); if (!session) { if (await hasStoredCredentials()) { @@ -498,6 +499,30 @@ export async function getValidToken(): Promise { return getValidAccessToken(session); } +/** + * Resolve a usable OAuth access token once per process at a time. + * + * A refresh token rotates when it is redeemed. Without this in-flight guard, + * concurrent API calls can both read the same expired session and attempt to + * refresh it: one succeeds while the other receives `invalid_grant`. The + * cross-process recovery in `refreshStoredSession` still handles a different + * CLI process winning that race; this guard prevents the race between callers + * that already share this module instance. + */ +export async function getValidToken(): Promise { + if (validTokenPromise) return validTokenPromise; + + const pending = resolveValidToken(); + validTokenPromise = pending; + try { + return await pending; + } finally { + if (validTokenPromise === pending) { + validTokenPromise = undefined; + } + } +} + export async function deleteToken(): Promise { await keyringDelete(); await fileDelete();