From c686ec177680643004f3e49c77ff919d645722e7 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 21 Aug 2026 16:53:05 -0400 Subject: [PATCH 01/55] feat(init): reconcile native iOS configuration --- .changeset/calm-apples-inspect.md | 5 + packages/cli-core/src/cli-program.ts | 28 +- .../src/commands/deploy/index.test.ts | 217 +++ .../cli-core/src/commands/deploy/index.ts | 75 + .../src/commands/deploy/providers.test.ts | 145 +- .../cli-core/src/commands/deploy/providers.ts | 59 +- .../src/commands/deploy/status.test.ts | 267 +++ .../cli-core/src/commands/deploy/status.ts | 111 +- .../cli-core/src/commands/env/pull.test.ts | 165 ++ packages/cli-core/src/commands/env/pull.ts | 123 +- packages/cli-core/src/commands/init/README.md | 108 +- .../src/commands/init/frameworks/ios.test.ts | 164 +- .../src/commands/init/frameworks/ios.ts | 184 +- .../src/commands/init/frameworks/types.ts | 6 + .../src/commands/init/index-ios.test.ts | 1317 ++++++++++++++ .../cli-core/src/commands/init/index.test.ts | 116 +- packages/cli-core/src/commands/init/index.ts | 734 +++++++- .../init/ios/apple-entitlement.test.ts | 360 ++++ .../commands/init/ios/apple-entitlement.ts | 816 +++++++++ .../init/ios/apply-cli-runtime.test.ts | 737 ++++++++ .../init/ios/apply-cli.test-helpers.ts | 278 +++ .../src/commands/init/ios/apply-cli.test.ts | 768 ++++++++ .../cli-core/src/commands/init/ios/apply.ts | 1551 +++++++++++++++++ .../commands/init/ios/build-settings.test.ts | 54 +- .../src/commands/init/ios/dry-run.test.ts | 559 ++++++ .../commands/init/ios/native-apple.test.ts | 680 ++++++++ .../src/commands/init/ios/native-apple.ts | 669 +++++++ .../init/ios/native-readiness.test.ts | 331 ++++ .../src/commands/init/ios/native-readiness.ts | 364 ++++ .../commands/init/ios/native-remote.test.ts | 638 +++++++ .../src/commands/init/ios/native-remote.ts | 618 +++++++ .../cli-core/src/commands/init/ios/output.ts | 131 ++ .../src/commands/init/ios/plan.test.ts | 806 +++++++++ .../cli-core/src/commands/init/ios/plan.ts | 644 +++++++ .../ios/prebuilt-auth-environment.test.ts | 106 ++ .../init/ios/prebuilt-auth-environment.ts | 52 + .../commands/init/ios/prebuilt-auth.test.ts | 403 +++++ .../src/commands/init/ios/prebuilt-auth.ts | 804 +++++++++ .../src/commands/init/strategy.test.ts | 62 +- .../cli-core/src/commands/link/index.test.ts | 66 +- packages/cli-core/src/commands/link/index.ts | 17 +- packages/cli-core/src/lib/config-instance.ts | 61 + packages/cli-core/src/lib/config.ts | 68 +- packages/cli-core/src/lib/errors.ts | 25 + packages/cli-core/src/lib/framework.ts | 5 +- .../cli-core/src/lib/plapi-native.test.ts | 166 ++ packages/cli-core/src/lib/plapi.test.ts | 31 +- packages/cli-core/src/lib/plapi.ts | 130 +- packages/cli-core/src/lib/telemetry.ts | 11 + .../src/test/integration/agent-mode.test.ts | 8 +- .../cli-core/src/test/lib/init-harness.ts | 94 + .../ios/MyApp.xcodeproj/project.pbxproj | 51 +- test/e2e/fixtures/ios/MyApp/ContentView.swift | 17 + .../e2e/fixtures/ios/MyApp/MyApp.entitlements | 7 + test/e2e/fixtures/ios/MyApp/MyAppApp.swift | 10 + test/e2e/fixtures/ios/README.md | 18 +- test/e2e/lib/fixture-setup.ts | 7 +- test/e2e/native-init.test.ts | 230 ++- 58 files changed, 15936 insertions(+), 341 deletions(-) create mode 100644 .changeset/calm-apples-inspect.md create mode 100644 packages/cli-core/src/commands/init/index-ios.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/apple-entitlement.ts create mode 100644 packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/apply-cli.test-helpers.ts create mode 100644 packages/cli-core/src/commands/init/ios/apply-cli.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/apply.ts create mode 100644 packages/cli-core/src/commands/init/ios/dry-run.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/native-apple.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/native-apple.ts create mode 100644 packages/cli-core/src/commands/init/ios/native-readiness.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/native-readiness.ts create mode 100644 packages/cli-core/src/commands/init/ios/native-remote.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/native-remote.ts create mode 100644 packages/cli-core/src/commands/init/ios/output.ts create mode 100644 packages/cli-core/src/commands/init/ios/plan.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/plan.ts create mode 100644 packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.ts create mode 100644 packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/prebuilt-auth.ts create mode 100644 packages/cli-core/src/lib/config-instance.ts create mode 100644 packages/cli-core/src/lib/plapi-native.test.ts create mode 100644 test/e2e/fixtures/ios/MyApp/ContentView.swift create mode 100644 test/e2e/fixtures/ios/MyApp/MyApp.entitlements create mode 100644 test/e2e/fixtures/ios/MyApp/MyAppApp.swift diff --git a/.changeset/calm-apples-inspect.md b/.changeset/calm-apples-inspect.md new file mode 100644 index 000000000..b5a3804fd --- /dev/null +++ b/.changeset/calm-apples-inspect.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing proven LocalSecrets and ProcessInfo integrations remain compatibility paths and source-proven custom flows remain core-only. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the linked development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and `AuthView`, including redirect, continuation, pending-session-task, preview, and error-handling lifecycle wiring; established or partially integrated application UI is never rewritten. diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index ad53816fc..83391cea5 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -48,6 +48,7 @@ import { maybeNotifyUpdate } from "./lib/update-check.ts"; import { CURRENT_VERSION } from "./lib/version.ts"; import { registerExtras } from "@clerk/cli-extras"; import { + discardCommandTelemetry, finalizeAndSendTelemetry, startCommandTelemetry, telemetryResultForError, @@ -61,6 +62,18 @@ export type Program = Command<[], { inputJson?: string; mode?: string; verbose?: type CommandRegistrant = (program: Program) => void; +/** + * `init --dry-run` promises an invocation-wide read-only boundary. Keep the + * check here, outside the init action, so global hooks cannot send telemetry, + * fetch an update, or persist their caches around an otherwise read-only run. + */ +function isReadOnlyInitDryRun(actionCommand: { + name(): string; + getOptionValue(key: string): unknown; +}): boolean { + return actionCommand.name() === "init" && actionCommand.getOptionValue("dryRun") === true; +} + const registrants: CommandRegistrant[] = [ registerInit, registerAuth, @@ -109,8 +122,15 @@ export function createProgram(): Program { .option("--verbose", "Show detailed output (enables debug messages)") as Program; program.hook("preAction", async (_thisCommand, actionCommand) => { + const readOnlyInitDryRun = isReadOnlyInitDryRun(actionCommand); // First so hook-time failures (e.g. invalid --mode) still produce an event. - startCommandTelemetry(actionCommand); + // A read-only iOS inspection is the exception: its boundary covers global + // command hooks as well as the init action itself. + if (readOnlyInitDryRun) { + discardCommandTelemetry(); + } else { + startCommandTelemetry(actionCommand); + } // Reset log level at the start of each command invocation so a previous // --verbose doesn't leak into subsequent runs. setLogLevel("info"); @@ -125,6 +145,11 @@ export function createProgram(): Program { setMode(opts.mode as Mode); } + // Environment selection only affects remote Clerk operations. Avoid even + // reading or rendering persisted CLI environment state for this local-only + // inspection path. + if (readOnlyInitDryRun) return; + // Initialize the active environment from persisted config const envName = await getEnvironment(); if (envName && isValidEnv(envName)) { @@ -150,6 +175,7 @@ export function createProgram(): Program { // Show update notification after each command, except for commands that // already perform their own version check (doctor, update). program.hook("postAction", async (_thisCommand, actionCommand) => { + if (isReadOnlyInitDryRun(actionCommand)) return; const cmdName = actionCommand.name(); if (cmdName === "doctor" || cmdName === "update") return; await maybeNotifyUpdate(CURRENT_VERSION); diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4ccd8c9d6..9f520aff7 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -27,6 +27,8 @@ const mockPatchInstanceConfig = mock(); const mockFetchInstanceConfig = mock(); const mockFetchInstanceConfigSchema = mock(); const mockFetchApplication = mock(); +const mockListIOSApplications = mock(); +const mockGetNativeSettings = mock(); const mockListApplicationDomains = mock(); const mockCreateProductionInstance = mock(); const mockGetApplicationDomainStatus = mock(); @@ -49,6 +51,8 @@ mock.module("../../lib/plapi.ts", () => ({ fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), + listIOSApplications: (...args: unknown[]) => mockListIOSApplications(...args), + getNativeSettings: (...args: unknown[]) => mockGetNativeSettings(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), createProductionInstance: (...args: unknown[]) => mockCreateProductionInstance(...args), getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), @@ -228,6 +232,8 @@ describe("deploy", () => { mockGetApplicationDomainStatus.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), ); + mockListIOSApplications.mockResolvedValue([]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: true }); stubCreateProductionInstance(); mockTriggerApplicationDomainDNSCheck.mockResolvedValue( domainStatus({ status: "complete", dns: true, ssl: true, mail: true }), @@ -261,6 +267,8 @@ describe("deploy", () => { mockFetchInstanceConfig.mockReset(); mockFetchInstanceConfigSchema.mockReset(); mockFetchApplication.mockReset(); + mockListIOSApplications.mockReset(); + mockGetNativeSettings.mockReset(); mockListApplicationDomains.mockReset(); mockCreateProductionInstance.mockReset(); mockGetApplicationDomainStatus.mockReset(); @@ -1253,6 +1261,213 @@ describe("deploy", () => { expect(err).not.toContain("https://accounts.example.com/v1/oauth_callback"); }); + test("skips Apple web credential prompts for an exact native-only production registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValueOnce([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + await runDeploy({}); + + expect(mockListIOSApplications).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple"); + expect(mockGetNativeSettings).toHaveBeenCalledWith("app_xyz789", "ins_prod_native_apple"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + const err = stripAnsi(captured.err); + expect(err).toContain("No deploy actions remain."); + expect(err).toContain("OAuth Apple"); + expect(err).not.toContain("Configure Apple OAuth for production"); + }); + + test("refuses to infer an App ID Prefix when native Apple lacks an exact production registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValueOnce([ + { + object: "ios_application", + id: "ios_other", + app_id_prefix: "OTHER12345", + bundle_id: "com.example.other", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "the production instance does not have an exact iOS Native Application registration for that Bundle ID", + ); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Failed"); + }); + + test("preserves Ctrl-C while verifying a native-only Apple registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications + .mockRejectedValueOnce(new Error("native status endpoint unavailable")) + .mockRejectedValueOnce(promptExitError()); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toMatchObject({ exitCode: EXIT_CODE.SIGINT }); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Paused"); + }); + + test("refuses native-only Apple when production Native API is disabled", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockGetNativeSettings.mockResolvedValue({ + object: "native_settings", + api_enabled: false, + }); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "Enable Native API at https://dashboard.clerk.com/~/native-applications", + ); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + + test("refuses native-only Apple that is not explicitly authenticatable", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.native", + }, + }, + }); + mockIsAgent.mockReturnValue(false); + + await expect(runDeploy({})).rejects.toThrow( + "Apple is not explicitly enabled for authentication on the production instance", + ); + + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + test("Apple .p8 file prompt validates path and PEM framing before continuing", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_apple" }, @@ -1306,6 +1521,8 @@ describe("deploy", () => { "-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg\n-----END PRIVATE KEY-----\n", }, }); + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); const p8Input = mockInput.mock.calls.find((call) => String((call[0] as { message?: string }).message).includes("Apple Private Key"), )?.[0] as { validate: (value: string) => Promise }; diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 6f0958e0d..ebeaf1a90 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -10,6 +10,9 @@ import { interruptedExitCode } from "../../lib/signals.ts"; import { setProfile } from "../../lib/config.ts"; import { createProductionInstance as apiCreateProductionInstance, + fetchInstanceConfig, + getNativeSettings, + listIOSApplications, patchInstanceConfig, type CnameTarget, type ProductionInstanceResponse, @@ -34,6 +37,7 @@ import { } from "./copy.ts"; import { mapDeployError } from "./errors.ts"; import { + inspectNativeAppleConfiguration, providerLabel, providerSetupIntro, showOAuthWalkthrough, @@ -567,6 +571,10 @@ async function collectAndSaveOAuthCredentials( productionInstanceId: string, frontendApiUrl?: string, ): Promise { + if (await nativeAppleCredentialsAreAlreadyConfigured(ctx, descriptor, productionInstanceId)) { + return true; + } + for (const line of providerSetupIntro(descriptor)) log.info(line); log.blank(); @@ -601,6 +609,73 @@ async function collectAndSaveOAuthCredentials( return true; } +async function nativeAppleCredentialsAreAlreadyConfigured( + ctx: DeployContext, + descriptor: OAuthProviderDescriptor, + productionInstanceId: string, +): Promise { + if (descriptor.provider !== "apple") return false; + + const productionConfig = await withSpinner( + "Checking production Sign in with Apple configuration...", + async () => fetchInstanceConfig(ctx.appId, productionInstanceId), + ); + const preliminary = inspectNativeAppleConfiguration(productionConfig, descriptor, []); + if (preliminary.status === "authentication-disabled") { + throwUsageError( + `Native Sign in with Apple is configured for ${preliminary.bundleId}, but Apple is not explicitly enabled for authentication on the production instance. ` + + "Review the Apple connection in the Clerk Dashboard, then rerun `clerk deploy`. No Apple web credentials were requested.", + ); + } + if (preliminary.status !== "registration-missing") { + return false; + } + + let iosApplications: Awaited>; + let nativeSettings: Awaited>; + try { + [iosApplications, nativeSettings] = await withSpinner( + "Checking production Native Application settings...", + async () => + Promise.all([ + listIOSApplications(ctx.appId, productionInstanceId), + getNativeSettings(ctx.appId, productionInstanceId), + ]), + ); + } catch (error) { + if (error instanceof UserAbortError) throw error; + throw new CliError( + `clerk deploy could not verify the production Native Application registration for ${preliminary.bundleId}. ` + + "No Apple web credentials were requested. Verify the exact Bundle ID at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`.", + ); + } + + const nativeConfiguration = inspectNativeAppleConfiguration( + productionConfig, + descriptor, + iosApplications, + nativeSettings, + ); + if (nativeConfiguration.status === "ready") { + log.success( + `Native Sign in with Apple is configured for ${nativeConfiguration.bundleId}; Apple web credentials are not required`, + ); + return true; + } + + if (nativeConfiguration.status === "native-api-disabled") { + throwUsageError( + `Native Sign in with Apple is configured for ${nativeConfiguration.bundleId}, but Native API is disabled on the production instance. ` + + "Enable Native API at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. The CLI will not infer an App ID Prefix or request unrelated Apple web credentials.", + ); + } + + throwUsageError( + `Native Sign in with Apple is configured for ${preliminary.bundleId}, but the production instance does not have an exact iOS Native Application registration for that Bundle ID. ` + + "Register it at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. The CLI will not infer an App ID Prefix or request unrelated Apple web credentials.", + ); +} + async function persistProductionInstance(ctx: DeployContext, productionInstanceId: string) { await setProfile(ctx.profileKey, { ...ctx.profile, diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index b756b3860..bb4c88c3d 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -1,11 +1,12 @@ import { describe, expect, test } from "bun:test"; import { buildOAuthProviderDescriptors, + inspectNativeAppleConfiguration, providerFields, providerLabel, type OAuthProviderDescriptor, } from "./providers.ts"; -import type { InstanceConfigSchema } from "../../lib/plapi.ts"; +import type { IOSApplication, InstanceConfigSchema } from "../../lib/plapi.ts"; const oauthSchema = (properties: Record) => ({ type: "object", @@ -27,6 +28,21 @@ const basicOAuthSchema = oauthSchema({ }, }); +const appleOAuthSchema = oauthSchema({ + client_id: { type: "string", description: "Apple Services ID" }, + client_secret: { + type: "string", + description: "Apple Private Key", + "x-clerk-sensitive": true, + }, + key_id: { type: "string", description: "Apple Key ID" }, + team_id: { type: "string", description: "Apple Team ID" }, + bundle_id: { + type: "string", + description: "iOS app Bundle ID for native Sign in with Apple", + }, +}); + const schemaResponse = (properties: Record): InstanceConfigSchema => ({ $schema: "https://json-schema.org/draft/2020-12/schema", $id: "https://clerk.com/schemas/platform-config/2025-01-01", @@ -43,6 +59,17 @@ function descriptorByProvider( return descriptor; } +function iosApplication(bundleId: string): IOSApplication { + return { + object: "ios_application", + id: `ios_${bundleId}`, + app_id_prefix: "ABCDE12345", + bundle_id: bundleId, + created_at: 1, + updated_at: 1, + }; +} + describe("deploy OAuth provider descriptors", () => { test("builds a descriptor for public providers from schema and shared metadata", () => { const result = buildOAuthProviderDescriptors( @@ -147,22 +174,7 @@ describe("deploy OAuth provider descriptors", () => { test("applies Apple production credential overrides", () => { const result = buildOAuthProviderDescriptors( ["apple"], - schemaResponse({ - connection_oauth_apple: oauthSchema({ - client_id: { type: "string", description: "Apple Services ID" }, - client_secret: { - type: "string", - description: "Apple Private Key", - "x-clerk-sensitive": true, - }, - key_id: { type: "string", description: "Apple Key ID" }, - team_id: { type: "string", description: "Apple Team ID" }, - bundle_id: { - type: "string", - description: "iOS app Bundle ID for native Sign in with Apple", - }, - }), - }), + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), ); const apple = descriptorByProvider(result.supported, "apple"); @@ -190,6 +202,105 @@ describe("deploy OAuth provider descriptors", () => { ]); }); + test("recognizes native-only Apple only for an exact production registration", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + const config = { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.app", + }, + }; + + expect( + inspectNativeAppleConfiguration(config, apple, [iosApplication("com.example.app")], { + object: "native_settings", + api_enabled: true, + }), + ).toEqual({ status: "ready", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration(config, apple, [iosApplication("com.example.other")], { + object: "native_settings", + api_enabled: true, + }), + ).toEqual({ status: "registration-missing", bundleId: "com.example.app" }); + }); + + test("requires Native API and authenticatable Apple settings for native readiness", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + const iosApplications = [iosApplication("com.example.app")]; + const connection = { + enabled: true, + authenticatable: true, + bundle_id: "com.example.app", + }; + + expect( + inspectNativeAppleConfiguration( + { connection_oauth_apple: connection }, + apple, + iosApplications, + { object: "native_settings", api_enabled: false }, + ), + ).toEqual({ status: "native-api-disabled", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + ...connection, + authenticatable: false, + }, + }, + apple, + iosApplications, + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.app", + }, + }, + apple, + iosApplications, + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); + }); + + test("keeps hosted Apple credentials on the hosted OAuth path", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.app", + client_id: "com.example.web", + }, + }, + apple, + [iosApplication("com.example.app")], + ), + ).toEqual({ status: "hosted-or-unconfigured" }); + }); + test("keeps compatibility prompt labels only for behavioral overrides", () => { expect(providerFields("google").map((field) => field.label)).toEqual([ "Client ID", diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 3a2aabc99..fa01c4e61 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -3,7 +3,12 @@ import { bold, cyan, dim, yellow } from "../../lib/color.ts"; import { clerkSubdomains } from "./copy.ts"; import { log } from "../../lib/log.ts"; import { openBrowser } from "../../lib/open.ts"; -import type { ConfigSchemaProperty, InstanceConfigSchema } from "../../lib/plapi.ts"; +import type { + ConfigSchemaProperty, + IOSApplication, + InstanceConfigSchema, + NativeSettings, +} from "../../lib/plapi.ts"; const DEFAULT_DOCS_URL_PREFIX = "https://clerk.com/docs/guides/configure/auth-strategies/social-connections"; @@ -62,6 +67,13 @@ export type OAuthProviderDescriptorResult = { unsupported: string[]; }; +export type NativeAppleConfiguration = + | { status: "not-apple" | "hosted-or-unconfigured" } + | { + status: "ready" | "authentication-disabled" | "registration-missing" | "native-api-disabled"; + bundleId: string; + }; + type ProviderOverride = { credentialLabel?: string; redirectLabel?: string; @@ -212,6 +224,51 @@ export function hasProviderRequiredCredentials( }); } +/** + * Distinguish native-only Apple configuration from hosted Apple OAuth without + * treating an unrelated iOS registration as proof. Native-only production + * setup is ready only when it is authenticatable, its explicit Bundle ID has + * an exact registration, and Native API is enabled on that production instance. + */ +export function inspectNativeAppleConfiguration( + config: Record, + descriptor: OAuthProviderDescriptor, + iosApplications: readonly IOSApplication[], + nativeSettings?: NativeSettings, +): NativeAppleConfiguration { + if (descriptor.provider !== "apple") return { status: "not-apple" }; + + const value = config[descriptor.configKey]; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { status: "hosted-or-unconfigured" }; + } + const providerConfig = value as Record; + if (providerConfig.enabled !== true || hasAppleHostedIdentifier(providerConfig)) { + return { status: "hosted-or-unconfigured" }; + } + + const rawBundleId = providerConfig.bundle_id; + const bundleId = typeof rawBundleId === "string" ? rawBundleId.trim() : ""; + if (!bundleId) return { status: "hosted-or-unconfigured" }; + if (providerConfig.authenticatable !== true) { + return { status: "authentication-disabled", bundleId }; + } + + if (!iosApplications.some((application) => application.bundle_id === bundleId)) { + return { status: "registration-missing", bundleId }; + } + return nativeSettings?.api_enabled === true + ? { status: "ready", bundleId } + : { status: "native-api-disabled", bundleId }; +} + +function hasAppleHostedIdentifier(config: Record): boolean { + return ["client_id", "client_secret", "team_id", "key_id"].some((key) => { + const value = config[key]; + return typeof value === "string" && value.trim().length > 0; + }); +} + function buildOAuthProviderDescriptor( provider: string, schema: InstanceConfigSchema, diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 62435c391..e10d2225c 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -4,6 +4,8 @@ import type { LiveDeploySnapshot } from "./status.ts"; const mockFetchApplication = mock(); const mockListApplicationDomains = mock(); +const mockListIOSApplications = mock(); +const mockGetNativeSettings = mock(); const mockFetchInstanceConfig = mock(); const mockFetchInstanceConfigSchema = mock(); const mockGetApplicationDomainStatus = mock(); @@ -12,6 +14,8 @@ const mockTriggerApplicationDomainDNSCheck = mock(); mock.module("../../lib/plapi.ts", () => ({ fetchApplication: (...args: unknown[]) => mockFetchApplication(...args), listApplicationDomains: (...args: unknown[]) => mockListApplicationDomains(...args), + listIOSApplications: (...args: unknown[]) => mockListIOSApplications(...args), + getNativeSettings: (...args: unknown[]) => mockGetNativeSettings(...args), fetchInstanceConfig: (...args: unknown[]) => mockFetchInstanceConfig(...args), fetchInstanceConfigSchema: (...args: unknown[]) => mockFetchInstanceConfigSchema(...args), getApplicationDomainStatus: (...args: unknown[]) => mockGetApplicationDomainStatus(...args), @@ -46,14 +50,62 @@ const passthroughHandlers = { work({ update: () => {} }), }; +const appleOAuthSchema = { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + team_id: { type: "string" }, + key_id: { type: "string" }, + bundle_id: { type: "string" }, + }, +}; + +function mockActiveProductionEnvironment(): void { + mockFetchApplication.mockResolvedValue({ + application_id: "app_1", + name: "app", + instances: [ + { instance_id: "ins_dev", environment_type: "development" }, + { instance_id: "ins_prod", environment_type: "production" }, + ], + }); + mockListApplicationDomains.mockResolvedValue({ + data: [ + { + object: "domain", + id: "dmn_1", + name: "example.com", + is_satellite: false, + is_provider_domain: false, + frontend_api_url: "https://clerk.example.com", + accounts_portal_url: "https://accounts.example.com", + development_origin: "", + cname_targets: [], + }, + ], + total_count: 1, + }); + mockFetchInstanceConfigSchema.mockResolvedValue({ + properties: { connection_oauth_apple: appleOAuthSchema }, + }); + mockGetApplicationDomainStatus.mockResolvedValue(completeStatus); +} + beforeEach(() => { mockFetchInstanceConfig.mockResolvedValue({}); mockFetchInstanceConfigSchema.mockResolvedValue({ properties: {} }); + mockListIOSApplications.mockResolvedValue([]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: true }); }); afterEach(() => { mockFetchApplication.mockReset(); mockListApplicationDomains.mockReset(); + mockListIOSApplications.mockReset(); + mockGetNativeSettings.mockReset(); mockFetchInstanceConfig.mockReset(); mockFetchInstanceConfigSchema.mockReset(); mockGetApplicationDomainStatus.mockReset(); @@ -153,6 +205,221 @@ describe("resolveDeployState", () => { expect(state.snapshot.completedOAuthProviders).toEqual(["google"]); } }); + + test("treats exact native-only Apple production registration as complete", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual(["apple"]); + expect(state.snapshot.pending).toBeUndefined(); + expect(state.snapshot.nativeAppleReadinessIssue).toBeUndefined(); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(true); + expect(report.oauth).toMatchObject({ complete: true, configured: ["apple"], pending: [] }); + } + expect(mockListIOSApplications).toHaveBeenCalledWith("app_1", "ins_prod"); + expect(mockGetNativeSettings).toHaveBeenCalledWith("app_1", "ins_prod"); + }); + + test("reports an actionable incomplete state for a missing exact native Apple registration", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_other", + app_id_prefix: "OTHER12345", + bundle_id: "com.example.other", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-missing", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.state).toBe("oauth_pending"); + expect(report.oauth.pending).toEqual(["apple"]); + expect(report.nextAction).toContain("com.example.native"); + expect(report.nextAction).toContain("https://dashboard.clerk.com/~/native-applications"); + expect(report.nextAction).toContain("will not infer an App ID Prefix"); + expect(report.nextAction).not.toContain( + "OAuth providers are missing production credentials: apple", + ); + } + }); + + test("keeps the preliminary native Apple status when native endpoint reads fail", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockRejectedValue(new Error("native endpoint unavailable")); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-missing", + }); + } + }); + + test("keeps hosted Apple completion credential-based without reading iOS registrations", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.native", + client_id: "com.example.web", + client_secret: "REDACTED", + team_id: "TEAM123456", + key_id: "KEY1234567", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual(["apple"]); + expect(state.snapshot.nativeAppleReadinessIssue).toBeUndefined(); + } + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + }); + + test("keeps exact native Apple incomplete when production Native API is disabled", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockGetNativeSettings.mockResolvedValue({ object: "native_settings", api_enabled: false }); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "native-api-disabled", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("Native API is disabled"); + expect(report.nextAction).toContain("https://dashboard.clerk.com/~/native-applications"); + expect(report.nextAction).toContain("will not infer an App ID Prefix"); + } + }); + + test("requires Apple to be explicitly authenticatable without reading native endpoints", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "authentication-disabled", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("not explicitly enabled for authentication"); + expect(report.nextAction).toContain("no web credentials should be added"); + } + expect(mockListIOSApplications).not.toHaveBeenCalled(); + expect(mockGetNativeSettings).not.toHaveBeenCalled(); + }); }); describe("waitForDeployStatus", () => { diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index 86e6d3a20..e90a45878 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -1,12 +1,14 @@ import { resolveProfile } from "../../lib/config.ts"; -import { PlapiError } from "../../lib/errors.ts"; +import { errorMessage, PlapiError, UserAbortError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { fetchApplication, fetchInstanceConfig, fetchInstanceConfigSchema, + getNativeSettings, getApplicationDomainStatus, listApplicationDomains, + listIOSApplications, triggerApplicationDomainDNSCheck, type ApplicationDomain, type DomainStatusResponse, @@ -25,6 +27,7 @@ import { OAUTH_KEY_PREFIX, buildOAuthProviderDescriptors, hasProviderRequiredCredentials, + inspectNativeAppleConfiguration, type OAuthProvider, type OAuthProviderDescriptor, } from "./providers.ts"; @@ -72,6 +75,11 @@ export interface DeployStatusReport { nextAction: string; } +type NativeAppleReadinessIssue = { + bundleId: string; + reason: "authentication-disabled" | "registration-missing" | "native-api-disabled"; +}; + export type LiveDeploySnapshot = Omit< DeployOperationState, "pending" | "oauthProviders" | "completedOAuthProviders" @@ -84,6 +92,7 @@ export type LiveDeploySnapshot = Omit< componentStatus: DeployComponentStatus; unsupportedOAuthProviderCount: number; unsupportedOAuthProviders: string[]; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; export type DeployState = @@ -214,8 +223,44 @@ export async function resolveLiveDeploySnapshot( domain.id, options, ); + const nativeAppleDescriptor = oauthProviderDescriptors.find( + (descriptor) => descriptor.provider === "apple", + ); + const preliminaryNativeAppleConfiguration = nativeAppleDescriptor + ? inspectNativeAppleConfiguration(productionConfig, nativeAppleDescriptor, []) + : undefined; + let nativeAppleConfiguration = preliminaryNativeAppleConfiguration; + if ( + nativeAppleDescriptor && + preliminaryNativeAppleConfiguration?.status === "registration-missing" + ) { + try { + nativeAppleConfiguration = await withSpinner( + "Reading production Native Application settings...", + async () => { + const [iosApplications, nativeSettings] = await Promise.all([ + listIOSApplications(ctx.appId, productionInstanceId), + getNativeSettings(ctx.appId, productionInstanceId), + ]); + return inspectNativeAppleConfiguration( + productionConfig, + nativeAppleDescriptor, + iosApplications, + nativeSettings, + ); + }, + ); + } catch (error) { + if (error instanceof UserAbortError) throw error; + log.debug(`Could not read production Native Application settings: ${errorMessage(error)}`); + } + } const completedOAuthProviders = oauthProviderDescriptors - .filter((descriptor) => hasProviderRequiredCredentials(productionConfig, descriptor)) + .filter( + (descriptor) => + hasProviderRequiredCredentials(productionConfig, descriptor) || + (descriptor.provider === "apple" && nativeAppleConfiguration?.status === "ready"), + ) .map((descriptor) => descriptor.provider); const pendingOAuthDescriptor = oauthProviderDescriptors.find( (descriptor) => !completedOAuthProviders.includes(descriptor.provider), @@ -235,6 +280,16 @@ export async function resolveLiveDeploySnapshot( componentStatus: deployComponentStatusFromDomainStatus(deployStatus), unsupportedOAuthProviderCount: unsupported.length, unsupportedOAuthProviders: unsupported, + ...(nativeAppleConfiguration && + "bundleId" in nativeAppleConfiguration && + isNativeAppleReadinessIssue(nativeAppleConfiguration.status) + ? { + nativeAppleReadinessIssue: { + bundleId: nativeAppleConfiguration.bundleId, + reason: nativeAppleConfiguration.status, + }, + } + : {}), }; const domainComplete = deployStatus.status === "complete"; @@ -386,6 +441,7 @@ export function buildDeployStatusReport( snapshot.productionInstanceId ? domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId) : null, + snapshot.nativeAppleReadinessIssue, ), }; } @@ -426,13 +482,29 @@ function deployNextAction( componentStatus: DeployComponentStatus, oauthPending: string[], domainsUrl: string | null, + nativeAppleReadinessIssue?: NativeAppleReadinessIssue, ): string { const domainsAction = domainsUrl ? ` ${domainSettingsNextAction(domainsUrl)}` : ""; + const nativeAppleAction = nativeAppleReadinessIssue + ? nativeAppleReadinessNextAction(nativeAppleReadinessIssue) + : ""; if (state === "complete") { return `Production is deployed and verified at https://${domain}. No action needed.${domainsAction}`; } if (state === "oauth_pending") { + if (nativeAppleReadinessIssue) { + const hostedPending = oauthPending.filter((provider) => provider !== "apple"); + const hostedAction = + hostedPending.length > 0 + ? ` These OAuth providers are also missing production credentials: ${hostedPending.join(", ")}.` + : ""; + return ( + `Domain verified, but setup is incomplete. ${nativeAppleAction}${hostedAction} ` + + "After resolving those items, run `clerk deploy status` again." + + domainsAction + ); + } return ( `Domain verified, but these OAuth providers are missing production credentials: ` + `${oauthPending.join(", ")}. Ask the user to finish \`clerk deploy\`, then run \`clerk deploy status\`.` + @@ -449,14 +521,45 @@ function deployNextAction( if (pendingComponents.length === 0) { return ( `Production setup for ${domain} is still finalizing on Clerk's side. ` + - `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` + `Re-run \`clerk deploy status\` in a few minutes.${domainsAction}` + + (nativeAppleAction ? ` ${nativeAppleAction}` : "") ); } return ( `${pendingComponents.join(", ")} still provisioning for ${domain}. ` + `Re-run \`clerk deploy status\` in a few minutes, DNS propagation can take time.` + - domainsAction + domainsAction + + (nativeAppleAction ? ` ${nativeAppleAction}` : "") + ); +} + +function isNativeAppleReadinessIssue( + status: string, +): status is NativeAppleReadinessIssue["reason"] { + return ( + status === "authentication-disabled" || + status === "registration-missing" || + status === "native-api-disabled" + ); +} + +function nativeAppleReadinessNextAction(issue: NativeAppleReadinessIssue): string { + if (issue.reason === "authentication-disabled") { + return ( + `Apple is not explicitly enabled for authentication on the production instance for ${issue.bundleId}. ` + + "Review the Apple connection in the Clerk Dashboard; no web credentials should be added for a native-only setup." + ); + } + if (issue.reason === "native-api-disabled") { + return ( + `Native API is disabled on the production instance for ${issue.bundleId}. ` + + "Enable it at https://dashboard.clerk.com/~/native-applications; the CLI will not infer an App ID Prefix." + ); + } + return ( + `Native Sign in with Apple is missing an exact production iOS Native Application registration for ${issue.bundleId}. ` + + "Register that Bundle ID at https://dashboard.clerk.com/~/native-applications; the CLI will not infer an App ID Prefix." ); } diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index e5aa89f47..383e9e298 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -9,6 +9,7 @@ import { stubFetch, useCaptureLog, } from "../../test/lib/stubs.ts"; +import { resolveFetchedApplicationInstance } from "../../lib/config-instance.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); @@ -29,6 +30,7 @@ mock.module("../../lib/spinner.ts", () => ({ type Profile = { workspaceId: string; appId: string; instances: Record }; const _profiles: Record = {}; +let _resolveAppContextCalls = 0; const INSTANCE_ALIASES: Record = { dev: "development", development: "development", @@ -54,7 +56,9 @@ mock.module("../../lib/config.ts", () => ({ if (!id) throw new Error(`No ${env} instance configured. Run \`clerk link\` to set one up.`); return { id, label: env }; }, + resolveFetchedApplicationInstance, resolveAppContext: async (options: { app?: string; instance?: string; cwd?: string }) => { + _resolveAppContextCalls++; if (options.app) { const app = { application_id: "app_1", @@ -154,6 +158,7 @@ describe("env pull", () => { beforeEach(async () => { Object.keys(_profiles).forEach((k) => delete _profiles[k]); + _resolveAppContextCalls = 0; tempDir = await mkdtemp(join(tmpdir(), "clerk-env-pull-test-")); _setConfigDir(tempDir); process.env.CLERK_PLATFORM_API_KEY = "test_key"; @@ -194,6 +199,160 @@ describe("env pull", () => { return pull(options); } + async function resolveKeys( + options: { + app?: string; + instance?: string; + cwd?: string; + includeSecretKey?: boolean; + } = {}, + ) { + const { resolveEnvironmentKeys } = await import("./pull.ts"); + return resolveEnvironmentKeys(options); + } + + test("resolves the linked development publishable key in memory without requesting secrets", async () => { + await setProfile(tempDir, { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev", production: "ins_prod" }, + }); + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApplication), { status: 200 }); + }); + + const keys = await resolveKeys({ cwd: tempDir }); + + expect(keys).toEqual({ + appId: "app_1", + instanceId: "ins_dev", + instanceLabel: "development", + publishableKey: "pk_test_abc123", + }); + expect(new URL(requestedUrl).searchParams.has("include_secret_keys")).toBe(false); + expect(await Bun.file(join(tempDir, ".env")).exists()).toBe(false); + expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + expect(captured.out).not.toContain("pk_test_abc123"); + expect(captured.err).not.toContain("pk_test_abc123"); + expect(captured.out).not.toContain("sk_test_xyz789"); + expect(captured.err).not.toContain("sk_test_xyz789"); + }); + + test("returns a secret key only when explicitly requested", async () => { + await setProfile(tempDir, { + workspaceId: "org_1", + appId: "app_1", + instances: { development: "ins_dev" }, + }); + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApplication), { status: 200 }); + }); + + const keys = await resolveKeys({ cwd: tempDir, includeSecretKey: true }); + + expect(keys.secretKey).toBe("sk_test_xyz789"); + expect(new URL(requestedUrl).searchParams.get("include_secret_keys")).toBe("true"); + expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + }); + + test("resolves an explicit app's development key with one public-only request and no profile lookup", async () => { + const exactApp = { + application_id: "app_exact", + instances: [mockApplication.instances[1], mockApplication.instances[0]], + }; + const requestedUrls: string[] = []; + stubFetch(async (input) => { + requestedUrls.push(input.toString()); + return new Response(JSON.stringify(exactApp), { status: 200 }); + }); + + const keys = await resolveKeys({ + app: "app_exact", + cwd: join(tempDir, "unlinked"), + includeSecretKey: true, + }); + + expect(keys).toEqual({ + appId: "app_exact", + instanceId: "ins_dev", + instanceLabel: "development", + publishableKey: "pk_test_abc123", + }); + expect(requestedUrls).toHaveLength(1); + const requestedUrl = new URL(requestedUrls[0]!); + expect(requestedUrl.pathname).toEndWith("/v1/platform/applications/app_exact"); + expect(requestedUrl.searchParams.has("include_secret_keys")).toBe(false); + expect(_resolveAppContextCalls).toBe(0); + expect(keys).not.toHaveProperty("secretKey"); + expect(await Bun.file(join(tempDir, ".env")).exists()).toBe(false); + expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); + expect(captured.out).not.toContain("pk_test_abc123"); + expect(captured.err).not.toContain("pk_test_abc123"); + expect(captured.out).not.toContain("sk_test_xyz789"); + expect(captured.err).not.toContain("sk_test_xyz789"); + }); + + test("uses canonical instance selection for an explicit app", async () => { + let requestCount = 0; + stubFetch(async () => { + requestCount++; + return new Response(JSON.stringify(mockApplication), { status: 200 }); + }); + + const keys = await resolveKeys({ app: "app_1", instance: "prod" }); + + expect(keys).toEqual({ + appId: "app_1", + instanceId: "ins_prod", + instanceLabel: "production", + publishableKey: "pk_live_abc123", + }); + expect(requestCount).toBe(1); + expect(_resolveAppContextCalls).toBe(0); + }); + + test("propagates an inaccessible explicit-app fetch without logging credentials", async () => { + const publishableKey = "pk_test_must_not_be_logged"; + const secretKey = "sk_test_must_not_be_logged"; + let requestCount = 0; + stubFetch(async () => { + requestCount++; + return new Response( + JSON.stringify({ + errors: [ + { + code: "resource_not_found", + message: "Application is inaccessible", + meta: { publishableKey, secretKey }, + }, + ], + }), + { status: 404 }, + ); + }); + + let thrown: unknown; + try { + await resolveKeys({ app: "app_inaccessible" }); + } catch (error) { + thrown = error; + } + + const { PlapiError } = await import("../../lib/errors.ts"); + expect(thrown).toBeInstanceOf(PlapiError); + expect((thrown as { context?: string }).context).toBe("Failed to fetch API keys"); + expect(requestCount).toBe(1); + expect(_resolveAppContextCalls).toBe(0); + expect(captured.out).not.toContain(publishableKey); + expect(captured.err).not.toContain(publishableKey); + expect(captured.out).not.toContain(secretKey); + expect(captured.err).not.toContain(secretKey); + }); + test("errors when no profile is linked", async () => { await expect(runEnvPull()).rejects.toThrow("No Clerk project linked"); }); @@ -670,12 +829,18 @@ describe("env pull", () => { // Replace beforeEach's Express package.json with a native Xcode project marker. await rm(join(tempDir, "package.json"), { force: true }); await mkdir(join(tempDir, "MyApp.xcodeproj"), { recursive: true }); + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApplication), { status: 200 }); + }); await runEnvPull(); const content = await Bun.file(join(tempDir, ".env")).text(); expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_abc123"); expect(content).not.toContain("CLERK_SECRET_KEY"); + expect(new URL(requestedUrl).searchParams.has("include_secret_keys")).toBe(false); }); describe("keyless", () => { diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index 62b977fbd..477bf0e69 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -1,5 +1,9 @@ import { resolve, join, basename } from "node:path"; -import { resolveAppContext, type AppContextOptions } from "../../lib/config.ts"; +import { + resolveAppContext, + resolveFetchedApplicationInstance, + type AppContextOptions, +} from "../../lib/config.ts"; import { fetchApplication } from "../../lib/plapi.ts"; import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "../../lib/dotenv.ts"; import { @@ -25,6 +29,27 @@ interface EnvPullOptions extends AppContextOptions { file?: string; } +export interface ResolveEnvironmentKeysOptions { + /** Directory whose linked Clerk profile should be resolved. */ + cwd?: string; + /** Application ID to resolve directly without consulting a linked profile. */ + app?: string; + /** Instance alias or ID. Defaults to the linked development instance. */ + instance?: string; + /** Request the instance secret key as well as its publishable key. */ + includeSecretKey?: boolean; +} + +export interface ResolvedEnvironmentKeys { + appId: string; + instanceId: string; + instanceLabel: string; + publishableKey: string; + secretKey?: string; +} + +type ResolvedAppContext = Awaited>; + /** Check whether a file contains Clerk keys (for backwards compat detection). */ async function hasClerkKeys(path: string): Promise { const file = Bun.file(path); @@ -55,6 +80,72 @@ async function resolveTargetFile( return fallback; } +/** + * Resolve an application's selected instance keys without writing them or + * logging their values. With no explicit instance, linked profiles resolve to + * their development instance. Secret keys are neither requested nor returned + * unless the caller opts in. + * + * An explicit application is always resolved through a public-only request. + * This path never consults the current directory's linked profile and ignores + * `includeSecretKey`, so callers can safely resolve client-side credentials. + * + * `resolvedContext` lets command orchestrators that already resolved the + * instance reuse that result without repeating profile or application lookup. + */ +export async function resolveEnvironmentKeys( + options: ResolveEnvironmentKeysOptions, + resolvedContext?: ResolvedAppContext, +): Promise { + if (options.app) { + const app = await withApiContext( + fetchApplication(options.app, { includeSecretKeys: false }), + "Failed to fetch API keys", + ); + const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); + if (!resolved.found) { + throw new CliError( + `Instance ${resolved.instanceId} not found in application ${options.app}.`, + { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }, + ); + } + + return { + appId: options.app, + instanceId: resolved.instanceId, + instanceLabel: resolved.instanceLabel, + publishableKey: resolved.instance.publishable_key, + }; + } + + const cwd = options.cwd ?? process.cwd(); + const ctx = resolvedContext ?? (await resolveAppContext({ instance: options.instance, cwd })); + const app = await withApiContext( + fetchApplication(ctx.appId, { includeSecretKeys: options.includeSecretKey === true }), + "Failed to fetch API keys", + ); + + const matched = app.instances.find((instance) => instance.instance_id === ctx.instanceId); + if (!matched) { + throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } + + return { + appId: ctx.appId, + instanceId: matched.instance_id, + instanceLabel: ctx.instanceLabel, + publishableKey: matched.publishable_key, + ...(options.includeSecretKey === true && + matched.secret_key && { secretKey: matched.secret_key }), + }; +} + export async function pull(options: EnvPullOptions): Promise { await withGutter("Pulling environment variables", async () => { const cwd = options.cwd ?? process.cwd(); @@ -68,36 +159,30 @@ export async function pull(options: EnvPullOptions): Promise { return; } - const [ctx, preferredEnvFile] = await Promise.all([ + const [ctx, preferredEnvFile, framework] = await Promise.all([ resolveAppContext({ ...options, cwd }), detectEnvFile(cwd), + detectFramework(cwd), ]); const targetFile = await resolveTargetFile(cwd, options.file, preferredEnvFile); const displayPath = options.file ?? basename(targetFile); + // Native platforms configure Clerk with only the publishable key. Avoid + // requesting a secret key that they cannot use; npm/server projects retain + // the existing key-pair behavior. + const includeSecretKey = isNpmFramework(framework ?? {}); await withSpinner(`Pulling env vars from ${ctx.instanceLabel} instance...`, async () => { - const app = await withApiContext(fetchApplication(ctx.appId), "Failed to fetch API keys"); - - const matched = app.instances.find((i) => i.instance_id === ctx.instanceId); - if (!matched) { - throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - docsUrl: "https://clerk.com/docs/guides/development/managing-environments", - }); - } + const keys = await resolveEnvironmentKeys( + { cwd, instance: options.instance, includeSecretKey }, + ctx, + ); const publishableKeyName = await detectPublishableKeyName(cwd); const secretKeyName = await detectSecretKeyName(cwd); - // Native platforms (iOS/Android) configure Clerk with only the publishable - // key in client source; a secret key has no use there and their default - // .gitignore templates don't cover .env, so skip writing it entirely - // rather than leaving a live credential in a tracked file. - const framework = await detectFramework(cwd); - const includeSecretKey = isNpmFramework(framework ?? {}); await mergeKeysIntoEnvFile(targetFile, { - [publishableKeyName]: matched.publishable_key, - ...(matched.secret_key && includeSecretKey && { [secretKeyName]: matched.secret_key }), + [publishableKeyName]: keys.publishableKey, + ...(keys.secretKey && { [secretKeyName]: keys.secretKey }), }); }); diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 5da4c01b3..bf2614cb4 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -18,6 +18,13 @@ clerk init --keyless --fresh clerk init -y clerk init --yes clerk init --no-skills +clerk init --target MyApp +clerk init --target MyApp --yes +clerk init --target MyApp --prebuilt-auth-ui +clerk init --target MyApp --sign-in-with-apple +clerk init --dry-run +clerk init --dry-run --target MyApp +clerk init --dry-run --target MyApp --json ``` ## Options @@ -33,52 +40,101 @@ clerk init --no-skills | `--login` | Force the authenticated flow: log in (interactively if needed) and link a real application instead of keyless keys. Errors in agent mode when unauthenticated (agents can't run OAuth) | | `--template ` | Pre-configure the keyless application at creation: `b2b-saas`, `b2c-saas`, `native`, `waitlist`. Only applies when the run resolves to keyless — errors otherwise (see [Application templates](#application-templates)); cannot be combined with `--login` | | `--fresh` | Replace an existing unclaimed keyless application with a new one, instead of keeping it (see [Keyless breadcrumb](#keyless-breadcrumb)). Only applies when the run resolves to keyless — errors otherwise; cannot be combined with `--login` | +| `--dry-run` | Inspect an existing native iOS project and print a semantic Clerk setup plan without changing local or remote state | +| `--json` | Emit the `--dry-run` inspection and setup plan as structured JSON. Implied in agent mode; requires `--dry-run` | +| `--target ` | Select an iOS application target by target name or PBX object ID for either inspection or setup | +| `--allow-dirty` | Allow iOS setup to update a planned local file that already has changes. Existing bytes still participate in stale-plan and atomic-write validation | +| `--app-id-prefix ` | Apple App ID Prefix to use if the selected iOS Bundle ID needs a new Clerk registration. Never inferred from `DEVELOPMENT_TEAM`; required in agent mode when local/remote evidence cannot supply it | +| `--sign-in-with-apple` | Opt into native Sign in with Apple for the selected iOS target. Adds the exact Apple entitlement and enables the matching native Clerk connection; never requests hosted/web Apple credentials | +| `--prebuilt-auth-ui` | Opt into ClerkKitUI's prebuilt authentication UI for an untouched, safely inspectable SwiftUI starter. Existing or customized application UI is preserved and returned for review instead of being rewritten | | `-y, --yes` | Skip y/n confirmation prompts only. It neither forces nor bypasses keyless — the strategy is picked by auth state, mode, and flags. It does **not** replace an existing unclaimed keyless app — that still requires `--fresh` | | `--no-skills` | Skip the optional agent skills install prompt at the end of init | +## Read-only iOS inspection + +`clerk init --dry-run` takes a separate, read-only path for existing native iOS projects. It inspects Xcode projects and workspaces, application targets and build configurations, Swift Package Manager linkage, target source membership, Swift Clerk setup, entitlements, and locally configured `CLERK_PUBLISHABLE_KEY` metadata. It then prints an ordered setup plan with a top-level status of `ready`, `action-required`, or `blocked`. + +Publishable-key discovery is target-aware. The inspector can recognize an inline key passed directly to `Clerk.configure(publishableKey:)`, an enabled Run-scheme environment variable, a target-owned `LocalSecrets.plist`, project `.env` files, and a local Clerk keyless breadcrumb. It distinguishes a key that is available to copy from one that the inspected Swift startup code is known to consume. Output contains only redacted source evidence plus the decoded Frontend API host needed for Associated Domains; it never contains the publishable key itself. + +The command does not authenticate, call Clerk APIs, run Xcode, resolve packages, send command telemetry, check for CLI updates, or write project/global CLI files. Publishable key values are never included in output. Flags that imply project creation or already-known remote application state (`--starter`, `--app`, `--app-id-prefix`, `--keyless`, `--login`, `--template`, and `--fresh`) are rejected before inspection. `--sign-in-with-apple` is allowed because dry-run previews only the local entitlement; it reports the Clerk connection as not inspected until a regular authenticated run. + +When multiple iOS application targets are present, the plan is `blocked` until one is selected with `--target `. A blocked plan still exits successfully because the inspection completed; automation should branch on the JSON `status` field. + +## Native iOS local setup + +For a native iOS project, normal `clerk init` re-runs the semantic inspection, builds the complete local plan, previews it with the publishable key redacted, and asks for consent before authentication or local writes. It reuses an existing verified local or remote clerk-ios package when possible; otherwise it adds the official `https://github.com/clerk/clerk-ios` Swift package. For an untouched Clerk integration, it links both `ClerkKit` and `ClerkKitUI` to the exact selected application target so the optional prebuilt `AuthView` path is available. Existing source-proven custom-flow projects remain `ClerkKit`-only unless their source or Xcode graph already requires `ClerkKitUI`. + +For a safely inspectable fresh SwiftUI target, the same command selects or creates a Clerk application, fetches only its development publishable key, adds `import ClerkKit`, configures Clerk directly in the single shipping `@main` initializer, and adds `.environment(Clerk.shared)` to the proven `WindowGroup` root. The key is public client configuration and is written directly to Swift source, matching the iOS Quickstart. It remains in memory until commit and is never printed, returned in JSON, sent to telemetry, or written through an intermediate `.env` or plist. Existing inline keys are compared with the selected application's key and never replaced on a mismatch. + +Existing proven LocalSecrets and ProcessInfo/Run-scheme integrations remain compatibility paths and are never migrated automatically. A proven LocalSecrets placeholder may still receive the linked development key through its target-owned plist; an existing valid value is verified first. Different valid keys, tracked/shared/malformed plists, custom configuration expressions, generated projects, ambiguous targets or startup structures, unsafe paths, and stale inputs are preserved and require review. + +The CLI previews every planned local path and asks once before writing. Human users can pass `--yes` to skip that confirmation. Agent/non-TTY mode must pass `--yes` explicitly for iOS mutations; agent mode never implies consent here. A planned file with existing Git changes is refused unless `--allow-dirty` is also explicit, and `--yes` does not imply `--allow-dirty`. + +The package graph and direct Swift edits are prepared in memory, staged beside their destination files, committed together after exact app/key resolution, and re-inspected as one rollback-aware local transaction. Re-running an already-complete target is byte-for-byte a no-op. The command does not run Xcode, resolve package versions, build the app, edit `Package.resolved`, change signing, or request a secret key. XcodeGen and Tuist output is not edited; update the generator's source specification instead. + +When every selected-target build configuration already points to a readable, target-exclusive XML entitlements file, `clerk init` can add the exact bare `webcredentials:` value to all of those files. When every configuration is missing entitlements and the selected target has exactly one exclusive filesystem-synchronized source root, it can instead create a minimal `/.entitlements` file and attach it with iPhone-device and iPhone-simulator-qualified build settings. Those qualified settings do not affect macOS, visionOS, or other platforms in a multiplatform target. Existing files preserve unrelated entitlements, comments, newline style, and file modes, and all eligible entitlement changes commit in the same stale-input and rollback-aware transaction as the SDK and direct Swift edits. A `?mode=developer` entry is preserved but does not replace the bare entry. Mixed or conflicting entitlements paths, classic or shared destination ambiguity, generated projects, unresolved build settings, malformed or binary plists, and paths outside the invocation root remain review steps. + +The read-only output also includes a native-readiness section for the Bundle ID, literal App ID Prefix evidence, and local Associated Domains coverage. Because `--dry-run` is strictly local-only, remote Native API and iOS registration state is reported as `not-inspected`. A regular authenticated run audits those resources through the Platform API after the local preview. + +If the linked development instance needs remote changes, `clerk init` prints a second, exact plan and asks separately before making them. Existing registrations are never updated or deleted. When a registration is missing, the CLI uses a consistently proven literal App ID Prefix, an explicit `--app-id-prefix`, or a human-entered value. If every selected-target configuration has the same valid `DEVELOPMENT_TEAM`, human mode offers it as a clearly labeled, unverified suggestion and lets the user enter a different prefix; it is never treated as proven evidence or selected non-interactively. Conflicting local evidence or an existing registration with a different prefix blocks before local files are committed. After consent, the guarded local transaction commits first, remote state is re-read, the exact iOS registration is created, Native API is enabled last, and both resources are verified. Remote retries are additive and idempotent: if a remote step fails after local commit, local changes remain and rerunning safely reconciles the remaining work. + +Native Sign in with Apple is an explicit opt-in, either through the human prompt or `--sign-in-with-apple`; `--yes` alone never enables it. The local transaction adds only `com.apple.developer.applesignin = ["Default"]` to every proven selected-target entitlements route. After the exact iOS registration and Native API are ready, the CLI enables the Apple connection for that exact Bundle ID and verifies the final config. It neither asks for nor changes an Apple Services ID, Team ID, Key ID, or private key. Existing hosted Apple fields are preserved. With ClerkKitUI, `AuthView` displays Apple automatically; a custom flow can call `try await Clerk.shared.auth.signInWithApple()`. + +The prebuilt authentication UI is also an explicit, independent opt-in. `--yes`, agent mode, ClerkKitUI linkage, and `--sign-in-with-apple` never select it by themselves. `--prebuilt-auth-ui` can rewrite only the exact untouched SwiftUI starter screen owned by the selected target; existing navigation, state, custom authentication, partial ClerkKitUI integrations, and established application content are preserved and reported as a review step. The generated screen matches the documented native-components quickstart: a `UserButton` signed-out entry presents `AuthView` in a sheet and prefetches Clerk images. It does not gate or replace established application content. Clerk's native components require iOS 17 and the modern ClerkKit/ClerkKitUI products available in clerk-ios 1.0.0 or newer. Before committing an opted-in UI, the authenticated run also inspects the linked Frontend API environment without printing its publishable key; when Apple is already enabled and authenticatable, the same pre-authorized local transaction verifies or adds the required Apple entitlement without changing the remote Apple strategy. + ## Agent Mode When running in agent mode (`--mode agent` or non-TTY), the command runs the full init flow non-interactively: -- All confirmation prompts are auto-skipped (as if `--yes` was passed) +- Confirmation prompts are generally auto-skipped, but changing a native iOS Xcode project requires an explicit `--yes` +- Native iOS remote mutations also require explicit `--yes`; when no existing registration or complete literal evidence supplies the App ID Prefix, pass `--app-id-prefix` +- Native Sign in with Apple additionally requires `--sign-in-with-apple`; `--yes` grants mutation consent but never opts a project into an authentication strategy +- The prebuilt iOS authentication UI additionally requires `--prebuilt-auth-ui`; `--yes` and agent mode never opt into replacing even an eligible starter screen +- `init --dry-run` automatically emits structured JSON, even when `--json` is omitted - For **existing projects**: framework and package manager are auto-detected, no flags required - For **new projects** (`--starter` or blank directory): `--framework` is required (no way to auto-detect in an empty dir). Package manager is auto-selected by availability (bun → pnpm → yarn → npm) unless `--pm` is provided - Project name defaults to the framework's default (e.g. `my-clerk-next-app`) unless `--name` is provided - For keyless-capable frameworks with no `--app` and no linked profile: - When **authenticated**, init creates a real Clerk app named after the project (`package.json#name`, `--name`, or directory basename) and links it. - When **unauthenticated**, init uses keyless: the app runs on auto-generated dev keys, and init writes a `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically. -- For frameworks that require API keys, init will not pick or create an app in agent mode; pass `--app ` or link the project first to pull real keys +- For frameworks that require API keys, agent mode normally requires `--app ` or an existing link. A safely inspectable fresh iOS target is the exception: with valid credentials and explicit `--yes`, init can create and link the development application needed by the approved direct-source plan - `--login` while unauthenticated exits with a usage error (agents can't complete the interactive browser login) -- Agent mode never trusts the mere _presence_ of a stored credential the way human mode does — a stored session that turns out to be expired/broken (e.g. keyring holds a stale OAuth session) is validated before init decides it's "authenticated". A broken credential is treated as unauthenticated, which routes a keyless-capable framework to keyless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login +- Agent mode never trusts the mere _presence_ of a credential before native iOS mutation. A Platform API key is validated with a read-only application-list request, and a stored OAuth session must still resolve to a user. Invalid credentials stop native iOS setup before local apply. Elsewhere, a broken credential is treated as unauthenticated, which routes a keyless-capable framework to keyless instead of blocking on a browser OAuth round-trip an agent can never complete. If `--login` (or a real app target) forces the authenticated flow anyway and the credential turns out broken, init exits with a usage error instead of attempting an interactive login - Agent mode never mints a fresh keyless application over an existing unclaimed one on re-run — see [Keyless breadcrumb](#keyless-breadcrumb) ## Flow +`--dry-run` first detects an existing native iOS project, performs the read-only inspection described above, prints its setup plan, and returns before authentication, linking, SDK installation, scaffolding, or any other setup work. + +The normal setup flow is: + 1. Gathers project context (framework, router variant, TypeScript, `src/` directory, package manager) -2. Determines the strategy (in precedence order). In agent mode, "authenticated" here means a _validated_ credential (a real `CLERK_PLATFORM_API_KEY`, or a stored session that still exchanges for a valid token) — not just the presence of something in the keyring, since agent mode has no interactive fallback if a stale credential turns out to be unusable: +2. **Native iOS only**: validates iOS-specific flags, resolves the current local Clerk profile, inspects the selected target, and previews the complete redacted SDK plus Swift/runtime configuration plan. It obtains one aggregate consent but writes nothing. Agent credentials may be validated with a read-only API call before this preview so an invalid non-interactive invocation cannot proceed; interactive login, application selection/creation, key fetching, and every local write remain after consent +3. Determines the strategy (in precedence order). In agent mode, "authenticated" here means a _validated_ credential (a Platform API key accepted by a read-only PLAPI request, or a stored session that still exchanges for a valid token) — not just the presence of something in the keyring, since agent mode has no interactive fallback if a stale credential turns out to be unusable: - **`--keyless`**: forces keyless mode, even when logged in. Only valid on a keyless-capable framework, and cannot be combined with `--login` or `--app` (usage errors otherwise). The app runs on auto-generated dev keys; init writes a `.clerk/keyless.json` breadcrumb so the next `clerk auth login` claims the app automatically - **`--login`**: forces the authenticated flow. In agent mode while unauthenticated (or while stored credentials are broken) this exits with a usage error, since agents can't complete the interactive browser login - - **Real app target** (`--app` or linked profile): authenticates, links if needed, and pulls real API keys into `.env` + - **Real app target** (`--app`, linked profile, or an approved fresh iOS direct-source plan): authenticates and links if needed, then configures the native runtime directly or pulls API keys for frameworks that consume an env file - **Agent + non-keyless framework + no real app target**: scaffolds locally and prints manual setup instructions instead of selecting or creating an app - **Agent + keyless-capable framework + authenticated + no real app target**: creates a real Clerk app named after the project, links it, and pulls real API keys into `.env` - **Agent + keyless-capable framework + unauthenticated + no real app target**: uses keyless mode — the app runs on auto-generated dev keys and the breadcrumb lets the next `clerk auth login` claim it. A broken/stale stored credential (present in the keyring but no longer valid) is treated the same as unauthenticated, so this is also the fallback when the presence-only check would have wrongly said "authenticated" - **Human mode + bootstrap + keyless-capable framework + not authenticated**: uses keyless mode - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled. `-y` does not bypass this — it only suppresses y/n confirmation prompts, not authentication - `--template` and `--fresh` are rejected with a usage error whenever the resolved strategy above isn't keyless — see [Application templates](#application-templates) and [Keyless breadcrumb](#keyless-breadcrumb) -3. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links the project via `clerk link` (skipped if already linked) -4. Displays detected framework and variant -5. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance -6. Installs the appropriate Clerk SDK (skips if already present) -7. Generates a scaffold plan for the detected framework -8. Warns if the git working tree has uncommitted changes -9. Previews planned file changes and asks for confirmation -10. Writes scaffold files to disk -11. Runs project formatters (Prettier/Biome) on generated files -12. Scans for issues: hardcoded keys, leftover auth-library imports, stale API calls -13. Prints a summary of created, modified, and skipped files with recommendations -14. **Authenticated mode**: pulls development instance API keys via `clerk env pull` -15. **Keyless mode** (unauthenticated runs whose resolved strategy in step 2 is keyless — an unauthenticated human-mode rerun on an existing project resolves to the authenticated flow instead): mints a keyless application and prints instructions for development without API keys and how to connect a Clerk account later — unless an unclaimed keyless app already exists for this project (see [Re-running init on an already-keyless project](#re-running-init-on-an-already-keyless-project)), in which case the existing keys are kept and reported instead -16. Optionally installs Clerk agent skills (cli + core + features, plus a framework-specific skill) via the project's package runner (see [Agent skills install](#agent-skills-install)) +4. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links or creates/selects the project application via `clerk link` +5. **Eligible native iOS only**: resolves the newly linked application by its exact ID, fetches only its public development key, and audits Native API, iOS registration, the selected prebuilt AuthView environment, and any explicitly requested native Apple connection before writing. It then prepares the approved PBX, Swift, and entitlements candidates again. A fresh target commits the eligible files through one rollback-aware transaction and re-inspects the result. This can include creating and attaching one entitlements file for an exclusive filesystem-synchronized target root, replacing only an explicitly selected pristine starter screen with the documented `UserButton` and `AuthView` sheet, and adding the exact native Apple entitlement when required by either explicit Apple setup or an already-enabled Apple button. A proven existing LocalSecrets path uses its compatibility transaction. The key is never resolved through mutable current-directory profile state, printed, or copied through dotenv. Additive remote Native Application changes run after the local commit; native Apple is enabled last and final state is re-read +6. Displays detected framework and variant +7. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance +8. Installs the appropriate Clerk SDK (skips if already present) +9. Generates a scaffold plan for the detected framework +10. Warns if the git working tree has uncommitted changes +11. Previews planned file changes and asks for confirmation +12. Writes scaffold files to disk +13. Runs project formatters (Prettier/Biome) on generated files +14. Scans for issues: hardcoded keys, leftover auth-library imports, stale API calls +15. Prints a summary of created, modified, and skipped files with recommendations +16. **Authenticated mode**: pulls development instance API keys via `clerk env pull` for frameworks that consume dotenv files. Native iOS either completes the proven runtime-key handoff or leaves key storage unchanged +17. **Keyless mode** (unauthenticated runs whose resolved strategy in step 3 is keyless — an unauthenticated human-mode rerun on an existing project resolves to the authenticated flow instead): mints a keyless application and prints instructions for development without API keys and how to connect a Clerk account later — unless an unclaimed keyless app already exists for this project (see [Re-running init on an already-keyless project](#re-running-init-on-an-already-keyless-project)), in which case the existing keys are kept and reported instead +18. Optionally installs Clerk agent skills (cli + core + features, plus a framework-specific skill) via the project's package runner (see [Agent skills install](#agent-skills-install)) ## Framework Detection @@ -100,12 +156,12 @@ Detects the project's framework from `package.json` dependencies (checked top-to Native mobile platforms may not have a `package.json`, so they are detected from project marker files when no npm framework matches: -| Marker files | Framework | Clerk SDK | Publishable Key Env Var | -| ------------------------------------------------------------------- | ---------------- | ------------------------------------- | ----------------------- | -| `*.xcodeproj` / `*.xcworkspace` | iOS (Swift) | `ClerkKit` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` | -| `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` | +| Marker files | Framework | Clerk SDK | Publishable Key Env Var | +| ------------------------------------------------------------------- | ---------------- | ------------------------------------------------- | ----------------------- | +| `*.xcodeproj` / `*.xcworkspace` | iOS (Swift) | `ClerkKit` + `ClerkKitUI` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` | +| `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` | -A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. For native platforms the Clerk SDK cannot be installed by a JS package manager, so init skips the SDK install step and the scaffold plan prints Swift Package Manager / Gradle install steps instead. The publishable key is configured in source code (`Clerk.configure(...)` / `Clerk.initialize(...)`), so init still pulls keys into the env file and instructs the user to copy the key over. +A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. Native SDKs are not installed by a JavaScript package manager. For iOS, init can edit the selected target's Swift Package Manager graph directly. New and source-blank core-only integrations receive both ClerkKit and ClerkKitUI for the prebuilt authentication path; a source-proven custom integration stays ClerkKit-only. A safely inspectable fresh SwiftUI target is configured directly in its shipping `@main` source. Existing LocalSecrets and ProcessInfo integrations remain compatibility paths. Android still prints the Gradle installation steps. The **Keyless** column indicates whether the framework's Clerk SDK supports keyless mode (auto-generated temporary dev keys). Keyless is the default for unauthenticated runs on Yes-row frameworks — during bootstrap (new projects) in human mode, and in all agent-mode runs. In human mode, an unauthenticated re-run in an existing project still triggers the authenticated flow. `--keyless` forces keyless anywhere a Yes-row framework is detected (existing projects included, even when logged in); passing it for a No-row framework exits with a usage error. In agent mode, an authenticated run on a keyless-capable framework creates a real app named after the project and links it. @@ -113,7 +169,7 @@ Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `ya ## Scaffolding -Scaffolding is supported for every detected framework. iOS and Android write no files (their SDKs are not npm packages and their build files are not safe to modify automatically) — instead they print the exact quickstart steps as post-instructions. +Scaffolding is supported for every detected framework. The dedicated iOS preflight may safely update the selected Xcode target's Swift package graph and authorize an exact runtime-key destination before generic scaffolding; remaining iOS work and all Android native setup are printed as post-instructions. All scaffolding is idempotent — files are skipped if they already contain Clerk setup. @@ -232,7 +288,7 @@ Express and Fastify share the server-entry scaffolding in [`node-server.ts`](./f ### iOS (Swift) / Android (Kotlin) -No files are written. The scaffold plan prints the quickstart steps: SDK install (Swift Package Manager for `ClerkKit`/`ClerkKitUI`, Gradle for `com.clerk:clerk-android-*`), enabling the Native API and registering the app on the Dashboard's Native Applications page, and configuring the publishable key in source (`Clerk.configure(...)` / `Clerk.initialize(...)`) by copying it from the pulled env file. +For iOS, the dedicated setup phase links both `ClerkKit` and `ClerkKitUI` for a fresh target so the optional prebuilt authentication path is available. It also upgrades a source-blank target left ClerkKit-only by an earlier setup, while preserving a source-proven ClerkKit-only custom flow. A safely inspectable fresh SwiftUI target receives direct `@main` Clerk configuration and environment injection; proven LocalSecrets/ProcessInfo projects stay on their existing compatibility path. With explicit `--prebuilt-auth-ui` consent, only an exact untouched SwiftUI starter screen can receive the quickstart `UserButton`, image prefetching, and `AuthView` sheet; established UI is never rewritten. Safe XML entitlements files can receive the exact Associated Domain transactionally, and a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file. The authenticated phase then audits and, with separate consent, additively creates the exact iOS registration and enables Native API for the linked development instance. The optional `--sign-in-with-apple` path composes the native Apple entitlement into that transaction and enables only the exact Bundle ID's Clerk Apple connection. Android prints the Gradle SDK step for `com.clerk:clerk-android-*`. ## Agent skills install diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index 2df6f4670..4b7878503 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -1,10 +1,32 @@ -import { test, expect } from "bun:test"; +import { afterAll, afterEach, test, expect } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { ios } from "./ios.ts"; import type { ProjectContext } from "./types.ts"; +import { createIOSFixture } from "../ios/test-helpers.ts"; + +const temporaryRoots: string[] = []; +const emptyRoot = await mkdtemp(join(tmpdir(), "clerk-ios-framework-empty-")); + +afterAll(() => rm(emptyRoot, { recursive: true, force: true })); + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function makeIOSFixture(complete: boolean, clerkSDK = true): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-")); + temporaryRoots.push(root); + await createIOSFixture(root, { complete, clerkSDK }); + return root; +} function makeCtx(): ProjectContext { return { - cwd: "/tmp/ios-app", + cwd: emptyRoot, framework: { dep: "ios", name: "iOS (Swift)", @@ -36,20 +58,150 @@ test("writes no files and prints the quickstart steps", async () => { expect( plan.postInstructions.some((i) => i.includes("ClerkKit") && i.includes("ClerkKitUI")), ).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("prebuilt AuthView path"))).toBe(true); expect( plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), ).toBe(true); expect(plan.postInstructions.some((i) => i.includes("Clerk.configure"))).toBe(true); - // The official quickstart requires injecting Clerk into the SwiftUI - // environment — views read it back via @Environment(Clerk.self). + expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe( + true, + ); + expect(plan.postInstructions.some((i) => i.includes("--prebuilt-auth-ui"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes(".onOpenURL"))).toBe(false); + // With no inspectable target, keep the guidance explicitly conditional. expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(true); expect(plan.postInstructions.some((i) => i.includes("docs/ios/getting-started/quickstart"))).toBe( true, ); }); -test("references the project's env file for the publishable key", async () => { +test("uses direct @main configuration as the fresh-project default", async () => { const plan = await ios.scaffold({ ...makeCtx(), envFile: ".env.local" }); - expect(plan.postInstructions.some((i) => i.includes(".env.local"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes(".env.local"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("single shipping `@main` App"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("Clerk.configure"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("value redacted"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist"))).toBe(false); + expect( + plan.postInstructions.some( + (i) => i.includes("Run scheme") && i.includes("manual runtime configuration"), + ), + ).toBe(false); +}); + +test("omits manual Native Applications guidance after authenticated remote verification", async () => { + const plan = await ios.scaffold({ ...makeCtx(), iosNativeRemoteReady: true }); + + expect( + plan.postInstructions.some((instruction) => + instruction.includes("dashboard.clerk.com/~/native-applications"), + ), + ).toBe(false); +}); + +test("explains that the prebuilt AuthView exposes Apple automatically after native setup", async () => { + const root = await makeIOSFixture(true); + const plan = await ios.scaffold({ + ...makeCtx(), + cwd: root, + iosTarget: "MyApp", + iosNativeRemoteReady: true, + iosNativeAppleReady: true, + }); + + expect( + plan.postInstructions.some( + (instruction) => + instruction.includes("Native Sign in with Apple is ready") && + instruction.includes("AuthView displays the Apple button automatically"), + ), + ).toBe(true); +}); + +test("keeps a proven LocalSecrets loader as a compatibility path", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-local-secrets-")); + temporaryRoots.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYnot-a-key', + ); + + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist loader"))).toBe(true); + expect( + plan.postInstructions.some((i) => i.includes("single shipping `@main` App initializer")), + ).toBe(false); + expect(plan.postInstructions.some((i) => i.includes(".env"))).toBe(false); +}); + +test("includes SwiftUI environment injection for the default prebuilt path", async () => { + const root = await makeIOSFixture(false); + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe( + true, + ); +}); + +test("keeps existing custom-flow installation and environment guidance core-only", async () => { + const root = await makeIOSFixture(false, false); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + const installInstruction = plan.postInstructions.find((instruction) => + instruction.includes("github.com/clerk/clerk-ios"), + ); + + expect(installInstruction).toContain("link ClerkKit for this existing custom-flow path"); + expect(installInstruction).not.toContain("ClerkKitUI"); + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("custom ClerkKit"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("ClerkKitUI's prebuilt AuthView"))).toBe( + false, + ); +}); + +test("omits SwiftUI environment injection when it is already present", async () => { + const root = await makeIOSFixture(true); + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false); +}); + +test("omits locally satisfied setup instructions for the selected target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-satisfied-")); + temporaryRoots.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const encodedHost = Buffer.from("clerk.example.test$").toString("base64"); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEYpk_test_${encodedHost}`, + ); + + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(plan.postInstructions.some((i) => i.includes("github.com/clerk/clerk-ios"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("Associated Domains"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("Configure Clerk"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe( + false, + ); + expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes(".onOpenURL"))).toBe(false); + expect( + plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), + ).toBe(true); }); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 0562a84e5..25fbd6531 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -1,14 +1,21 @@ import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; +import { planIOSDirectConfig } from "../ios/direct-config.ts"; +import { inspectIOSProject } from "../ios/inspect.ts"; +import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "../ios/plan.ts"; +import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "../ios/products.ts"; +import { planIOSRuntimeKey } from "../ios/runtime-key.ts"; +import { planIOSAssociatedDomain } from "../ios/associated-domain.ts"; /** * iOS (Swift) support for `clerk init`. * * The Clerk iOS SDK ships via Swift Package Manager and the publishable key is * configured in Swift source (`Clerk.configure(publishableKey:)`), not an env - * file — and adding an SPM dependency requires editing the Xcode project - * bundle, which is not safe to automate. So instead of writing files, this - * scaffolder prints the exact quickstart steps; `clerk init` still links the - * app and pulls real keys so the user can copy the publishable key. + * file. The dedicated iOS apply phase safely handles the selected target's SPM + * product linkage before this scaffolder runs. For a safely inspectable fresh + * SwiftUI target, init configures the linked development publishable key + * directly in the shipping @main App source. Existing LocalSecrets and + * ProcessInfo integrations remain supported compatibility paths. * * Docs: https://clerk.com/docs/ios/getting-started/quickstart */ @@ -19,14 +26,173 @@ export const ios: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "ios", async scaffold(ctx: ProjectContext): Promise { + const inspection = await inspectIOSProject(ctx.cwd, { target: ctx.iosTarget }); + const selection = inspection.selection; + const target = + selection.state === "selected" + ? inspection.appTargets.find( + (candidate) => + candidate.id === selection.targetId && + candidate.projectPath === selection.projectPath, + ) + : undefined; + const productDecision = target ? clerkKitUIInstallDecision(target) : "prebuilt"; + const includeClerkKitUI = productDecision === "prebuilt"; + const hasLocalSecretsConfigure = target?.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "local-secrets-loader", + ); + const hasProcessInfoConfigure = target?.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "process-info-environment", + ); + const shouldPlanDirectConfig = + selection.state === "selected" && + target != null && + shouldPlanIOSDirectConfig(inspection, target, productDecision); + const directConfigPlan = + shouldPlanDirectConfig && selection.state === "selected" + ? await planIOSDirectConfig({ + root: ctx.cwd, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const preliminaryPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); + const preliminaryConfigureStep = preliminaryPlan.steps.find( + (step) => step.id === "configure-publishable-key", + ); + const needsRuntimeKeyHandoff = + selection.state === "selected" && + target != null && + preliminaryConfigureStep?.status === "required" && + hasIOSRuntimeKeyHandoffShape(inspection, target); + const runtimeKeyPlan = needsRuntimeKeyHandoff + ? await planIOSRuntimeKey({ + root: ctx.cwd, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const associatedDomainPlan = + selection.state === "selected" + ? await planIOSAssociatedDomain({ + root: ctx.cwd, + projectPath: selection.projectPath, + targetId: selection.targetId, + deferToPublishableKey: + directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + }) + : undefined; + const setupPlan = buildIOSSetupPlan(inspection, { + runtimeKeyPlan: runtimeKeyPlan && { + status: runtimeKeyPlan.status, + blockers: runtimeKeyPlan.blockers, + }, + directConfigPlan, + associatedDomainPlan, + }); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const needsAttention = (id: string) => + setupPlan.steps.find((step) => step.id === id)?.status !== "satisfied"; + const packageIsVerified = + target?.packages.package === "remote" || target?.packages.package === "local"; + const requiredProductsLinked = + target?.packages.clerkKit === "linked" && + (!includeClerkKitUI || target.packages.clerkKitUI === "linked"); + const installInstructions = + productDecision === "unknown" + ? [ + "Swift source membership is incomplete. Confirm whether this target should link ClerkKitUI for prebuilt AuthView or remain ClerkKit-only for a custom flow.", + ] + : packageIsVerified && requiredProductsLinked + ? [] + : packageIsVerified && + target?.packages.clerkKit === "linked" && + includeClerkKitUI && + target.packages.clerkKitUI !== "linked" + ? [ + "Link ClerkKitUI from the existing clerk-ios Swift package for the fastest prebuilt AuthView path", + ] + : includeClerkKitUI + ? [ + "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (link ClerkKit and ClerkKitUI for the fastest prebuilt AuthView path)", + ] + : [ + "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (link ClerkKit for this existing custom-flow path)", + ]; + const requiresSwiftUIEnvironment = + target != null && (target.swift.environmentConsumers.length > 0 || includeClerkKitUI); + const environmentInstructions = needsAttention("inject-clerk-environment") + ? target?.swift.evidenceComplete === true + ? requiresSwiftUIEnvironment + ? [ + "Inject Clerk into the SwiftUI environment so Clerk-aware views can read it via `@Environment(Clerk.self)`: `ContentView().environment(Clerk.shared)`", + ] + : [] + : [ + "If AuthView or another view reads Clerk via `@Environment(Clerk.self)`, inject it with `ContentView().environment(Clerk.shared)`", + ] + : []; + const registrationInstructions = + !ctx.iosNativeRemoteReady && needsAttention("register-native-application") + ? [ + "Enable the Native API and register your iOS app (App ID Prefix + Bundle ID) on the Native Applications page: https://dashboard.clerk.com/~/native-applications", + ] + : []; + const domainInstructions = needsAttention("add-associated-domain") + ? [ + "In Xcode, add the Associated Domains capability with `webcredentials:`", + ] + : []; + const configureInstructions = needsAttention("configure-publishable-key") + ? selection.state === "selected" && configureStep?.status === "blocked" + ? [configureStep.description] + : hasLocalSecretsConfigure + ? [configureStep?.description ?? "Repair the existing LocalSecrets runtime wiring."] + : hasProcessInfoConfigure + ? [ + "Keep the existing ProcessInfo integration connected to CLERK_PUBLISHABLE_KEY in the enabled Run scheme. This is a compatibility path; clerk init does not write or replace Run-scheme variables.", + ] + : [ + 'Configure Clerk directly in the single shipping `@main` App initializer with the selected application\'s development publishable key: `Clerk.configure(publishableKey: "")`. For a safely inspectable SwiftUI target, `clerk init` applies this with the value redacted from previews and output.', + ] + : []; + const authFlowInstructions = needsAttention("add-authentication-flow") + ? [ + productDecision === "core-only" + ? "Complete the signed-out authentication route with the existing custom ClerkKit sign-in/sign-up flow" + : productDecision === "unknown" + ? "Confirm whether the signed-out route should use ClerkKitUI's AuthView or a custom ClerkKit flow" + : "For a pristine SwiftUI placeholder, rerun `clerk init --prebuilt-auth-ui` to add ClerkKitUI's documented UserButton and AuthView sheet; otherwise add a signed-out authentication route with AuthView or a custom ClerkKit flow without replacing existing application UI", + ] + : []; + const nativeAppleInstructions = ctx.iosNativeAppleReady + ? [ + productDecision === "prebuilt" + ? "Native Sign in with Apple is ready; ClerkKitUI's AuthView displays the Apple button automatically" + : productDecision === "core-only" + ? "Native Sign in with Apple is ready; a custom flow can start it with `try await Clerk.shared.auth.signInWithApple()`" + : "Native Sign in with Apple is ready; AuthView displays Apple automatically, while custom flows can call `try await Clerk.shared.auth.signInWithApple()`", + ] + : []; + const callbackInstructions = + needsAttention("wire-auth-callbacks") && productDecision !== "prebuilt" + ? [ + "For redirect-based authentication launched outside AuthView, verify that the app forwards incoming URLs to Clerk", + ] + : []; + return { actions: [], postInstructions: [ - "Add the Clerk iOS SDK via Swift Package Manager: https://github.com/clerk/clerk-ios (add both ClerkKit and ClerkKitUI to your target)", - "Enable the Native API and register your iOS app (App ID Prefix + Bundle ID) on the Native Applications page: https://dashboard.clerk.com/~/native-applications", - "In Xcode, add the Associated Domains capability with `webcredentials:`", - `Configure Clerk in your @main App struct: \`Clerk.configure(publishableKey: "")\` — copy CLERK_PUBLISHABLE_KEY from ${ctx.envFile} after \`clerk env pull\``, - "Inject Clerk into the SwiftUI environment so views can read it via `@Environment(Clerk.self)`: `ContentView().environment(Clerk.shared)`", + ...installInstructions, + ...registrationInstructions, + ...domainInstructions, + ...configureInstructions, + ...nativeAppleInstructions, + ...authFlowInstructions, + ...environmentInstructions, + ...callbackInstructions, "Full setup guide: https://clerk.com/docs/ios/getting-started/quickstart", ], }; diff --git a/packages/cli-core/src/commands/init/frameworks/types.ts b/packages/cli-core/src/commands/init/frameworks/types.ts index b2c162250..a993d6561 100644 --- a/packages/cli-core/src/commands/init/frameworks/types.ts +++ b/packages/cli-core/src/commands/init/frameworks/types.ts @@ -20,6 +20,12 @@ export interface ProjectContext { i18nLocaleDir?: string; /** When true, the project was created via bootstrap (empty repo). Scaffolders may add starter UI. */ isBootstrap?: boolean; + /** Explicit native iOS application target selected by `clerk init --target`. */ + iosTarget?: string; + /** Authenticated remote Native API and iOS registration verification completed. */ + iosNativeRemoteReady?: boolean; + /** Native Sign in with Apple entitlement and Clerk connection verification completed. */ + iosNativeAppleReady?: boolean; } export type FileAction = diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts new file mode 100644 index 000000000..1f70267ea --- /dev/null +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -0,0 +1,1317 @@ +import { test, expect, describe, spyOn } from "bun:test"; + +// Pure spyOn approach — Bun's mock.module globally replaces modules for the +// entire test run, which pollutes other test files that import the same +// modules. spyOn restores cleanly. Shared setup lives in the harness. +import { + useInitHarness, + FAKE_CTX, + loginMod, + linkMod, + pullMod, + config, + frameworkMod, + context, + scaffoldMod, + heuristics, + skillsMod, + bootstrapMod, + iosApplyMod, + nativeRemoteMod, + nativeAppleMod, + plapiMod, + fapiMod, + FAKE_IOS_NATIVE_READINESS, +} from "../../test/lib/init-harness.ts"; +import * as telemetryMod from "../../lib/telemetry.ts"; +import { init } from "./index.ts"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import type { IOSLocalSetupResult } from "./ios/apply.ts"; +import type { IOSAppleEntitlementPlan } from "./ios/apple-entitlement.ts"; +import type { IOSNativeApplePlan } from "./ios/native-apple.ts"; +import type { IOSNativeRemotePlan } from "./ios/native-remote.ts"; +import type { IOSPrebuiltAuthPlan } from "./ios/prebuilt-auth.ts"; + +const VALID_DEVELOPMENT_KEY = `pk_test_${btoa("example.clerk.accounts.dev$")}`; + +function nativeIOSContext() { + return { + ...FAKE_CTX, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; +} + +function iosRemotePlan(overrides: Partial = {}): IOSNativeRemotePlan { + return { + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status: "ready", + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + appIdPrefix: "LEGACY1234", + nativeApi: "required", + registration: "required", + actions: ["Register the iOS application.", "Enable the Native API."], + blockers: [], + ...overrides, + }; +} + +function iosAppleEntitlementPlan( + overrides: Partial = {}, +): IOSAppleEntitlementPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-sign-in-with-apple-entitlement", + status: "ready", + root: "/tmp/test", + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + targetName: "MyApp", + files: [{ path: "MyApp/MyApp.entitlements", operation: "modify", expectedHash: "hash" }], + actions: ["Add the native Sign in with Apple entitlement."], + blockers: [], + ...overrides, + }; +} + +function iosNativeApplePlan(overrides: Partial = {}): IOSNativeApplePlan { + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "ready", + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + configVersion: "v1_1234abcd", + connection: "required", + bundleIdentifierConfiguration: "required", + current: { enabled: false, authenticatable: false }, + desired: { enabled: true, authenticatable: true }, + actions: ["Enable native Sign in with Apple for com.example.MyApp."], + blockers: [], + ...overrides, + }; +} + +function iosPrebuiltAuthPlan(overrides: Partial = {}): IOSPrebuiltAuthPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-prebuilt-auth", + status: "ready", + root: "/tmp/test", + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + allowDirty: false, + appSourcePath: "MyApp/MyAppApp.swift", + expectedAppSourceHash: "app-hash", + sourcePath: "MyApp/ContentView.swift", + expectedSourceHash: "content-hash", + actions: [], + blockers: [], + ...overrides, + }; +} + +function iosSetupResult(overrides: Partial = {}): IOSLocalSetupResult { + return { + targetName: "MyApp", + nativeReadiness: FAKE_IOS_NATIVE_READINESS, + prebuiltAuthRequested: false, + prebuiltAuthActive: false, + nativeAppleRequested: false, + requiresLinkedApp: false, + requiresDevelopmentKey: + overrides.requiresDevelopmentKey ?? overrides.requiresLinkedApp ?? false, + verifiesExistingKey: false, + ...overrides, + }; +} + +describe("init iOS", () => { + const { setup, track } = useInitHarness(); + + function trackStages() { + const stage = spyOn(telemetryMod, "setTelemetryStage"); + track(stage); + return () => stage.mock.calls.map((call) => call[0]); + } + + test("rejects iOS-only apply flags for a non-iOS project before authentication", async () => { + setup({ email: "test@test.com" }); + spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); + + await expect(init({ target: "MyApp" })).rejects.toThrow( + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects", + ); + + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test("rejects agent --app before project mutation when authentication is unavailable", async () => { + setup({ isAgent: true, email: null }); + + await expect(init({ app: "app_requested", yes: true })).rejects.toThrow( + "--app requires authentication", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test.each([ + ["invalid-prefix", "sk_test_sensitive_invalid", 401, "invalid Platform API key"], + ["unauthorized", "ak_test_sensitive_unauthorized", 403, "unauthorized Platform API key"], + ])("rejects an %s before native project mutation", async (_case, key, status, reason) => { + const previous = process.env.CLERK_PLATFORM_API_KEY; + process.env.CLERK_PLATFORM_API_KEY = key; + try { + const { captured } = setup({ isAgent: true, email: null }); + track( + spyOn(plapiMod, "listApplications").mockRejectedValue( + new PlapiError(status, JSON.stringify({ errors: [{ message: reason }] })), + ), + ); + + await expect(init({ app: "app_requested", yes: true })).rejects.toThrow( + "--app requires authentication", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + } finally { + if (previous === undefined) delete process.env.CLERK_PLATFORM_API_KEY; + else process.env.CLERK_PLATFORM_API_KEY = previous; + } + }); + + test("preserves Platform API transport failures before native project mutation", async () => { + const key = "ak_test_sensitive_transport"; + const previous = process.env.CLERK_PLATFORM_API_KEY; + process.env.CLERK_PLATFORM_API_KEY = key; + try { + const { captured } = setup({ isAgent: true, email: null }); + track( + spyOn(plapiMod, "listApplications").mockRejectedValue(new Error("network unavailable")), + ); + + await expect(init({ app: "app_requested", yes: true })).rejects.toThrow( + "network unavailable", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + } finally { + if (previous === undefined) delete process.env.CLERK_PLATFORM_API_KEY; + else process.env.CLERK_PLATFORM_API_KEY = previous; + } + }); + + test("default unauthenticated agent iOS init fails before local apply", async () => { + const previous = process.env.CLERK_PLATFORM_API_KEY; + delete process.env.CLERK_PLATFORM_API_KEY; + try { + setup({ isAgent: true, email: null }); + spyOn(context, "gatherContext").mockResolvedValue(nativeIOSContext()); + + await expect(init({ yes: true })).rejects.toThrow( + "Native iOS setup in agent mode requires valid Clerk authentication", + ); + + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + } finally { + if (previous !== undefined) process.env.CLERK_PLATFORM_API_KEY = previous; + } + }); + + test("rejects --allow-dirty with --dry-run before project work", async () => { + setup(); + + await expect(init({ dryRun: true, allowDirty: true })).rejects.toThrow( + "--allow-dirty applies only when clerk init is making local changes", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + }); + + test("rejects an invalid App ID Prefix before project work", async () => { + setup(); + + await expect(init({ appIdPrefix: " " })).rejects.toThrow( + "--app-id-prefix must contain between 1 and 255 characters", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + }); + + test("rejects --app-id-prefix with local-only dry-run", async () => { + setup(); + + await expect(init({ dryRun: true, appIdPrefix: "LEGACY1234" })).rejects.toThrow( + "--app-id-prefix cannot be combined with --dry-run", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + }); + + test("rejects a known non-iOS override before bootstrapping or project work", async () => { + setup(); + spyOn(frameworkMod, "lookupFramework").mockReturnValue(FAKE_CTX.framework); + + await expect(init({ framework: "next", target: "MyApp" })).rejects.toThrow( + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test("never bootstraps when an iOS existing-project flag is present", async () => { + setup(); + spyOn(context, "gatherContext").mockResolvedValue(null); + + await expect(init({ target: "MyApp" })).rejects.toThrow( + "Could not detect an existing native iOS project", + ); + + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test("rejects --starter with iOS existing-project flags before project work", async () => { + setup(); + + await expect(init({ starter: true, target: "MyApp" })).rejects.toThrow( + "require an existing native iOS project", + ); + + expect(context.gatherContext).not.toHaveBeenCalled(); + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + }); + + test.each([ + [{ keyless: true }, "--keyless is not supported for iOS"], + [{ template: "native" as const }, "--template only applies to keyless applications"], + [{ fresh: true }, "--fresh only applies to keyless applications"], + ])("rejects iOS-incompatible flags before Xcode apply", async (flags, message) => { + setup(); + spyOn(context, "gatherContext").mockResolvedValue({ + ...FAKE_CTX, + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }); + + await expect(init({ yes: true, ...flags })).rejects.toThrow(message); + + expect(iosApplyMod.applyIOSLocalSetup).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); + }); + + test("native iOS skips npm SDK install and does not create an unused env file", async () => { + setup({ email: "test@test.com" }); + + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true }); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith({ + root: iosCtx.cwd, + target: undefined, + yes: true, + agent: false, + allowDirty: false, + signInWithApple: undefined, + prebuiltAuthUI: undefined, + }); + expect(heuristics.installSdk).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + }); + + test("forwards only an explicit prebuilt AuthView opt-in to iOS preflight", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + + await init({ yes: true, prebuiltAuthUI: true }); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( + expect.objectContaining({ + root: iosCtx.cwd, + yes: true, + prebuiltAuthUI: true, + signInWithApple: undefined, + }), + ); + expect(nativeAppleMod.prepareIOSNativeAppleConnection).not.toHaveBeenCalled(); + expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); + }); + + test("normalizes Commander's prebuiltAuthUi option before iOS preflight", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + + await init({ yes: true, prebuiltAuthUi: true }); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( + expect.objectContaining({ + root: iosCtx.cwd, + yes: true, + prebuiltAuthUI: true, + }), + ); + }); + + test("promotes the pre-authorized Apple entitlement when AuthView exposes Apple", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const conditionalApplePlan = iosAppleEntitlementPlan(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: conditionalApplePlan, + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + const environment = spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + } as never); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + + await init({ yes: true, prebuiltAuthUI: true }); + + expect(environment).toHaveBeenCalledWith("example.clerk.accounts.dev", {}); + expect(environment).toHaveBeenCalledTimes(2); + expect(commitLocal).toHaveBeenCalledWith( + expect.objectContaining({ + appleEntitlementPlan: conditionalApplePlan, + prebuiltAuthAppleEntitlementPlan: undefined, + }), + undefined, + ); + expect(setupResult.appleEntitlementPlan).toBeUndefined(); + expect(setupResult.prebuiltAuthAppleEntitlementPlan).toBe(conditionalApplePlan); + expect(nativeAppleMod.prepareIOSNativeAppleConnection).not.toHaveBeenCalled(); + }); + + test("drops the conditional Apple entitlement when AuthView will not expose Apple", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ status: "satisfied", root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ + social: { + oauth_apple: { + enabled: true, + authenticatable: false, + strategy: "oauth_apple", + }, + }, + } as never); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + + await init({ yes: true, prebuiltAuthUI: true }); + + expect(commitLocal).toHaveBeenCalledWith( + expect.objectContaining({ + prebuiltAuthAppleEntitlementPlan: undefined, + }), + undefined, + ); + expect(commitLocal.mock.calls[0]?.[0].appleEntitlementPlan).toBeUndefined(); + }); + + test("fails closed when AuthView Apple availability changes before local commit", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + const environment = spyOn(fapiMod, "fetchUserSettings") + .mockResolvedValueOnce({ + social: { + oauth_apple: { + enabled: true, + authenticatable: false, + strategy: "oauth_apple", + }, + }, + } as never) + .mockResolvedValueOnce({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + } as never); + + await expect(init({ yes: true, prebuiltAuthUI: true })).rejects.toThrow( + "AuthView methods changed while the approved iOS setup was being prepared", + ); + + expect(environment).toHaveBeenCalledTimes(2); + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.applyIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(VALID_DEVELOPMENT_KEY); + }); + + test("blocks before local or remote mutation when required AuthView Apple capability is unsafe", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan({ + status: "blocked", + files: [], + actions: [], + blockers: [{ code: "unsupported-entitlements", message: "Review the entitlements file." }], + }), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + } as never); + + await expect(init({ yes: true, prebuiltAuthUI: true })).rejects.toThrow( + "required selected-target entitlement could not be prepared safely", + ); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.applyIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(VALID_DEVELOPMENT_KEY); + }); + + test("redacts malformed or failed AuthView environment responses", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ status: "satisfied", root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + const secret = "provider-secret-must-not-escape"; + spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ + social: { + oauth_apple: { + enabled: "yes", + authenticatable: true, + strategy: "oauth_apple", + client_secret: secret, + }, + }, + } as never); + + await expect(init({ yes: true, prebuiltAuthUI: true })).rejects.toThrow( + "Apple sign-in settings could not be safely determined", + ); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(secret); + expect(`${captured.out}\n${captured.err}`).not.toContain(VALID_DEVELOPMENT_KEY); + }); + + test("wires a linked development key directly when iOS preflight proves a runtime sink", async () => { + setup({ email: "test@test.com" }); + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + const runtimeKeyPlan = { + schemaVersion: 1 as const, + kind: "clerk-ios-runtime-key" as const, + status: "ready" as const, + root: iosCtx.cwd, + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + localSecretsPath: "MyApp/LocalSecrets.plist", + gitignorePath: ".gitignore", + gitignoreRule: "/MyApp/LocalSecrets.plist", + expectedLocalSecretsHash: "source-hash", + expectedGitignoreHash: "ignore-hash", + changesGitignore: true, + actions: ["Set the redacted publishable key."], + blockers: [], + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ profile: { appId: "app_test" } } as never); + const setupResult = iosSetupResult({ + runtimeKeyPlan, + requiresLinkedApp: true, + }); + const preflightSpy = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const linkSpy = spyOn(linkMod, "link").mockResolvedValue(undefined); + const resolveKeysSpy = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: "pk_test_redacted", + }); + const applyPlannedSpy = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + const scaffoldSpy = spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Finish the remaining iOS setup"], + }); + + await init({ yes: true }); + + expect(pullMod.resolveEnvironmentKeys).toHaveBeenCalledWith({ + app: "app_test", + cwd: iosCtx.cwd, + }); + expect(pullMod.resolveEnvironmentKeys).toHaveBeenCalledTimes(1); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith( + setupResult, + "pk_test_redacted", + ); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(preflightSpy.mock.invocationCallOrder[0]).toBeLessThan( + linkSpy.mock.invocationCallOrder[0]!, + ); + expect(linkSpy.mock.invocationCallOrder[0]).toBeLessThan( + resolveKeysSpy.mock.invocationCallOrder[0]!, + ); + expect(applyPlannedSpy.mock.invocationCallOrder[0]).toBeLessThan( + scaffoldSpy.mock.invocationCallOrder[0]!, + ); + }); + + test("audits remote native state before local commit and applies it afterward", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: "pk_test_must_not_be_forwarded", + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const prepareRemote = spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan(), + ); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + const applyRemote = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockResolvedValue( + undefined, + ); + const scaffold = spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: [], + }); + + await init({ yes: true, appIdPrefix: "LEGACY1234" }); + + expect(prepareRemote).toHaveBeenCalledWith({ + applicationId: "app_test", + instanceId: "ins_test", + target: setupResult.nativeReadiness.target, + appIdPrefix: "LEGACY1234", + unverifiedAppIdPrefixSuggestion: setupResult.unverifiedAppIdPrefixSuggestion, + agent: false, + yes: true, + }); + expect(commitLocal).toHaveBeenCalledWith(setupResult, undefined); + expect(applyRemote).toHaveBeenCalledWith(expect.objectContaining({ status: "ready" })); + expect(resolveKeys.mock.invocationCallOrder[0]).toBeLessThan( + prepareRemote.mock.invocationCallOrder[0]!, + ); + expect(prepareRemote.mock.invocationCallOrder[0]).toBeLessThan( + commitLocal.mock.invocationCallOrder[0]!, + ); + expect(commitLocal.mock.invocationCallOrder[0]).toBeLessThan( + applyRemote.mock.invocationCallOrder[0]!, + ); + expect(applyRemote.mock.invocationCallOrder[0]).toBeLessThan( + scaffold.mock.invocationCallOrder[0]!, + ); + expect(scaffold).toHaveBeenCalledWith(expect.objectContaining({ iosNativeRemoteReady: true })); + }); + + test("applies an explicitly requested native Apple setup only after local and native readiness", async () => { + const { captured } = setup({ email: "test@test.com" }); + const stages = trackStages(); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + appleEntitlementPlan: iosAppleEntitlementPlan(), + nativeAppleRequested: true, + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + const preflightLocal = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const prepareNative = spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan(), + ); + const applePlan = iosNativeApplePlan(); + const prepareApple = spyOn(nativeAppleMod, "prepareIOSNativeAppleConnection").mockResolvedValue( + applePlan, + ); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + const applyNative = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockResolvedValue( + undefined, + ); + const applyApple = spyOn(nativeAppleMod, "applyIOSNativeAppleConnection").mockResolvedValue( + undefined, + ); + const scaffold = spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: [], + }); + + await init({ yes: true, signInWithApple: true }); + + expect(preflightLocal).toHaveBeenCalledWith(expect.objectContaining({ signInWithApple: true })); + expect(prepareApple).toHaveBeenCalledWith({ + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + nativeApplicationReady: true, + requested: true, + agent: false, + yes: true, + }); + expect(prepareNative.mock.invocationCallOrder[0]).toBeLessThan( + prepareApple.mock.invocationCallOrder[0]!, + ); + expect(prepareApple.mock.invocationCallOrder[0]).toBeLessThan( + commitLocal.mock.invocationCallOrder[0]!, + ); + expect(commitLocal.mock.invocationCallOrder[0]).toBeLessThan( + applyNative.mock.invocationCallOrder[0]!, + ); + expect(applyNative.mock.invocationCallOrder[0]).toBeLessThan( + applyApple.mock.invocationCallOrder[0]!, + ); + expect(scaffold).toHaveBeenCalledWith( + expect.objectContaining({ iosNativeRemoteReady: true, iosNativeAppleReady: true }), + ); + expect(stages()).toEqual([ + "flags", + "detect", + "strategy", + "ios_inspect", + "link", + "keys", + "ios_native_plan", + "ios_apple_plan", + "ios_local_setup", + "ios_native_setup", + "ios_apple_setup", + "scaffold", + "already_set_up", + ]); + expect(`${captured.out}\n${captured.err}`).not.toContain("pk_test_must_not_be_forwarded"); + }); + + test("does not opt into native Apple merely because --yes was supplied", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ + appleEntitlementPlan: iosAppleEntitlementPlan({ status: "satisfied", actions: [] }), + nativeAppleRequested: false, + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }), + ); + + await init({ yes: true }); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( + expect.objectContaining({ signInWithApple: undefined }), + ); + expect(nativeAppleMod.prepareIOSNativeAppleConnection).not.toHaveBeenCalled(); + expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); + }); + + test("does not commit local iOS files when the remote readiness audit blocks", async () => { + setup({ email: "test@test.com" }); + const stages = trackStages(); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockRejectedValue( + new Error("conflicting registration"), + ); + + await expect(init({ yes: true })).rejects.toThrow("conflicting registration"); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(nativeRemoteMod.applyIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(stages().at(-1)).toBe("ios_native_plan"); + }); + + test("does not mutate remote state when the approved local transaction fails", async () => { + setup({ email: "test@test.com" }); + const stages = trackStages(); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue(iosRemotePlan()); + spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( + new Error("stale local source"), + ); + + await expect(init({ yes: true })).rejects.toThrow("stale local source"); + + expect(nativeRemoteMod.applyIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(stages().at(-1)).toBe("ios_local_setup"); + }); + + test("reports partial remote failure without claiming the local setup was rolled back", async () => { + setup({ email: "test@test.com" }); + const stages = trackStages(); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue(iosRemotePlan()); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockRejectedValue( + new Error("remote mutation failed"), + ); + + await expect(init({ yes: true })).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED, + message: expect.stringContaining("Local changes remain intact; rerun clerk init"), + }); + + expect(commitLocal).toHaveBeenCalledTimes(1); + expect(stages().at(-1)).toBe("ios_native_setup"); + }); + + test("does not write a key when the linked app changes during resolution", async () => { + setup({ email: "test@test.com" }); + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + const runtimeKeyPlan = { + schemaVersion: 1 as const, + kind: "clerk-ios-runtime-key" as const, + status: "ready" as const, + root: iosCtx.cwd, + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + localSecretsPath: "MyApp/LocalSecrets.plist", + gitignorePath: ".gitignore", + gitignoreRule: "/MyApp/LocalSecrets.plist", + expectedLocalSecretsHash: "source-hash", + expectedGitignoreHash: "ignore-hash", + changesGitignore: true, + actions: ["Set the redacted publishable key."], + blockers: [], + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_linked" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ runtimeKeyPlan, requiresLinkedApp: true }), + ); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_changed", + instanceId: "ins_changed", + instanceLabel: "development", + publishableKey: "pk_test_redacted", + }); + + await expect(init({ yes: true })).rejects.toThrow( + "linked Clerk application changed while its iOS publishable key was being resolved", + ); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledTimes(1); + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + }); + + test("does not apply an approved iOS plan with a production instance key", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const productionKey = `pk_live_${Buffer.from("production.clerk.example$").toString("base64")}`; + const setupResult = iosSetupResult({ requiresLinkedApp: true }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_production" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_production", + instanceId: "ins_production", + instanceLabel: "production", + publishableKey: productionKey, + }); + + await expect(init({ yes: true })).rejects.toThrow("limited to the linked development instance"); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(productionKey); + }); + + test("does not commit when the local app link changes after key resolution", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ requiresLinkedApp: true }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce({ profile: { appId: "app_selected" } } as never) + .mockResolvedValueOnce({ profile: { appId: "app_selected" } } as never) + .mockResolvedValueOnce({ profile: { appId: "app_changed" } } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_selected", + instanceId: "ins_selected", + instanceLabel: "development", + publishableKey: "pk_test_redacted", + }); + + await expect(init({ yes: true })).rejects.toThrow( + "local Clerk application link changed before the approved iOS setup", + ); + + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + }); + + test("rejects an explicit same-profile app when its existing iOS runtime key is stale", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const linkedKey = `pk_test_${Buffer.from("explicit-stale.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_same_profile" }, + } as never); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + const localApply = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const commit = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( + new Error("The existing iOS runtime publishable key does not match the linked app."), + ); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_same_profile", + instanceId: "ins_same_profile", + instanceLabel: "development", + publishableKey: linkedKey, + }); + await expect(init({ yes: true, app: "app_same_profile" })).rejects.toThrow( + "does not match the linked app", + ); + + expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( + expect.not.objectContaining({ expectedPublishableKey: expect.anything() }), + ); + expect(linkMod.link).not.toHaveBeenCalled(); + expect(localApply).toHaveBeenCalledTimes(1); + expect(commit).toHaveBeenCalledWith(setupResult, linkedKey); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); + }); + + test("rejects an implicitly linked profile when its existing iOS runtime key is stale", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const linkedKey = `pk_test_${Buffer.from("implicit-stale.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_implicitly_linked" }, + } as never); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( + new Error("The existing iOS runtime publishable key does not match the linked app."), + ); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_implicitly_linked", + instanceId: "ins_implicitly_linked", + instanceLabel: "development", + publishableKey: linkedKey, + }); + await expect(init({ yes: true })).rejects.toThrow("does not match the linked app"); + + expect(linkMod.link).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, linkedKey); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); + }); + + test("matching an existing iOS runtime key is a read-only authenticated no-op", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const linkedKey = `pk_test_${Buffer.from("matching.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_matching" }, + } as never); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_matching", + instanceId: "ins_matching", + instanceLabel: "development", + publishableKey: linkedKey, + }); + await init({ yes: true }); + + expect(resolveKeys).toHaveBeenCalledTimes(1); + expect(resolveKeys).toHaveBeenCalledWith({ app: "app_matching", cwd: iosCtx.cwd }); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, linkedKey); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); + }); + + test("an explicit app with no or a different local profile proceeds when the frozen key matches", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const key = `pk_test_${Buffer.from("explicit-match.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce({ profile: { appId: "app_previous" } } as never) + .mockResolvedValueOnce({ profile: { appId: "app_previous" } } as never) + .mockResolvedValue({ profile: { appId: "app_requested" } } as never); + const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_requested", + instanceId: "ins_requested", + instanceLabel: "development", + publishableKey: key, + }); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + const localApply = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + + await init({ yes: true, app: "app_requested" }); + + expect(localApply.mock.invocationCallOrder[0]).toBeLessThan( + resolveKeys.mock.invocationCallOrder[0]!, + ); + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: "app_requested", + cwd: iosCtx.cwd, + createIfMissing: undefined, + skipAutolink: true, + }); + expect(resolveKeys).toHaveBeenCalledTimes(1); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, key); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + }); + + test("reuses the frozen explicit key after a profile race without cwd-based resolution", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const key = `pk_test_${Buffer.from("frozen.clerk.example$").toString("base64")}`; + const runtimeKeyPlan = { + schemaVersion: 1 as const, + kind: "clerk-ios-runtime-key" as const, + status: "ready" as const, + root: iosCtx.cwd, + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + localSecretsPath: "MyApp/LocalSecrets.plist", + gitignorePath: ".gitignore", + gitignoreRule: "/MyApp/LocalSecrets.plist", + expectedLocalSecretsHash: "source-hash", + expectedGitignoreHash: "ignore-hash", + changesGitignore: true, + actions: ["Set the redacted publishable key."], + blockers: [], + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce({ profile: { appId: "app_raced" } } as never) + .mockResolvedValue({ profile: { appId: "app_requested" } } as never); + const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_requested", + instanceId: "ins_requested", + instanceLabel: "development", + publishableKey: key, + }); + const setupResult = iosSetupResult({ + runtimeKeyPlan, + requiresLinkedApp: true, + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + + await init({ yes: true, app: "app_requested" }); + + expect(resolveKeys).toHaveBeenCalledTimes(1); + expect(resolveKeys).toHaveBeenCalledWith({ app: "app_requested", cwd: iosCtx.cwd }); + expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, key); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + }); + + test("native framework skips the agent skills install prompt", async () => { + setup({ email: "test@test.com" }); + + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true }); + + expect(skillsMod.installSkills).not.toHaveBeenCalled(); + }); + + test("--framework ios without package.json does not trigger bootstrap", async () => { + setup({ email: "test@test.com" }); + + const iosFramework = { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }; + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + envFile: ".env", + framework: iosFramework, + }; + spyOn(frameworkMod, "lookupFramework").mockReturnValue(iosFramework); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(context, "hasPackageJson").mockResolvedValue(false); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true, framework: "ios" }); + + expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 55b9c7099..9eabd2f85 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -21,6 +21,7 @@ import { nextStepsMod, mockExistingProject, mockMiddlewareScaffold, + iosApplyMod, } from "../../test/lib/init-harness.ts"; import * as telemetryMod from "../../lib/telemetry.ts"; import { init } from "./index.ts"; @@ -49,9 +50,9 @@ describe("init", () => { test("forwards --app to link when provided", async () => { setup({ email: "test@test.com" }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); - spyOn(config, "resolveProfile").mockResolvedValue({ - profile: { appId: "app_other" }, - } as never); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce({ profile: { appId: "app_other" } } as never) + .mockResolvedValue({ profile: { appId: "app_abc" } } as never); await init({ yes: true, app: "app_abc" }); @@ -66,7 +67,9 @@ describe("init", () => { test("forwards --app to link when no profile exists", async () => { setup({ email: "test@test.com" }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); - // resolveProfile already returns undefined by default in setup() + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ profile: { appId: "app_abc" } } as never); await init({ yes: true, app: "app_abc" }); @@ -78,6 +81,22 @@ describe("init", () => { }); }); + test("does not fetch or write keys when an explicit app relink is declined", async () => { + setup({ email: "test@test.com" }); + spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_existing" }, + } as never); + + await expect(init({ yes: true, app: "app_requested" })).rejects.toMatchObject({ + name: "UserAbortError", + }); + + expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSRuntimeKeySetup).not.toHaveBeenCalled(); + }); + test("agent mode runs existing-project flow without prompts", async () => { setup({ isAgent: true }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); @@ -475,95 +494,6 @@ describe("init", () => { expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env.local", cwd: mockCtx.cwd }); }); - test("native framework skips npm SDK install but still pulls env keys", async () => { - setup({ email: "test@test.com" }); - - const iosCtx = { - ...FAKE_CTX, - existingClerk: false, - deps: {}, - envFile: ".env", - framework: { - dep: "ios", - name: "iOS (Swift)", - sdk: "ClerkKit", - envVar: "CLERK_PUBLISHABLE_KEY", - envFile: ".env" as const, - ecosystem: "swift" as const, - }, - }; - spyOn(context, "gatherContext").mockResolvedValue(iosCtx); - spyOn(scaffoldMod, "scaffold").mockResolvedValue({ - actions: [], - postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], - }); - - await init({ yes: true }); - - expect(heuristics.installSdk).not.toHaveBeenCalled(); - expect(pullMod.pull).toHaveBeenCalledWith({ file: ".env", cwd: iosCtx.cwd }); - }); - - test("native framework skips the agent skills install prompt", async () => { - setup({ email: "test@test.com" }); - - const iosCtx = { - ...FAKE_CTX, - existingClerk: false, - deps: {}, - envFile: ".env", - framework: { - dep: "ios", - name: "iOS (Swift)", - sdk: "ClerkKit", - envVar: "CLERK_PUBLISHABLE_KEY", - envFile: ".env" as const, - ecosystem: "swift" as const, - }, - }; - spyOn(context, "gatherContext").mockResolvedValue(iosCtx); - spyOn(scaffoldMod, "scaffold").mockResolvedValue({ - actions: [], - postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], - }); - - await init({ yes: true }); - - expect(skillsMod.installSkills).not.toHaveBeenCalled(); - }); - - test("--framework ios without package.json does not trigger bootstrap", async () => { - setup({ email: "test@test.com" }); - - const iosFramework = { - dep: "ios", - name: "iOS (Swift)", - sdk: "ClerkKit", - envVar: "CLERK_PUBLISHABLE_KEY", - envFile: ".env" as const, - ecosystem: "swift" as const, - }; - const iosCtx = { - ...FAKE_CTX, - existingClerk: false, - deps: {}, - envFile: ".env", - framework: iosFramework, - }; - spyOn(frameworkMod, "lookupFramework").mockReturnValue(iosFramework); - spyOn(context, "gatherContext").mockResolvedValue(iosCtx); - spyOn(context, "hasPackageJson").mockResolvedValue(false); - spyOn(scaffoldMod, "scaffold").mockResolvedValue({ - actions: [], - postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], - }); - - await init({ yes: true, framework: "ios" }); - - expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); - expect(pullMod.pull).toHaveBeenCalled(); - }); - test("bootstrap passes project dir to link, not parent cwd", async () => { setup({ email: "test@test.com" }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 5f14811fb..4d223f727 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -2,15 +2,17 @@ import { createOption } from "@commander-js/extra-typings"; import type { Program } from "../../cli-program.ts"; import { login } from "../auth/login.js"; import { link } from "../link/index.js"; -import { pull } from "../env/pull.js"; +import { pull, resolveEnvironmentKeys } from "../env/pull.js"; import { isAgent } from "../../mode.js"; import { dim, bold } from "../../lib/color.js"; import { throwUserAbort, throwUsageError, + ApiError, CliError, ERROR_CODE, errorMessage, + isAuthError, } from "../../lib/errors.js"; import { lookupFramework, @@ -34,6 +36,8 @@ import { } from "../../lib/keyless.js"; import { readSdkKeylessApp } from "../../lib/keyless-target.ts"; import { interruptedExitCode } from "../../lib/signals.ts"; +import { listApplications } from "../../lib/plapi.ts"; +import { decodePublishableKey, fetchUserSettings } from "../../lib/fapi.ts"; import { printNextSteps } from "../../lib/next-steps.js"; import { gatherContext, hasPackageJson } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; @@ -61,6 +65,33 @@ import { } from "./bootstrap.js"; import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; +import { inspectIOSProject } from "./ios/inspect.ts"; +import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "./ios/plan.ts"; +import { planIOSDirectConfig } from "./ios/direct-config.ts"; +import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./ios/products.ts"; +import { planIOSRuntimeKey } from "./ios/runtime-key.ts"; +import { planIOSAssociatedDomain } from "./ios/associated-domain.ts"; +import { planIOSAppleEntitlement } from "./ios/apple-entitlement.ts"; +import { planIOSPrebuiltAuth } from "./ios/prebuilt-auth.ts"; +import { createIOSDryRunOutput, formatIOSSetupPlan } from "./ios/output.ts"; +import { + applyIOSLocalSetup, + applyIOSPlannedLocalSetup, + planIOSPrebuiltAuthSDKCompatibility, + planIOSPrebuiltAuthRuntimeBlockers, + type IOSLocalSetupResult, +} from "./ios/apply.ts"; +import { + applyIOSNativeRemoteSetup, + prepareIOSNativeRemoteSetup, + validateAppIdPrefix, +} from "./ios/native-remote.ts"; +import { + applyIOSNativeAppleConnection, + prepareIOSNativeAppleConnection, + type IOSNativeApplePlan, +} from "./ios/native-apple.ts"; +import { auditIOSPrebuiltAuthEnvironment } from "./ios/prebuilt-auth-environment.ts"; type InitOptions = { /** Framework to set up (skips auto-detection). */ @@ -82,18 +113,60 @@ type InitOptions = { template?: KeylessTemplate; /** Replace an existing unclaimed keyless application instead of keeping it. */ fresh?: boolean; + /** Inspect an iOS project and print the setup plan without changing local or remote state. */ + dryRun?: boolean; + /** Emit the read-only iOS inspection and setup plan as JSON. */ + json?: boolean; + /** iOS application target name or PBX object ID. */ + target?: string; + /** Allow an iOS apply action to update a project file that already has local changes. */ + allowDirty?: boolean; + /** Apple App ID Prefix used when a new Clerk iOS registration is required. */ + appIdPrefix?: string; + /** Opt into native Sign in with Apple setup for the selected iOS target. */ + signInWithApple?: boolean; + /** Opt into ClerkKitUI's prebuilt AuthView flow for a proven pristine SwiftUI target. */ + prebuiltAuthUI?: boolean; + /** Commander's camel-case form of --prebuilt-auth-ui. Normalized at the command boundary. */ + prebuiltAuthUi?: boolean; }; export async function init(options: InitOptions = {}) { + if (options.prebuiltAuthUI == null && options.prebuiltAuthUi != null) { + options = { ...options, prebuiltAuthUI: options.prebuiltAuthUi }; + } const cwd = process.cwd(); const agent = isAgent(); + const machineOutput = options.dryRun === true && (options.json === true || agent); setTelemetryStage("flags"); - await assertUsableFlags(options, agent); + assertUsableFlags(options); + + // An agent cannot recover by completing an interactive browser login. This + // read-only credential validation happens before project detection so an + // invalid authenticated invocation cannot bootstrap or mutate anything. + let validatedAgentAuthLabel = + agent && (options.login || options.app) ? await validateAgentAuthentication() : undefined; + if (validatedAgentAuthLabel === null) { + throwUsageError( + `${options.app ? "--app" : "--login"} requires authentication that agent mode cannot complete interactively. Ask the user to run \`clerk auth login\`, then re-run \`clerk init\`.`, + ); + } const frameworkOverride = options.framework ? (lookupFramework(options.framework) ?? undefined) : undefined; + const requiresExistingIOSProject = + options.target != null || + options.allowDirty === true || + options.appIdPrefix != null || + options.signInWithApple === true || + options.prebuiltAuthUI === true; + if (requiresExistingIOSProject && frameworkOverride && frameworkOverride.dep !== "ios") { + throwUsageError( + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects.", + ); + } // In agent mode, implicitly enable --yes to skip all confirmation prompts. const overrides: BootstrapOverrides = { @@ -102,12 +175,18 @@ export async function init(options: InitOptions = {}) { nameOverride: options.name, }; - intro("Setting up Clerk"); + if (!machineOutput) { + intro(options.dryRun ? "Inspecting Clerk setup" : "Setting up Clerk"); + } setTelemetryStage("detect"); - const resolved = options.starter - ? await handleStarter(cwd, frameworkOverride, overrides) - : await resolveProjectContext(cwd, frameworkOverride, overrides); + const resolved = options.dryRun + ? await resolveReadOnlyProjectContext(cwd, frameworkOverride, overrides, machineOutput) + : requiresExistingIOSProject + ? await resolveExistingProjectContext(cwd, frameworkOverride, overrides) + : options.starter + ? await handleStarter(cwd, frameworkOverride, overrides) + : await resolveProjectContext(cwd, frameworkOverride, overrides); if (!resolved) return; @@ -117,6 +196,203 @@ export async function init(options: InitOptions = {}) { ctx.isBootstrap = true; } + if ( + !options.dryRun && + ctx.framework.dep !== "ios" && + (options.target || + options.allowDirty || + options.appIdPrefix || + options.signInWithApple || + options.prebuiltAuthUI) + ) { + throwUsageError( + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui apply only to native iOS projects.", + ); + } + if (ctx.framework.dep === "ios") { + ctx.iosTarget = options.target; + assertIOSUsableFlags(options); + } + + if (options.dryRun) { + if (ctx.framework.dep !== "ios") { + throwUsageError( + `--dry-run currently supports native iOS projects only; detected ${ctx.framework.name}.`, + ); + } + setTelemetryStage("ios_inspect"); + const inspect = async () => inspectIOSProject(ctx.cwd, { target: options.target }); + const inspection = machineOutput + ? await inspect() + : await withSpinner("Inspecting Xcode project...", inspect); + const dryRunSelection = inspection.selection; + const selectedTarget = + dryRunSelection.state === "selected" + ? inspection.appTargets.find( + (target) => + target.id === dryRunSelection.targetId && + target.projectPath === dryRunSelection.projectPath, + ) + : undefined; + const productDecision = selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined; + const inspectedPrebuiltAuthPlan = + dryRunSelection.state === "selected" + ? await planIOSPrebuiltAuth({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + }) + : undefined; + const prebuiltAuthActive = + inspectedPrebuiltAuthPlan != null && + inspectedPrebuiltAuthPlan.status !== "blocked" && + (options.prebuiltAuthUI === true || inspectedPrebuiltAuthPlan.status === "satisfied"); + const directConfigPlan = + dryRunSelection.state === "selected" && + selectedTarget && + productDecision && + shouldPlanIOSDirectConfig( + inspection, + selectedTarget, + prebuiltAuthActive ? "prebuilt" : productDecision, + ) + ? await planIOSDirectConfig({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + }) + : undefined; + const preliminaryPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); + const configureStep = preliminaryPlan.steps.find( + (step) => step.id === "configure-publishable-key", + ); + const needsRuntimeKeyHandoff = + dryRunSelection.state === "selected" && + selectedTarget != null && + configureStep?.status === "required" && + hasIOSRuntimeKeyHandoffShape(inspection, selectedTarget); + const runtimeKeyPlan = needsRuntimeKeyHandoff + ? await planIOSRuntimeKey({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + }) + : undefined; + const prebuiltRuntimeBlockers = prebuiltAuthActive + ? planIOSPrebuiltAuthRuntimeBlockers( + inspection, + directConfigPlan, + runtimeKeyPlan?.status === "ready" ? runtimeKeyPlan : undefined, + ) + : []; + const prebuiltAuthPlan = + inspectedPrebuiltAuthPlan && prebuiltRuntimeBlockers.length > 0 + ? { + ...inspectedPrebuiltAuthPlan, + status: "blocked" as const, + actions: [], + blockers: [ + ...inspectedPrebuiltAuthPlan.blockers, + { + code: "runtime-prerequisites" as const, + message: prebuiltRuntimeBlockers.join(" "), + }, + ], + } + : inspectedPrebuiltAuthPlan; + const associatedDomainPlan = + dryRunSelection.state === "selected" + ? await planIOSAssociatedDomain({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + deferToPublishableKey: + directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + }) + : undefined; + const hasLocalAppleIntent = selectedTarget?.configurations.some( + (configuration) => configuration.entitlements?.signInWithApple === true, + ); + const appleEntitlementPlan = + dryRunSelection.state === "selected" && + (options.signInWithApple === true || hasLocalAppleIntent === true) + ? await planIOSAppleEntitlement({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + }) + : undefined; + const sdkInstallPlan = + dryRunSelection.state === "selected" && selectedTarget != null && prebuiltAuthActive + ? await planIOSPrebuiltAuthSDKCompatibility({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, + }) + : undefined; + const plan = buildIOSSetupPlan(inspection, { + sdkInstallPlan, + runtimeKeyPlan: runtimeKeyPlan && { + status: runtimeKeyPlan.status, + blockers: runtimeKeyPlan.blockers, + }, + directConfigPlan, + associatedDomainPlan, + appleEntitlementPlan, + prebuiltAuthPlan, + prebuiltAuthSelected: options.prebuiltAuthUI === true, + }); + if (machineOutput) { + log.data( + JSON.stringify(createIOSDryRunOutput(inspection, plan, { associatedDomainPlan }), null, 2), + ); + } else { + log.info(formatIOSSetupPlan(inspection, plan, { associatedDomainPlan })); + await outro(plan.status === "ready" ? "Setup looks ready" : "Setup incomplete"); + } + setTelemetryStage("done"); + return; + } + + setTelemetryStage("strategy"); + let iosLocalSetup: IOSLocalSetupResult | undefined; + let iosProfile: Awaited> | undefined; + let preauthenticatedIOSLabel: string | undefined; + if (ctx.framework.dep === "ios") { + // Resolve the local link before the redacted preview. No application key + // is fetched and no local file is written until the user has authorized + // the complete semantic plan. + iosProfile = await resolveProfile(ctx.cwd); + if (agent && validatedAgentAuthLabel === undefined) { + validatedAgentAuthLabel = await validateAgentAuthentication(); + } + if (agent && validatedAgentAuthLabel === null) { + throwUsageError( + "Native iOS setup in agent mode requires valid Clerk authentication before any Xcode files can be changed. Ask the user to run `clerk auth login` or provide a valid Platform API key, then rerun `clerk init`.", + ); + } + + preauthenticatedIOSLabel = agent ? validatedAgentAuthLabel! : undefined; + + setTelemetryStage("ios_inspect"); + iosLocalSetup = await applyIOSLocalSetup({ + root: ctx.cwd, + target: options.target, + yes: options.yes === true, + agent, + allowDirty: options.allowDirty === true, + signInWithApple: options.signInWithApple, + prebuiltAuthUI: options.prebuiltAuthUI, + }); + if (agent && iosLocalSetup.verifiesExistingKey && !options.app && !iosProfile) { + throwUsageError( + "This iOS target already contains a publishable key. Agent mode cannot choose its matching Clerk application safely; rerun with --app . No local files were changed.", + ); + } + } + await enrichProjectContext(ctx); const optsKeyless = options.keyless === true; @@ -128,15 +404,23 @@ export async function init(options: InitOptions = {}) { // stale/broken credential ends up blocked on an interactive browser OAuth // round-trip it can never complete. So agent mode validates the credential // (it can fall back to keyless) instead of trusting mere presence. - setTelemetryStage("strategy"); + if (!optsKeyless && agent && validatedAgentAuthLabel === undefined) { + validatedAgentAuthLabel = await validateAgentAuthentication(); + } const authed = optsKeyless ? false : agent - ? await isAuthenticatedForAgent() + ? validatedAgentAuthLabel !== null : await isAuthenticated(); const linkedProfile = - !optsKeyless && agent && !options.app ? await resolveProfile(ctx.cwd) : undefined; - const hasRealAppTarget = Boolean(options.app || linkedProfile); + ctx.framework.dep === "ios" + ? iosProfile + : !optsKeyless && agent && authed && !options.app + ? await resolveProfile(ctx.cwd) + : undefined; + const hasRealAppTarget = Boolean( + options.app || linkedProfile || iosLocalSetup?.requiresLinkedApp, + ); const strategy = pickStrategy({ optsKeyless, @@ -150,13 +434,239 @@ export async function init(options: InitOptions = {}) { assertKeylessOnlyFlags(options, strategy); + let authenticatedAppId: string | undefined; if (strategy === "authenticate") { setTelemetryStage("link"); bar(); const createIfMissing = agent ? await deriveProjectName(ctx.cwd, bootstrap?.projectName) : undefined; - await authenticateAndLink(ctx.cwd, options.app, createIfMissing); + authenticatedAppId = await authenticateAndLink( + ctx.cwd, + options.app, + createIfMissing, + iosLocalSetup?.requiresLinkedApp === true, + preauthenticatedIOSLabel, + ); + } + + let authenticatedKeysHandled = false; + if (iosLocalSetup?.requiresLinkedApp) { + if (strategy !== "authenticate") { + throw new CliError( + "The approved iOS configuration requires a linked Clerk application, but authentication did not complete. No local setup changes were written.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + if (!authenticatedAppId) { + throw new CliError( + "The Clerk application link could not be verified. No local setup changes were written.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + setTelemetryStage("keys"); + const keys = await withSpinner("Fetching the development publishable key...", async () => + resolveEnvironmentKeys({ app: authenticatedAppId, cwd: ctx.cwd }), + ); + if (keys.instanceLabel !== "development") { + throw new CliError( + "Automatic iOS configuration is limited to the linked development instance. No local setup changes were written.", + { code: ERROR_CODE.INVALID_ENVIRONMENT }, + ); + } + if (keys.appId !== authenticatedAppId) { + throw new CliError( + "The linked Clerk application changed while its iOS publishable key was being resolved. No local setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_STALE }, + ); + } + let iosSetupForCommit = iosLocalSetup; + let inspectedAuthViewAppleRequirement: "required" | "not-required" | undefined; + if (iosLocalSetup.prebuiltAuthActive) { + const authEnvironment = await withSpinner( + "Inspecting AuthView authentication methods...", + async () => { + try { + const { fapiHost } = decodePublishableKey(keys.publishableKey); + const settings = await fetchUserSettings(fapiHost, {}); + return auditIOSPrebuiltAuthEnvironment(settings); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + if (error instanceof ApiError || error instanceof CliError) throw error; + log.debug(`Could not inspect AuthView authentication methods: ${errorMessage(error)}`); + throw new CliError( + "The linked Clerk application's AuthView methods could not be inspected safely. No local setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED }, + ); + } + }, + ); + if (authEnvironment.apple === "blocked") { + throw new CliError(`${authEnvironment.message} No local setup changes were written.`, { + code: ERROR_CODE.IOS_SETUP_BLOCKED, + }); + } + inspectedAuthViewAppleRequirement = authEnvironment.apple; + if (authEnvironment.apple === "required") { + const conditionalPlan = iosLocalSetup.prebuiltAuthAppleEntitlementPlan; + if (!conditionalPlan || conditionalPlan.status === "blocked") { + const reasons = conditionalPlan?.blockers + .map((blocker) => ` • ${blocker.message}`) + .join("\n"); + throw new CliError( + `AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be prepared safely. No local setup changes were written${reasons ? `:\n${reasons}` : "."}`, + { code: ERROR_CODE.IOS_SETUP_BLOCKED }, + ); + } + } + } + setTelemetryStage("ios_native_plan"); + const nativeRemotePlan = await prepareIOSNativeRemoteSetup({ + applicationId: keys.appId, + instanceId: keys.instanceId, + target: iosLocalSetup.nativeReadiness.target, + appIdPrefix: options.appIdPrefix, + ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion + ? { unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion } + : {}), + agent, + yes: options.yes === true, + }); + let nativeApplePlan: IOSNativeApplePlan | undefined; + if (iosLocalSetup.nativeAppleRequested) { + if (!iosLocalSetup.appleEntitlementPlan) { + throw new CliError( + "Native Sign in with Apple was requested without a validated local entitlement plan. No local or Apple connection changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, + ); + } + const target = iosLocalSetup.nativeReadiness.target; + if (target.status !== "selected" || target.bundleIdentifier.status !== "resolved") { + throw new CliError( + "The selected iOS Bundle ID could not be revalidated for native Sign in with Apple. No local or Apple connection changes were written.", + { code: ERROR_CODE.IOS_TARGET_UNRESOLVED }, + ); + } + setTelemetryStage("ios_apple_plan"); + const preparedApple = await prepareIOSNativeAppleConnection({ + applicationId: keys.appId, + instanceId: keys.instanceId, + bundleIdentifier: target.bundleIdentifier.value, + nativeApplicationReady: + nativeRemotePlan.status !== "blocked" && nativeRemotePlan.registration !== "blocked", + requested: true, + agent, + yes: options.yes === true, + }); + if (preparedApple.status === "skipped") { + throw new CliError( + "Native Sign in with Apple was selected locally but its Clerk connection plan was skipped. No local or Apple connection changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, + ); + } + nativeApplePlan = preparedApple; + } + const commitProfile = await resolveProfile(ctx.cwd); + if (commitProfile?.profile.appId !== authenticatedAppId) { + throw new CliError( + "The local Clerk application link changed before the approved iOS setup could be committed. No local or remote setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_STALE }, + ); + } + + if (iosLocalSetup.prebuiltAuthActive) { + const authEnvironment = await withSpinner( + "Revalidating AuthView authentication methods...", + async () => { + try { + const { fapiHost } = decodePublishableKey(keys.publishableKey); + const settings = await fetchUserSettings(fapiHost, {}); + return auditIOSPrebuiltAuthEnvironment(settings); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + if (error instanceof ApiError || error instanceof CliError) throw error; + log.debug( + `Could not revalidate AuthView authentication methods: ${errorMessage(error)}`, + ); + throw new CliError( + "The linked Clerk application's AuthView methods could not be revalidated safely. No local or remote setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED }, + ); + } + }, + ); + if (authEnvironment.apple === "blocked") { + throw new CliError( + `${authEnvironment.message} No local or remote setup changes were written.`, + { code: ERROR_CODE.IOS_SETUP_BLOCKED }, + ); + } + if (authEnvironment.apple !== inspectedAuthViewAppleRequirement) { + throw new CliError( + "The linked Clerk application's AuthView methods changed while the approved iOS setup was being prepared. No local or remote setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_STALE }, + ); + } + + if (authEnvironment.apple === "required") { + const conditionalPlan = iosLocalSetup.prebuiltAuthAppleEntitlementPlan; + if (!conditionalPlan || conditionalPlan.status === "blocked") { + throw new CliError( + "AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be revalidated safely. No local or remote setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_STALE }, + ); + } + iosSetupForCommit = { + ...iosLocalSetup, + appleEntitlementPlan: iosLocalSetup.appleEntitlementPlan ?? conditionalPlan, + prebuiltAuthAppleEntitlementPlan: undefined, + }; + } else { + iosSetupForCommit = { + ...iosLocalSetup, + prebuiltAuthAppleEntitlementPlan: undefined, + }; + } + } + + setTelemetryStage("ios_local_setup"); + await applyIOSPlannedLocalSetup( + iosSetupForCommit, + iosSetupForCommit.requiresDevelopmentKey ? keys.publishableKey : undefined, + ); + try { + setTelemetryStage("ios_native_setup"); + await applyIOSNativeRemoteSetup(nativeRemotePlan); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + if (error instanceof ApiError || error instanceof CliError) throw error; + log.debug(`Could not reconcile Clerk Native Application settings: ${errorMessage(error)}`); + throw new CliError( + "The local iOS setup completed, but Clerk Native Application settings could not be completed remotely. Local changes remain intact; rerun clerk init to safely reconcile the additive remote steps.", + { code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED }, + ); + } + log.success("Clerk Native API and iOS application registration verified"); + ctx.iosNativeRemoteReady = true; + if (nativeApplePlan) { + try { + setTelemetryStage("ios_apple_setup"); + await applyIOSNativeAppleConnection(nativeApplePlan); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + if (error instanceof ApiError || error instanceof CliError) throw error; + log.debug(`Could not reconcile the native Apple connection: ${errorMessage(error)}`); + throw new CliError( + "The local iOS setup and Clerk Native Application registration completed, but the native Apple connection could not be completed. Those completed changes remain intact; rerun clerk init to reconcile Sign in with Apple safely.", + { code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED }, + ); + } + ctx.iosNativeAppleReady = true; + } + authenticatedKeysHandled = true; + } else if (iosLocalSetup) { + setTelemetryStage("ios_local_setup"); + await applyIOSPlannedLocalSetup(iosLocalSetup); } // Short-circuit on a fully-clean re-run so env pull / skills prompt don't @@ -182,6 +692,7 @@ export async function init(options: InitOptions = {}) { template: options.template, fresh: options.fresh === true, skipConfirm: overrides.skipConfirm, + authenticatedKeysHandled, }); // Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with. @@ -207,7 +718,46 @@ export async function init(options: InitOptions = {}) { * application the CLI creates; `--login` and `--app` describe one that * already exists. */ -async function assertUsableFlags(options: InitOptions, agent: boolean): Promise { +function assertUsableFlags(options: InitOptions): void { + if (options.json && !options.dryRun) { + throwUsageError("--json currently requires --dry-run."); + } + if (options.dryRun && options.allowDirty) { + throwUsageError("--allow-dirty applies only when clerk init is making local changes."); + } + if (options.dryRun && options.appIdPrefix != null) { + throwUsageError( + "--app-id-prefix cannot be combined with --dry-run because dry-run never reads or changes remote application state.", + ); + } + if (options.appIdPrefix != null && !validateAppIdPrefix(options.appIdPrefix)) { + throwUsageError("--app-id-prefix must contain between 1 and 255 characters after trimming."); + } + if (options.dryRun && options.starter) { + throwUsageError( + "--dry-run cannot be combined with --starter because dry-run never creates files.", + ); + } + if ( + options.starter && + (options.target || + options.allowDirty || + options.appIdPrefix || + options.signInWithApple || + options.prebuiltAuthUI) + ) { + throwUsageError( + "--target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui require an existing native iOS project and cannot be combined with --starter.", + ); + } + if ( + options.dryRun && + (options.app || options.keyless || options.login || options.template || options.fresh) + ) { + throwUsageError( + "--dry-run cannot be combined with --app, --keyless, --login, --template, or --fresh because it never reads or changes remote application state.", + ); + } if (options.keyless && options.login) { throwUsageError("--keyless and --login cannot be combined."); } @@ -224,12 +774,27 @@ async function assertUsableFlags(options: InitOptions, agent: boolean): Promise< if (options.fresh && options.login) { throwUsageError("--fresh applies to keyless applications and cannot be combined with --login."); } - // Presence-only here would repeat the hang below: an agent can't complete an - // interactive login, so a stored-but-broken credential must read as - // unauthenticated rather than let this guard wave the request through. - if (options.login && agent && !(await isAuthenticatedForAgent())) { +} + +/** + * Rejects keyless-only flags before the iOS apply phase. Native iOS does not + * consume Clerk's keyless bootstrap, so letting strategy resolution reject + * these later could otherwise modify the Xcode project before a usage error. + */ +function assertIOSUsableFlags(options: InitOptions): void { + if (options.keyless) { + throwUsageError( + "--keyless is not supported for iOS (Swift). Run `clerk auth login` and use `clerk init --app ` instead.", + ); + } + if (options.template) { throwUsageError( - "--login requires an interactive terminal to complete the browser login. Ask the user to run `clerk auth login`, then re-run `clerk init`.", + "--template only applies to keyless applications, but iOS (Swift) does not support keyless mode. Drop --template.", + ); + } + if (options.fresh) { + throwUsageError( + "--fresh only applies to keyless applications, but iOS (Swift) does not support keyless mode. Drop --fresh.", ); } } @@ -240,13 +805,23 @@ async function assertUsableFlags(options: InitOptions, agent: boolean): Promise< * credential, because a human who turns out to be unauthenticated just gets * an interactive login prompt. An agent has no such fallback — if it trusts a * stale/broken credential, it ends up blocked on a browser OAuth round-trip - * that can never complete. So this validates before trusting: a real API key - * is accepted outright (no OAuth involved), everything else must actually - * resolve to a user. + * that can never complete. Platform API keys are validated with a read-only + * request; stored OAuth credentials must actually resolve to a user. */ -async function isAuthenticatedForAgent(): Promise { - if (process.env.CLERK_PLATFORM_API_KEY) return true; - return (await getAuthenticatedEmail()) !== null; +async function validateAgentAuthentication(): Promise { + if (process.env.CLERK_PLATFORM_API_KEY) { + try { + await listApplications(); + return "Using API key"; + } catch (error) { + if (interruptedExitCode() !== null) throw error; + if (!isAuthError(error)) throw error; + return null; + } + } + + const email = await getAuthenticatedEmail(); + return email ? `Logged in as ${email}` : null; } /** @@ -349,6 +924,40 @@ async function resolveProjectContext( return bootstrapAndDetect(cwd, frameworkOverride, overrides); } +async function resolveExistingProjectContext( + cwd: string, + frameworkOverride: FrameworkInfo | undefined, + overrides: BootstrapOverrides, +): Promise { + const ctx = await withSpinner("Detecting framework...", async () => + gatherContext(cwd, frameworkOverride, overrides.pmOverride), + ); + if (!ctx) { + throw new CliError( + "Could not detect an existing native iOS project. --target, --allow-dirty, --app-id-prefix, --sign-in-with-apple, and --prebuilt-auth-ui never bootstrap a new project.", + { code: ERROR_CODE.FRAMEWORK_UNDETECTED }, + ); + } + return { ctx, bootstrap: null }; +} + +async function resolveReadOnlyProjectContext( + cwd: string, + frameworkOverride: FrameworkInfo | undefined, + overrides: BootstrapOverrides, + machineOutput: boolean, +): Promise { + const detect = async () => gatherContext(cwd, frameworkOverride, overrides.pmOverride); + const ctx = machineOutput ? await detect() : await withSpinner("Detecting framework...", detect); + if (!ctx) { + throw new CliError( + "Could not detect an existing project. Read-only mode never bootstraps or modifies a directory.", + { code: ERROR_CODE.FRAMEWORK_UNDETECTED }, + ); + } + return { ctx, bootstrap: null }; +} + // --- Next steps --- function devCommand(pm: string): string { @@ -367,6 +976,17 @@ function printBootstrapNextSteps( } function printBootstrapManualSetupInfo(framework: FrameworkInfo): void { + if (framework.dep === "ios") { + const lines = [ + `\n Set up Clerk for ${framework.name}:`, + " Run `clerk init --app ` to link the project and configure a safely inspectable fresh SwiftUI target automatically.", + ' Manual source setup uses `Clerk.configure(publishableKey: "")` in the shipping @main App initializer and `.environment(Clerk.shared)` on the WindowGroup root.', + " Existing ProcessInfo/Run-scheme and LocalSecrets loaders remain supported compatibility paths; clerk init does not replace a custom runtime source.", + ]; + log.info(lines.map(dim).join("\n")); + return; + } + // Only reachable for non-keyless frameworks: keyless-capable ones resolve to // the "keyless" or "authenticate" strategy in agent mode instead. const lines = [ @@ -428,6 +1048,8 @@ type KeylessRunOptions = { fresh: boolean; /** Agent mode and `-y` both skip y/n prompts, so both must default to *not* replacing. */ skipConfirm: boolean; + /** The linked publishable key was wired directly into a proven native runtime sink. */ + authenticatedKeysHandled?: boolean; }; async function runStrategy( @@ -440,6 +1062,12 @@ async function runStrategy( printBootstrapManualSetupInfo(ctx.framework); return; case "authenticate": + if (keylessOptions.authenticatedKeysHandled) return; + // Native Swift does not load Clerk configuration from a dotenv file. + // A proven runtime sink is handled above; otherwise leave the project + // untouched and print the remaining source-level setup instead of + // creating an unused key file that may be tracked. + if (ctx.framework.dep === "ios") return; await pull({ file: ctx.envFile, cwd: ctx.cwd }); return; case "keyless": @@ -465,22 +1093,45 @@ async function authenticateAndLink( cwd: string, app: string | undefined, createIfMissing: string | undefined, -): Promise { - const label = await resolveAuthLabel(); + requireLinkedAppId: boolean, + preauthenticatedLabel?: string, +): Promise { + const label = preauthenticatedLabel ?? (await resolveAuthLabel()); const profile = await resolveProfile(cwd); const alreadyOnRequestedApp = profile && (!app || profile.profile.appId === app); if (label && alreadyOnRequestedApp) { log.info(dim(`${label} · Linked to ${profile.profile.appId}`)); - return; + return profile.profile.appId; } if (label) { log.info(dim(label)); } - await link({ skipIfLinked: true, app, cwd, createIfMissing }); + await link({ + skipIfLinked: true, + app, + cwd, + createIfMissing, + ...(requireLinkedAppId && { skipAutolink: true }), + }); + + const linked = app || requireLinkedAppId ? await resolveProfile(cwd) : undefined; + if (app && linked?.profile.appId !== app) { + if (profile) throwUserAbort(); + throw new CliError( + `The project was not linked to the requested Clerk application ${app}. No keys were written.`, + { code: ERROR_CODE.NOT_LINKED }, + ); + } + if (requireLinkedAppId && !linked) { + throw new CliError("The Clerk application link could not be verified. No keys were written.", { + code: ERROR_CODE.NOT_LINKED, + }); + } + return linked?.profile.appId; } // --- Keyless app setup --- @@ -582,8 +1233,9 @@ async function detectAndInstall( setTelemetryStage("install"); await installSdk(ctx); } - // Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a - // package manager here — the framework's scaffold plan prints install steps. + // The dedicated iOS phase already handled its Xcode package graph. Other + // non-npm ecosystems (for example Gradle) print install steps from their + // framework scaffold plan. setTelemetryStage("scaffold"); return scaffoldAndWrite(cwd, ctx, skipConfirm); @@ -673,6 +1325,22 @@ export function registerInit(program: Program): void { "--fresh", "Replace an existing unclaimed keyless application with a new one, instead of keeping it. Only applies when the strategy resolves to keyless — errors otherwise", ) + .option( + "--dry-run", + "Inspect an existing iOS project and print a setup plan without changing local or remote state", + ) + .option("--json", "Output the read-only iOS inspection and setup plan as JSON") + .option("--target ", "Select an iOS application target by name or PBX object ID") + .option("--allow-dirty", "Allow an iOS project file with existing local changes to be updated") + .option( + "--app-id-prefix ", + "Apple App ID Prefix to use when Clerk needs to register the selected iOS Bundle ID", + ) + .option("--sign-in-with-apple", "Enable native Sign in with Apple for the selected iOS target") + .option( + "--prebuilt-auth-ui", + "Add ClerkKitUI's prebuilt AuthView flow to a proven pristine SwiftUI target", + ) .option("-y, --yes", "Skip confirmation prompts") .option("--no-skills", "Skip the optional agent skills install prompt") .setExamples([ @@ -706,6 +1374,14 @@ export function registerInit(program: Program): void { command: "clerk init --keyless --fresh", description: "Replace an existing unclaimed keyless app with a new one", }, + { + command: "clerk init --dry-run", + description: "Inspect an iOS project and print its setup plan without changes", + }, + { + command: "clerk init --dry-run --target MyApp --json", + description: "Inspect one iOS app target and emit a machine-readable plan", + }, { command: "clerk init -y", description: "Skip all confirmation prompts" }, { command: "clerk init --no-skills", description: "Skip the agent skills install prompt" }, ]) diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts new file mode 100644 index 000000000..7f9746bff --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts @@ -0,0 +1,360 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + planIOSAssociatedDomain, + prepareIOSAssociatedDomainMutation, + validatePreparedIOSAssociatedDomain, +} from "./associated-domain.ts"; +import { + applyIOSAppleEntitlement, + planIOSAppleEntitlement, + prepareIOSAppleEntitlementMutation, + validatePreparedIOSAppleEntitlement, +} from "./apple-entitlement.ts"; +import { applyIOSFileTransaction } from "./file-transaction.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; +const APPLE_KEY = "com.apple.developer.applesignin"; +const HOST = "apple-native.clerk.example"; +const KEY = `pk_test_${Buffer.from(`${HOST}$`).toString("base64")}`; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-apple-entitlement-")); + temporaryDirectories.push(root); + return root; +} + +async function fixture(options: Parameters[1] = {}): Promise { + const root = await temporaryRoot(); + await createIOSFixture(root, options); + return root; +} + +function planOptions(root: string) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + }; +} + +function appleBlock(value = "Default", newline = "\n"): string { + return [ + `\t${APPLE_KEY}`, + "\t", + `\t\t${value}`, + "\t", + ].join(newline); +} + +async function replaceEntitlements(root: string, body: string, newline = "\n"): Promise { + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = [ + '', + '', + '', + "", + "\t", + body, + "", + "", + "", + ].join(newline); + await writeFile(path, source); + return source; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("iOS Sign in with Apple entitlement setup", () => { + test("adds exactly Default while preserving comments, CRLF newlines, mode, and idempotence", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const original = await replaceEntitlements( + root, + [ + "\tapplication-identifier", + "\tLEGACY1234.com.example.MyApp", + ].join("\r\n"), + "\r\n", + ); + await chmod(path, 0o640); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const prepared = await prepareIOSAppleEntitlementMutation(plan); + + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "modify" }], + }); + expect(prepared.status).toBe("ready"); + expect(JSON.stringify({ plan, prepared })).not.toContain("candidateBytes"); + expect(JSON.stringify({ plan, prepared })).not.toContain(""); + expect(source).toContain("\r\n"); + expect(source.replace(appleBlock("Default", "\r\n"), "")).toContain(original.split("\r\n")[5]!); + expect((await lstat(path)).mode & 0o7777).toBe(0o640); + + const digest = await treeDigest(root); + const rerun = await planIOSAppleEntitlement(planOptions(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSAppleEntitlement(rerun)).status).toBe("satisfied"); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("preserves the closing dict indentation without inserting a whitespace-only line", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = ( + await replaceEntitlements(root, "\texisting\n\tvalue") + ).replace("\n", "\n "); + await writeFile(path, source); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const result = await applyIOSAppleEntitlement(plan); + const updated = await readFile(path, "utf8"); + + expect(result.status).toBe("applied"); + expect(updated).toContain(`${appleBlock()}\n `); + expect(updated).not.toContain("\n \n"); + }); + + test("treats only the exact one-element Default array as satisfied", async () => { + const root = await fixture(); + await replaceEntitlements(root, appleBlock()); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + + expect(plan.status).toBe("satisfied"); + expect(plan.actions).toEqual([]); + expect((await prepareIOSAppleEntitlementMutation(plan)).status).toBe("satisfied"); + }); + + test("blocks conflicting, malformed, duplicated, and encoded Apple entitlement values", async () => { + const cases = [ + `${APPLE_KEY}PrimaryApp`, + `${APPLE_KEY}DefaultPrimaryApp`, + `${APPLE_KEY}Default`, + `${APPLE_KEY}Default${APPLE_KEY}Default`, + `com.apple.developer.applesigninDefault`, + ]; + for (const body of cases) { + const root = await fixture(); + await replaceEntitlements(root, body); + const before = await treeDigest(root); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toMatch( + /conflicting-apple-entitlement|unsupported-entitlements/, + ); + expect((await applyIOSAppleEntitlement(plan)).status).toBe("blocked"); + expect(await treeDigest(root)).toEqual(before); + } + }); + + test("updates every distinct entitlements variant selected by target configurations", async () => { + const root = await fixture(); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const debugPath = join(root, "MyApp", "MyApp.entitlements"); + const releasePath = join(root, "MyApp", "MyApp-Release.entitlements"); + await writeFile(releasePath, await readFile(debugPath)); + const project = await readFile(projectPath, "utf8"); + const marker = `${IOS_FIXTURE_IDS.targetRelease} = { isa = XCBuildConfiguration;`; + const start = project.indexOf(marker); + const end = project.indexOf("\n ", start + marker.length); + await writeFile( + projectPath, + `${project.slice(0, start)}${project + .slice(start, end) + .replace( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp-Release.entitlements;", + )}${project.slice(end)}`, + ); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan.files.map((file) => file.path)).toEqual([ + "MyApp/MyApp-Release.entitlements", + "MyApp/MyApp.entitlements", + ]); + expect(result.status).toBe("applied"); + for (const file of plan.files) { + expect(await readFile(join(root, file.path), "utf8")).toContain(APPLE_KEY); + } + }); + + test("inherits exact-target, generated-project, mixed-path, and shared-file safety blockers", async () => { + const invalid = await fixture(); + expect( + (await planIOSAppleEntitlement({ ...planOptions(invalid), targetId: "missing" })).blockers[0] + ?.code, + ).toBe("invalid-selection"); + + const generated = await fixture({ generated: "tuist" }); + expect((await planIOSAppleEntitlement(planOptions(generated))).blockers[0]?.code).toBe( + "generated-project", + ); + + const mixed = await fixture({ releaseEntitlements: false }); + expect((await planIOSAppleEntitlement(planOptions(mixed))).blockers[0]?.code).toBe( + "mixed-entitlements", + ); + + const shared = await fixture({ secondTarget: true }); + const projectPath = join(shared, "MyApp.xcodeproj", "project.pbxproj"); + let project = await readFile(projectPath, "utf8"); + for (const id of [IOS_FIXTURE_IDS.secondDebug, IOS_FIXTURE_IDS.secondRelease]) { + const marker = `${id} = { isa = XCBuildConfiguration; buildSettings = { `; + project = project.replace( + marker, + `${marker}CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; `, + ); + } + await writeFile(projectPath, project); + expect((await planIOSAppleEntitlement(planOptions(shared))).blockers[0]?.code).toBe( + "shared-entitlements", + ); + + const outside = await fixture(); + const unsafe = await fixture(); + const unsafePath = join(unsafe, "MyApp", "MyApp.entitlements"); + await rm(unsafePath); + await symlink(join(outside, "MyApp", "MyApp.entitlements"), unsafePath); + expect((await planIOSAppleEntitlement(planOptions(unsafe))).blockers[0]?.code).toBe( + "unsafe-entitlements", + ); + }); + + test("returns stale without touching a post-preview user edit", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const plan = await planIOSAppleEntitlement(planOptions(root)); + await writeFile(path, "newer user bytes\n"); + + const result = await applyIOSAppleEntitlement(plan); + + expect(result.status).toBe("stale"); + expect(await readFile(path, "utf8")).toBe("newer user bytes\n"); + }); + + test("creates and attaches a missing synchronized-root entitlements file", async () => { + const root = await fixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const path = join(root, "MyApp", "MyApp.entitlements"); + + const plan = await planIOSAppleEntitlement({ + ...planOptions(root), + allowMissingEntitlementsCreation: true, + }); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan).toMatchObject({ + status: "ready", + files: [{ path: "MyApp/MyApp.entitlements", operation: "create" }], + missingEntitlementsSettings: { status: "ready" }, + }); + expect(result.status).toBe("applied"); + expect(await readFile(path, "utf8")).toContain(appleBlock()); + expect((await lstat(path)).mode & 0o7777).toBe(0o644); + expect((await planIOSAppleEntitlement(planOptions(root))).status).toBe("satisfied"); + }); + + test("composes with the Associated Domains create and PBX candidates", async () => { + const root = await fixture({ includeKey: false }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${KEY}") } + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + const associatedPlan = await planIOSAssociatedDomain({ + ...planOptions(root), + deferToPublishableKey: true, + allowMissingEntitlementsCreation: true, + }); + const applePlan = await planIOSAppleEntitlement({ + ...planOptions(root), + allowMissingEntitlementsCreation: true, + }); + const associated = await prepareIOSAssociatedDomainMutation(associatedPlan, KEY); + expect(associated.status).toBe("ready"); + if (associated.status !== "ready") throw new Error("expected Associated Domains candidate"); + + const prepared = await prepareIOSAppleEntitlementMutation(applePlan, { + baseMutations: associated.mutations, + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected composed Apple candidate"); + expect(prepared.consumedBaseMutationPaths).toEqual( + associated.mutations.map((mutation) => mutation.path).sort(), + ); + expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); + + const result = await applyIOSFileTransaction(prepared.mutations, [ + () => validatePreparedIOSAppleEntitlement(prepared), + () => validatePreparedIOSAssociatedDomain(associated), + ]); + const source = await readFile(join(root, "MyApp", "MyApp.entitlements"), "utf8"); + + expect(result.status).toBe("applied"); + expect(source).toContain(APPLE_KEY); + expect(source).toContain(`webcredentials:${HOST}`); + }); + + test("composes with an existing entitlements candidate and rolls the aggregate write back", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = await readFile(path, "utf8"); + await writeFile( + path, + source.replace("webcredentials:clerk.example.test", "applinks:keep.test"), + ); + const before = await readFile(path); + const associatedPlan = await planIOSAssociatedDomain({ + ...planOptions(root), + deferToPublishableKey: true, + }); + const associated = await prepareIOSAssociatedDomainMutation(associatedPlan, KEY); + expect(associated.status).toBe("ready"); + if (associated.status !== "ready") throw new Error("expected Associated Domains candidate"); + const applePlan = await planIOSAppleEntitlement(planOptions(root)); + const prepared = await prepareIOSAppleEntitlementMutation(applePlan, { + baseMutations: associated.mutations, + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected composed Apple candidate"); + + const result = await applyIOSFileTransaction(prepared.mutations, [() => false]); + + expect(result.status).toBe("rolled-back"); + expect(await readFile(path)).toEqual(before); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts new file mode 100644 index 000000000..17b6ef3e0 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -0,0 +1,816 @@ +import { lstat, readFile } from "node:fs/promises"; +import { dirname, isAbsolute, resolve } from "node:path"; +import plist from "@expo/plist"; +import { + planIOSAssociatedDomain, + type IOSAssociatedDomainBlockerCode, +} from "./associated-domain.ts"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSFileTransaction, + hashIOSFileBytes, + type IOSCreateFileMutation, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + prepareIOSMissingEntitlementsSettingsMutation, + validateIOSMissingEntitlementsSettingsPostcondition, + type IOSMissingEntitlementsSettingsPlan, +} from "./entitlements-settings.ts"; +import { isRecord } from "./pbx.ts"; + +const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; +const APPLE_SIGN_IN_VALUE = "Default"; +const MAX_ENTITLEMENTS_BYTES = 1_000_000; + +export type IOSAppleEntitlementBlockerCode = + | IOSAssociatedDomainBlockerCode + | "conflicting-apple-entitlement" + | "invalid-plan"; + +export interface IOSAppleEntitlementBlocker { + code: IOSAppleEntitlementBlockerCode; + message: string; +} + +export interface IOSAppleEntitlementPlanFile { + /** Invocation-root-relative path. */ + path: string; + operation: "create" | "modify"; + expectedHash?: string; +} + +export interface IOSAppleEntitlementPlan { + schemaVersion: 1; + kind: "clerk-ios-sign-in-with-apple-entitlement"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + targetName?: string; + files: IOSAppleEntitlementPlanFile[]; + /** PBX settings needed only when the target has no entitlements file yet. */ + missingEntitlementsSettings?: IOSMissingEntitlementsSettingsPlan; + actions: string[]; + blockers: IOSAppleEntitlementBlocker[]; +} + +export interface IOSAppleEntitlementPlanOptions { + root: string; + /** Invocation-root-relative selected .xcodeproj path. */ + projectPath: string; + targetId: string; + /** Allows the strict synchronized-root planner to create and attach a new file. */ + allowMissingEntitlementsCreation?: boolean; +} + +export interface IOSAppleEntitlementPrepareOptions { + /** + * Previously prepared file candidates to compose with. Candidate bytes remain + * private and must never be serialized into output or telemetry. + */ + baseMutations?: readonly IOSFileMutation[]; +} + +export type PreparedIOSAppleEntitlementMutation = + | { status: "satisfied"; plan: IOSAppleEntitlementPlan } + | { status: "blocked"; plan: IOSAppleEntitlementPlan } + | { status: "stale"; plan: IOSAppleEntitlementPlan } + | { + status: "ready"; + plan: IOSAppleEntitlementPlan; + /** @internal Candidate bytes must never be serialized into output or telemetry. */ + mutations: IOSFileMutation[]; + /** Absolute paths whose caller-supplied candidates were semantically composed. */ + consumedBaseMutationPaths: string[]; + }; + +export interface IOSAppleEntitlementApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSAppleEntitlementPlan; +} + +interface EntitlementsDocument { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + hash: string; + mode: number; + source: string; + bom: boolean; + appleState: "absent" | "exact"; +} + +type EntitlementsInspection = + | { status: "safe"; document: EntitlementsDocument } + | { status: "blocked"; blocker: IOSAppleEntitlementBlocker }; + +function blocker( + code: IOSAppleEntitlementBlockerCode, + message: string, +): IOSAppleEntitlementBlocker { + return { code, message }; +} + +function planBase(options: IOSAppleEntitlementPlanOptions) { + return { + schemaVersion: 1 as const, + kind: "clerk-ios-sign-in-with-apple-entitlement" as const, + root: resolve(options.root), + projectPath: options.projectPath.replaceAll("\\", "/"), + targetId: options.targetId, + }; +} + +function blockedPlan( + options: IOSAppleEntitlementPlanOptions, + blockers: IOSAppleEntitlementBlocker[], + targetName?: string, +): IOSAppleEntitlementPlan { + return { + ...planBase(options), + status: "blocked", + ...(targetName ? { targetName } : {}), + files: [], + actions: [], + blockers, + }; +} + +function blockPrepared( + plan: IOSAppleEntitlementPlan, + code: IOSAppleEntitlementBlockerCode, + message: string, +): Extract { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + actions: [], + blockers: [blocker(code, message)], + }, + }; +} + +function stripXMLCommentsPreservingOffsets(source: string): string { + return source.replace(//g, (comment) => " ".repeat(comment.length)); +} + +function decodeXMLText(value: string): string | undefined { + if (/[<>]/.test(value)) return undefined; + let unsupported = false; + const decoded = value.replace( + /&(?:#x([0-9a-f]+)|#([0-9]+)|(amp|lt|gt|quot|apos));/gi, + (_entity, hex: string | undefined, decimal: string | undefined, named: string | undefined) => { + if (hex) return String.fromCodePoint(Number.parseInt(hex, 16)); + if (decimal) return String.fromCodePoint(Number.parseInt(decimal, 10)); + if (named === "amp") return "&"; + if (named === "lt") return "<"; + if (named === "gt") return ">"; + if (named === "quot") return '"'; + if (named === "apos") return "'"; + unsupported = true; + return ""; + }, + ); + if (unsupported || /&[^;\s]*;/.test(decoded)) return undefined; + return decoded; +} + +function appleKeyStructure(source: string): { + literalCount: number; + semanticCount: number; + safelyDecoded: boolean; +} { + const structural = stripXMLCommentsPreservingOffsets(source); + const literalCount = [ + ...structural.matchAll(/]*>\s*com\.apple\.developer\.applesignin\s*<\/key>/g), + ].length; + let semanticCount = 0; + let safelyDecoded = true; + for (const match of structural.matchAll(/]*>([\s\S]*?)<\/key>/g)) { + const decoded = decodeXMLText(match[1] ?? ""); + if (decoded == null) { + safelyDecoded = false; + continue; + } + if (decoded.trim() === APPLE_SIGN_IN_KEY) semanticCount += 1; + } + return { literalCount, semanticCount, safelyDecoded }; +} + +function inspectEntitlementsBytes( + root: string, + absolutePath: string, + bytes: Uint8Array, + mode: number, +): EntitlementsInspection { + const relativePath = relativeIOSPath(root, absolutePath); + try { + if (bytes.byteLength > MAX_ENTITLEMENTS_BYTES) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} must be an XML plist no larger than 1 MB.`, + ), + }; + } + if (new TextDecoder().decode(bytes.slice(0, 8)).startsWith("bplist")) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} is a binary plist. Save it as XML before automatic setup.`, + ), + }; + } + const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; + const source = new TextDecoder("utf-8", { fatal: true }).decode(bom ? bytes.slice(3) : bytes); + const parsed: unknown = plist.parse(source); + if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); + const rawValue = parsed[APPLE_SIGN_IN_KEY]; + const structure = appleKeyStructure(source); + if ( + !structure.safelyDecoded || + structure.literalCount > 1 || + structure.semanticCount > 1 || + (rawValue !== undefined && (structure.literalCount !== 1 || structure.semanticCount !== 1)) || + (rawValue === undefined && (structure.literalCount !== 0 || structure.semanticCount !== 0)) + ) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativePath} does not contain one safely editable literal Sign in with Apple key.`, + ), + }; + } + if (rawValue !== undefined) { + if ( + !Array.isArray(rawValue) || + rawValue.length !== 1 || + rawValue[0] !== APPLE_SIGN_IN_VALUE + ) { + return { + status: "blocked", + blocker: blocker( + "conflicting-apple-entitlement", + `${relativePath} has a conflicting Sign in with Apple entitlement; expected exactly ["Default"].`, + ), + }; + } + } + return { + status: "safe", + document: { + absolutePath, + relativePath, + bytes, + hash: hashIOSFileBytes(bytes), + mode, + source, + bom, + appleState: rawValue === undefined ? "absent" : "exact", + }, + }; + } catch { + return { + status: "blocked", + blocker: blocker( + "unreadable-entitlements", + `${relativePath} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } +} + +async function inspectEntitlementsFile( + root: string, + absolutePath: string, +): Promise { + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) { + return { + status: "blocked", + blocker: blocker( + "unsafe-entitlements", + `${relativeIOSPath(root, absolutePath)} resolves outside the inspected project root.`, + ), + }; + } + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink()) { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath(root, absolutePath)} must be a regular, non-symlink XML plist.`, + ), + }; + } + return inspectEntitlementsBytes( + root, + absolutePath, + new Uint8Array(await readFile(absolutePath)), + info.mode & 0o7777, + ); + } catch { + return { + status: "blocked", + blocker: blocker( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } +} + +function lineIndentAt(source: string, index: number): string { + const start = source.lastIndexOf("\n", index - 1) + 1; + return /^[\t ]*/.exec(source.slice(start, index))?.[0] ?? ""; +} + +function addAppleEntitlementToXML(source: string): string | undefined { + const structural = stripXMLCommentsPreservingOffsets(source); + if (appleKeyStructure(source).semanticCount !== 0) return undefined; + const dictClose = structural.lastIndexOf(""); + if (dictClose < 0) return undefined; + const newline = source.includes("\r\n") ? "\r\n" : "\n"; + const closingIndent = lineIndentAt(source, dictClose); + const insertionPoint = dictClose - closingIndent.length; + const firstKey = /${APPLE_SIGN_IN_KEY}`, + `${childIndent}`, + `${childIndent}\t${APPLE_SIGN_IN_VALUE}`, + `${childIndent}`, + "", + ].join(newline); + return `${source.slice(0, insertionPoint)}${insertion}${closingIndent}${source.slice(dictClose)}`; +} + +function bytesWithOptionalBOM(source: string, bom: boolean): Uint8Array { + const encoded = new TextEncoder().encode(source); + if (!bom) return encoded; + const bytes = new Uint8Array(encoded.length + 3); + bytes.set([0xef, 0xbb, 0xbf]); + bytes.set(encoded, 3); + return bytes; +} + +function newEntitlementsBytes(): Uint8Array { + return new TextEncoder().encode( + [ + '', + '', + '', + "", + `\t${APPLE_SIGN_IN_KEY}`, + "\t", + `\t\t${APPLE_SIGN_IN_VALUE}`, + "\t", + "", + "", + "", + ].join("\n"), + ); +} + +function isCreateMutation(mutation: IOSFileMutation): mutation is IOSCreateFileMutation { + return "kind" in mutation && mutation.kind === "create"; +} + +function isMissingFileError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: unknown }).code === "ENOENT" + ); +} + +function validBaseMutation(mutation: IOSFileMutation): boolean { + return ( + Number.isInteger(mutation.mode) && + mutation.mode >= 0 && + mutation.mode <= 0o7777 && + hashIOSFileBytes(mutation.candidateBytes) === mutation.candidateHash && + (isCreateMutation(mutation) || + hashIOSFileBytes(mutation.originalBytes) === mutation.originalHash) + ); +} + +function preparedWithHiddenMutations( + plan: IOSAppleEntitlementPlan, + mutations: IOSFileMutation[], + consumedBaseMutationPaths: string[], +): Extract { + const result = { + status: "ready" as const, + plan, + consumedBaseMutationPaths: [...consumedBaseMutationPaths].sort(), + } as Extract; + Object.defineProperty(result, "mutations", { + value: mutations, + enumerable: false, + configurable: false, + writable: false, + }); + return result; +} + +function samePlanFiles( + left: readonly IOSAppleEntitlementPlanFile[], + right: readonly IOSAppleEntitlementPlanFile[], +): boolean { + return ( + left.length === right.length && + left.every( + (file, index) => + file.path === right[index]?.path && + file.operation === right[index]?.operation && + file.expectedHash === right[index]?.expectedHash, + ) + ); +} + +function candidateWithApple(root: string, document: EntitlementsDocument): Uint8Array | undefined { + if (document.appleState === "exact") return document.bytes; + const source = addAppleEntitlementToXML(document.source); + if (!source) return undefined; + const bytes = bytesWithOptionalBOM(source, document.bom); + const inspected = inspectEntitlementsBytes(root, document.absolutePath, bytes, document.mode); + return inspected.status === "safe" && inspected.document.appleState === "exact" + ? bytes + : undefined; +} + +/** + * Plans the exact native Sign in with Apple entitlement across every selected + * target entitlements variant. No Apple or Clerk credentials are retained. + */ +export async function planIOSAppleEntitlement( + options: IOSAppleEntitlementPlanOptions, +): Promise { + const normalized = { ...options, root: resolve(options.root) }; + const entitlementProbe = await planIOSAssociatedDomain({ + root: normalized.root, + projectPath: normalized.projectPath, + targetId: normalized.targetId, + deferToPublishableKey: true, + allowMissingEntitlementsCreation: normalized.allowMissingEntitlementsCreation, + }); + if (entitlementProbe.status === "blocked") { + return blockedPlan( + normalized, + entitlementProbe.blockers.map((item) => blocker(item.code, item.message)), + entitlementProbe.targetName, + ); + } + + const files: IOSAppleEntitlementPlanFile[] = entitlementProbe.files.map((file) => ({ + path: file.path, + operation: file.operation, + ...(file.expectedHash ? { expectedHash: file.expectedHash } : {}), + })); + let allExact = files.length > 0 && files.every((file) => file.operation === "modify"); + for (const file of files) { + if (file.operation === "create") { + allExact = false; + continue; + } + const inspected = await inspectEntitlementsFile( + normalized.root, + resolve(normalized.root, file.path), + ); + if (inspected.status === "blocked") { + return blockedPlan(normalized, [inspected.blocker], entitlementProbe.targetName); + } + if (inspected.document.hash !== file.expectedHash) { + return blockedPlan( + normalized, + [blocker("stale-entitlements", `${file.path} changed while setup was inspected.`)], + entitlementProbe.targetName, + ); + } + if (inspected.document.appleState !== "exact") allExact = false; + } + + return { + ...planBase(normalized), + status: allExact ? "satisfied" : "ready", + ...(entitlementProbe.targetName ? { targetName: entitlementProbe.targetName } : {}), + files, + ...(entitlementProbe.missingEntitlementsSettings + ? { missingEntitlementsSettings: entitlementProbe.missingEntitlementsSettings } + : {}), + actions: allExact + ? [] + : [ + files.some((file) => file.operation === "create") + ? "Create and attach an iOS entitlements file with the Sign in with Apple entitlement set to Default." + : "Set the Sign in with Apple entitlement to Default in every selected-target iOS entitlements configuration.", + ], + blockers: [], + }; +} + +export async function prepareIOSAppleEntitlementMutation( + plan: IOSAppleEntitlementPlan, + options: IOSAppleEntitlementPrepareOptions = {}, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-sign-in-with-apple-entitlement" || + resolve(plan.root) !== plan.root || + !plan.projectPath || + !plan.targetId || + plan.files.length === 0 + ) { + return blockPrepared( + plan, + "invalid-plan", + "The serialized Apple entitlement plan is incomplete.", + ); + } + + const baseByPath = new Map(); + for (const mutation of options.baseMutations ?? []) { + const path = resolve(mutation.path); + if ( + !isAbsolute(mutation.path) || + path !== mutation.path || + baseByPath.has(path) || + !(await pathIsSafelyWithinIOSRoot(plan.root, path)) || + !validBaseMutation(mutation) + ) { + return blockPrepared( + plan, + "invalid-plan", + "A caller-supplied base mutation is invalid, duplicated, or outside the invocation root.", + ); + } + baseByPath.set(path, mutation); + } + + // Compare the exact authorized bytes before reparsing. A concurrent edit + // that also makes the plist malformed is stale input, not a new structural + // blocker, and its newer bytes must remain untouched. + for (const file of plan.files) { + const absolutePath = resolve(plan.root, file.path); + if (!(await pathIsSafelyWithinIOSRoot(plan.root, absolutePath))) { + return blockPrepared( + plan, + "invalid-plan", + "A planned entitlements path no longer resolves safely inside the invocation root.", + ); + } + if (file.operation === "create") { + try { + await lstat(absolutePath); + return { status: "stale", plan }; + } catch (error) { + if (!isMissingFileError(error)) return { status: "stale", plan }; + } + continue; + } + try { + if (!file.expectedHash) + return blockPrepared(plan, "invalid-plan", "A planned file hash is missing."); + const info = await lstat(absolutePath); + if ( + !info.isFile() || + info.isSymbolicLink() || + hashIOSFileBytes(await readFile(absolutePath)) !== file.expectedHash + ) { + return { status: "stale", plan }; + } + } catch { + return { status: "stale", plan }; + } + } + + const replanned = await planIOSAppleEntitlement({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowMissingEntitlementsCreation: plan.missingEntitlementsSettings != null, + }); + if (replanned.status === "blocked") return { status: "blocked", plan: replanned }; + if (!samePlanFiles(plan.files, replanned.files)) return { status: "stale", plan }; + if (plan.status === "satisfied") { + return replanned.status === "satisfied" + ? { status: "satisfied", plan: replanned } + : { status: "stale", plan }; + } + if (replanned.status !== "ready") return { status: "stale", plan }; + + const createFile = plan.files.find((file) => file.operation === "create"); + if (createFile) { + if ( + plan.files.length !== 1 || + !plan.missingEntitlementsSettings || + createFile.path !== plan.missingEntitlementsSettings.entitlementsPath + ) { + return blockPrepared( + plan, + "invalid-plan", + "The missing-entitlements Apple plan is internally inconsistent.", + ); + } + const entitlementsPath = resolve(plan.root, createFile.path); + const pbxprojPath = resolve(plan.root, plan.projectPath, "project.pbxproj"); + const baseEntitlements = baseByPath.get(entitlementsPath); + const basePbx = baseByPath.get(pbxprojPath); + if (baseEntitlements && !isCreateMutation(baseEntitlements)) { + return { status: "stale", plan }; + } + if (basePbx && isCreateMutation(basePbx)) { + return blockPrepared( + plan, + "invalid-plan", + "The base Xcode mutation must replace an existing file.", + ); + } + const settings = await prepareIOSMissingEntitlementsSettingsMutation( + plan.missingEntitlementsSettings, + basePbx as IOSExistingFileMutation | undefined, + ); + if (settings.status === "stale") return { status: "stale", plan }; + if (settings.status !== "ready") { + return blockPrepared( + plan, + "invalid-plan", + "The iOS entitlements build settings could not be prepared safely.", + ); + } + + let createMutation: IOSCreateFileMutation; + if (baseEntitlements) { + const expectedIdentity = plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; + if ( + !expectedIdentity || + baseEntitlements.expectedParentIdentity.device !== expectedIdentity.device || + baseEntitlements.expectedParentIdentity.inode !== expectedIdentity.inode + ) { + return { status: "stale", plan }; + } + const inspected = inspectEntitlementsBytes( + plan.root, + entitlementsPath, + baseEntitlements.candidateBytes, + baseEntitlements.mode, + ); + if (inspected.status === "blocked") { + return blockPrepared(plan, inspected.blocker.code, inspected.blocker.message); + } + const candidateBytes = candidateWithApple(plan.root, inspected.document); + if (!candidateBytes) { + return blockPrepared( + plan, + "unsupported-entitlements", + "The composed entitlements candidate could not be updated safely.", + ); + } + createMutation = { + ...baseEntitlements, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + }; + } else { + const expectedParentIdentity = + plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; + if ( + !expectedParentIdentity || + dirname(entitlementsPath) !== + resolve(plan.root, plan.missingEntitlementsSettings.synchronizedRootPath ?? "") + ) { + return blockPrepared( + plan, + "invalid-plan", + "The entitlements destination no longer matches its synchronized target root.", + ); + } + const candidateBytes = newEntitlementsBytes(); + createMutation = { + kind: "create", + path: entitlementsPath, + expectedParentIdentity: { ...expectedParentIdentity }, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: 0o644, + }; + } + return preparedWithHiddenMutations( + plan, + [createMutation, settings.mutation], + [...(baseEntitlements ? [entitlementsPath] : []), ...(basePbx ? [pbxprojPath] : [])], + ); + } + + const mutations: IOSExistingFileMutation[] = []; + const consumed: string[] = []; + for (const file of plan.files) { + if (file.operation !== "modify" || !file.expectedHash) { + return blockPrepared( + plan, + "invalid-plan", + "The Apple entitlement plan has an invalid file entry.", + ); + } + const absolutePath = resolve(plan.root, file.path); + const current = await inspectEntitlementsFile(plan.root, absolutePath); + if (current.status === "blocked" || current.document.hash !== file.expectedHash) { + return { status: "stale", plan }; + } + const base = baseByPath.get(absolutePath); + if (base && isCreateMutation(base)) return { status: "stale", plan }; + if ( + base && + (base.originalHash !== file.expectedHash || + base.mode !== current.document.mode || + hashIOSFileBytes(base.originalBytes) !== current.document.hash) + ) { + return { status: "stale", plan }; + } + const source = base + ? inspectEntitlementsBytes(plan.root, absolutePath, base.candidateBytes, base.mode) + : current; + if (source.status === "blocked") { + return blockPrepared(plan, source.blocker.code, source.blocker.message); + } + if (source.document.appleState === "exact") { + if (base && current.document.appleState !== "exact") { + mutations.push(base); + consumed.push(absolutePath); + } + continue; + } + const candidateBytes = candidateWithApple(plan.root, source.document); + if (!candidateBytes) { + return blockPrepared( + plan, + "unsupported-entitlements", + `${file.path} could not be updated without rewriting unrelated plist content.`, + ); + } + mutations.push({ + path: absolutePath, + originalBytes: base?.originalBytes ?? current.document.bytes, + originalHash: base?.originalHash ?? current.document.hash, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: base?.mode ?? current.document.mode, + }); + if (base) consumed.push(absolutePath); + } + if (mutations.length === 0) return { status: "satisfied", plan }; + return preparedWithHiddenMutations(plan, mutations, consumed); +} + +export async function validatePreparedIOSAppleEntitlement( + prepared: Extract, +): Promise { + if ( + prepared.plan.missingEntitlementsSettings && + !(await validateIOSMissingEntitlementsSettingsPostcondition( + prepared.plan.missingEntitlementsSettings, + )) + ) { + return false; + } + const current = await planIOSAppleEntitlement({ + root: prepared.plan.root, + projectPath: prepared.plan.projectPath, + targetId: prepared.plan.targetId, + }); + const expectedPaths = prepared.plan.files.map((file) => file.path).sort(); + return ( + current.status === "satisfied" && + current.files + .map((file) => file.path) + .sort() + .every((path, index) => path === expectedPaths[index]) && + current.files.length === expectedPaths.length + ); +} + +export async function applyIOSAppleEntitlement( + plan: IOSAppleEntitlementPlan, +): Promise { + const prepared = await prepareIOSAppleEntitlementMutation(plan); + if (prepared.status === "blocked") return { status: "blocked", plan: prepared.plan }; + if (prepared.status === "stale") return { status: "stale", plan: prepared.plan }; + if (prepared.status === "satisfied") return { status: "satisfied", plan: prepared.plan }; + const result = await applyIOSFileTransaction(prepared.mutations, [ + async () => validatePreparedIOSAppleEntitlement(prepared), + ]); + return { status: result.status, plan: prepared.plan }; +} diff --git a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts new file mode 100644 index 000000000..e39591659 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts @@ -0,0 +1,737 @@ +import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { cp, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { inspectIOSProject } from "./inspect.ts"; +import { applyIOSLocalSetup, applyIOSPlannedLocalSetup, applyIOSRuntimeKeySetup } from "./apply.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; +import * as prompts from "../../../lib/prompts.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import type { PbxObjects } from "./pbx.ts"; +import { + authFixtureKey, + canonicalSwiftUIFixture, + createIsolatedCLIState, + createUnconfiguredFixture, + developmentPublishableKey, + runCLI, + runCommand, + temporaryDirectories, +} from "./apply-cli.test-helpers.ts"; +import { ERROR_CODE } from "../../../lib/errors.ts"; + +setDefaultTimeout(15_000); + +describe("clerk init iOS SDK runtime apply", () => { + const captured = useCaptureLog(); + test("does not combine LocalSecrets mutation with new entitlements creation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-missing-entitlements-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + expect(setup.runtimeKeyPlan).toMatchObject({ status: "ready" }); + expect(setup.associatedDomainPlan).toBeUndefined(); + expect(await treeDigest(root)).toEqual(before); + }); + + test("hands off a runtime key without rewriting a fully linked unattributed package graph", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-unattributed-handoff-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const unattributedProject = (await Bun.file(projectFile).text()) + .replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKit;`, + "productName = ClerkKit;", + ) + .replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKitUI;`, + "productName = ClerkKitUI;", + ); + await Bun.write(projectFile, unattributedProject); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + const beforeProjectBytes = await Bun.file(projectFile).bytes(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + expect(setup.runtimeKeyPlan).toMatchObject({ status: "ready" }); + expect(await Bun.file(projectFile).bytes()).toEqual(beforeProjectBytes); + + const key = developmentPublishableKey("unattributed.clerk.example"); + await applyIOSRuntimeKeySetup(setup.runtimeKeyPlan!, key); + + expect(await Bun.file(projectFile).bytes()).toEqual(beforeProjectBytes); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).toContain(key); + }); + + test("does not bypass AuthView compatibility proof for unattributed Clerk products", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-unattributed-auth-view-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + + const initialSetup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + }); + await applyIOSPlannedLocalSetup(initialSetup, authFixtureKey); + + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await Bun.file(projectFile).text()); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const object of Object.values(objects)) { + if (object.isa === "XCRemoteSwiftPackageReference") { + object.requirement = { kind: "exactVersion", version: "1.2.0" }; + } + if ( + object.isa === "XCSwiftPackageProductDependency" && + ["ClerkKit", "ClerkKitUI"].includes(String(object.productName)) + ) { + delete object.package; + } + } + await Bun.write(projectFile, buildPbxProject(project)); + + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }), + ).rejects.toThrow("not attributed to a Swift package reference"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not bypass unattributed-product review when a policy-required UI product is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-unattributed-missing-ui-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: "core-only", + }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const unattributedProject = (await Bun.file(projectFile).text()).replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKit;`, + "productName = ClerkKit;", + ); + await Bun.write(projectFile, unattributedProject); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("Clerk iOS SDK could not be installed automatically"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not bypass a non-attribution package blocker when all required products are linked", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-wrong-package-runtime-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const wrongPackageId = "919191919191919191919191"; + const malformed = (await Bun.file(projectFile).text()) + .replace( + ` ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ` ${wrongPackageId} = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://example.com/not-clerk.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; };\n ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ) + .replaceAll(`package = ${IOS_FIXTURE_IDS.clerkPackage};`, `package = ${wrongPackageId};`); + await Bun.write(projectFile, malformed); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("verified clerk-ios reference"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not let an unattributed product hide another product's wrong package", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-mixed-package-runtime-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const wrongPackageId = "919191919191919191919191"; + const mixed = (await Bun.file(projectFile).text()) + .replace( + ` ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ` ${wrongPackageId} = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://example.com/not-clerk.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; };\n ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ) + .replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKit;`, + "productName = ClerkKit;", + ) + .replace( + `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKitUI;`, + `package = ${wrongPackageId}; productName = ClerkKitUI;`, + ); + await Bun.write(projectFile, mixed); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("verified clerk-ios reference"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("blocks all local writes when a structurally eligible runtime sink fails strict preflight", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-blocked-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), ""); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("readable XML property-list dictionary"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("a mismatched expected app key blocks before SDK or key mutation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-relink-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + const before = await treeDigest(root); + const expectedKey = developmentPublishableKey("different.clerk.example"); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + await expect(applyIOSPlannedLocalSetup(setup, expectedKey)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_PUBLISHABLE_KEY_MISMATCH, + message: expect.stringContaining( + "does not match the linked Clerk application's development key", + ), + }); + + expect(await treeDigest(root)).toEqual(before); + expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); + }); + + test("a requested app with a satisfied sink fails closed without its expected key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-missing-expected-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + await expect(applyIOSPlannedLocalSetup(setup)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_PUBLISHABLE_KEY_UNAVAILABLE, + message: expect.stringContaining("development publishable key was not available"), + }); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("a matching expected app key permits SDK installation regardless of local profile", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-match-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + const key = developmentPublishableKey("matching.clerk.example"); + const localSecretsPath = join(root, "MyApp", "LocalSecrets.plist"); + await Bun.write( + localSecretsPath, + `CLERK_PUBLISHABLE_KEY${key}`, + ); + + const result = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + await applyIOSPlannedLocalSetup(result, key); + + expect(result).toMatchObject({ + requiresLinkedApp: true, + verifiesExistingKey: true, + }); + expect((await inspectIOSProject(root, { target: "MyApp" })).appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + expect(await Bun.file(localSecretsPath).text()).toContain(key); + expect(JSON.stringify(result)).not.toContain(key); + }); + + test("a LocalSecrets change during SDK validation rolls the project edit back", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-verification-race-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + const expectedKey = developmentPublishableKey("verified.clerk.example"); + const concurrentKey = developmentPublishableKey("concurrent.clerk.example"); + const localSecretsPath = join(root, "MyApp", "LocalSecrets.plist"); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const plist = (key: string) => + `CLERK_PUBLISHABLE_KEY${key}`; + await Bun.write(localSecretsPath, plist(expectedKey)); + const projectBefore = await Bun.file(projectPath).bytes(); + const entitlementsBefore = await Bun.file(entitlementsPath).bytes(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + await expect( + applyIOSPlannedLocalSetup(setup, expectedKey, { + beforePostWriteValidation: async () => { + await Bun.write(localSecretsPath, plist(concurrentKey)); + }, + }), + ).rejects.toThrow("SDK change was restored byte-for-byte"); + + expect(await Bun.file(projectPath).bytes()).toEqual(projectBefore); + expect(await Bun.file(entitlementsPath).bytes()).toEqual(entitlementsBefore); + expect(await Bun.file(localSecretsPath).text()).toBe(plist(concurrentKey)); + expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); + expect(`${captured.out}\n${captured.err}`).not.toContain(concurrentKey); + }); + + test("an inline key for another application blocks the SDK and source transaction", async () => { + const root = await createUnconfiguredFixture(); + const existingKey = developmentPublishableKey("existing-inline.clerk.example"); + const selectedKey = developmentPublishableKey("selected-inline.clerk.example"); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "${existingKey}") + } + + var body: some Scene { + WindowGroup { + Text("Hello") + .environment(Clerk.shared) + } + } +} +`, + ); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + await expect(applyIOSPlannedLocalSetup(setup, selectedKey)).rejects.toThrow( + "belongs to a different Clerk application", + ); + + expect(await treeDigest(root)).toEqual(before); + expect(`${captured.out}\n${captured.err}`).not.toContain(selectedKey); + }); + + test("a post-preview Swift edit prevents both source and SDK writes", async () => { + const root = await createUnconfiguredFixture(); + const key = developmentPublishableKey("stale-direct.clerk.example"); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write(sourcePath, `${await Bun.file(sourcePath).text()}\n// concurrent edit\n`); + const concurrentTree = await treeDigest(root); + + await expect(applyIOSPlannedLocalSetup(setup, key)).rejects.toThrow( + "Swift app entry source changed after the preview", + ); + + expect(await treeDigest(root)).toEqual(concurrentTree); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "absent", + clerkKitUI: "absent", + }); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + }); + + test("a post-preview entitlements edit prevents both source and SDK writes", async () => { + const root = await createUnconfiguredFixture(); + const key = developmentPublishableKey("stale-entitlements.clerk.example"); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const projectBefore = await Bun.file(projectPath).bytes(); + const sourceBefore = await Bun.file(sourcePath).bytes(); + const concurrentEntitlements = (await Bun.file(entitlementsPath).text()).replace( + "", + "\n", + ); + await Bun.write(entitlementsPath, concurrentEntitlements); + + await expect(applyIOSPlannedLocalSetup(setup, key)).rejects.toThrow( + "entitlements file changed after the preview", + ); + + expect(await Bun.file(projectPath).bytes()).toEqual(projectBefore); + expect(await Bun.file(sourcePath).bytes()).toEqual(sourceBefore); + expect(await Bun.file(entitlementsPath).text()).toBe(concurrentEntitlements); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "absent", + clerkKitUI: "absent", + }); + expect(`${captured.out}\n${captured.err}`).not.toContain(key); + }); + + test("a declined human confirmation leaves the project byte-identical", async () => { + const root = await createUnconfiguredFixture(); + const before = await treeDigest(root); + const confirmation = spyOn(prompts, "confirm").mockResolvedValue(false); + + try { + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: false, + agent: false, + allowDirty: false, + }), + ).rejects.toMatchObject({ name: "UserAbortError" }); + } finally { + confirmation.mockRestore(); + } + + expect(await treeDigest(root)).toEqual(before); + }); + + test("dry-run advertises the action but remains byte-for-byte read-only", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI(root, ["init", "--dry-run", "--json"], configDir); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "install-clerk-sdk", + status: "required", + automatable: true, + }), + ); + expect(await treeDigest(root)).toEqual(before); + }); + + test("dry-run advertises safe missing-entitlements creation without writing", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI(root, ["init", "--dry-run", "--json"], configDir); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "add-associated-domain", + status: "required", + automatable: true, + }), + ); + expect(output.nativeReadiness.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp.entitlements"], + blockers: [], + }); + expect(JSON.stringify(output)).not.toContain(authFixtureKey); + expect(await treeDigest(root)).toEqual(before); + }); + + test("requires --allow-dirty for the planned project file and preserves its changes", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, [ + "git", + "-c", + "user.name=Clerk CLI Tests", + "-c", + "user.email=cli-tests@clerk.invalid", + "commit", + "-m", + "fixture", + ]); + const dirtyProject = (await Bun.file(projectFile).text()).replaceAll( + "PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;", + "PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp.local;", + ); + await Bun.write(projectFile, dirtyProject); + + const blocked = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app-id-prefix", "LEGACY1234"], + configDir, + ); + expect(blocked.exitCode).toBe(1); + expect(`${blocked.stdout}\n${blocked.stderr}`).toContain("--allow-dirty"); + expect(await Bun.file(projectFile).text()).toBe(dirtyProject); + + const applied = await runCLI( + root, + [ + "--mode", + "agent", + "init", + "--yes", + "--allow-dirty", + "--target", + "MyApp", + "--app-id-prefix", + "LEGACY1234", + ], + configDir, + ); + expect(applied.exitCode).toBe(0); + expect(await Bun.file(projectFile).text()).toContain( + "PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp.local;", + ); + }); + + test("requires --allow-dirty for entitlements and preserves unrelated local content", async () => { + const root = await createUnconfiguredFixture(); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, [ + "git", + "-c", + "user.name=Clerk CLI Tests", + "-c", + "user.email=cli-tests@clerk.invalid", + "commit", + "-m", + "fixture", + ]); + const localComment = ""; + const dirtyEntitlements = (await Bun.file(entitlementsPath).text()).replace( + "", + `\n${localComment}`, + ); + await Bun.write(entitlementsPath, dirtyEntitlements); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("MyApp/MyApp.entitlements already has local changes"); + expect(await Bun.file(entitlementsPath).text()).toBe(dirtyEntitlements); + + const key = developmentPublishableKey("dirty-entitlements.clerk.example"); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + }); + await applyIOSPlannedLocalSetup(setup, key); + + const appliedEntitlements = await Bun.file(entitlementsPath).text(); + expect(appliedEntitlements).toContain(localComment); + expect(appliedEntitlements).toContain("webcredentials:dirty-entitlements.clerk.example"); + }); + + test("dirty-checks .gitignore when crash-safe key staging needs a guard", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-dirty-ignore-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n"); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, [ + "git", + "-c", + "user.name=Clerk CLI Tests", + "-c", + "user.email=cli-tests@clerk.invalid", + "commit", + "-m", + "fixture", + ]); + await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n# local change\n"); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow(".gitignore already has local changes"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("fails closed when Git cannot determine the selected project file status", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, [ + "git", + "-c", + "user.name=Clerk CLI Tests", + "-c", + "user.email=cli-tests@clerk.invalid", + "commit", + "-m", + "fixture", + ]); + await Bun.write(join(root, ".git", "index"), "not a valid Git index"); + const before = await Bun.file(projectFile).text(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("could not be verified"); + expect(await Bun.file(projectFile).text()).toBe(before); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test-helpers.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test-helpers.ts new file mode 100644 index 000000000..42f3bd40d --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test-helpers.ts @@ -0,0 +1,278 @@ +import { afterAll, afterEach } from "bun:test"; +import { cp, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; +import type { PbxObjects } from "./pbx.ts"; + +export const temporaryDirectories: string[] = []; +const cliPath = resolve(import.meta.dir, "../../../cli.ts"); +export const canonicalSwiftUIFixture = resolve( + import.meta.dir, + "../../../../../../test/e2e/fixtures/ios", +); +export const authFixtureKey = `pk_test_${Buffer.from("ios-apply.clerk.example$").toString("base64")}`; +const authFixtureApp = { + application_id: "app_ios_apply", + name: "iOS Apply Fixture", + instances: [ + { + instance_id: "ins_ios_apply_development", + environment_type: "development", + publishable_key: authFixtureKey, + }, + ], +}; +let nativeAPIEnabled = false; +let nextIOSApplication = 1; +let appleConfigVersion = "v1_1234abcd"; +let appleConnection: Record = { + enabled: false, + authenticatable: true, +}; +const iosApplications: Array<{ + object: "ios_application"; + id: string; + app_id_prefix: string; + bundle_id: string; + created_at: number; + updated_at: number; +}> = []; +const authServer = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/v1/platform/applications") { + return Response.json([]); + } + if (request.method === "POST" && url.pathname === "/v1/platform/applications") { + return Response.json(authFixtureApp); + } + if ( + request.method === "GET" && + url.pathname === `/v1/platform/applications/${authFixtureApp.application_id}` + ) { + return Response.json(authFixtureApp); + } + const nativeBase = `/v1/platform/applications/${authFixtureApp.application_id}/instances/ins_ios_apply_development`; + if (url.pathname === `${nativeBase}/native_settings`) { + if (request.method === "GET") { + return Response.json({ object: "native_settings", api_enabled: nativeAPIEnabled }); + } + if (request.method === "PATCH") { + const body = (await request.json()) as { api_enabled?: boolean }; + if (body.api_enabled !== true) return Response.json({ error: "invalid" }, { status: 422 }); + nativeAPIEnabled = true; + return Response.json({ object: "native_settings", api_enabled: true }); + } + } + if (url.pathname === `${nativeBase}/native_applications/ios`) { + if (request.method === "GET") return Response.json(iosApplications); + if (request.method === "POST") { + const body = (await request.json()) as { app_id_prefix: string; bundle_id: string }; + const existing = iosApplications.find( + (application) => + application.app_id_prefix === body.app_id_prefix && + application.bundle_id === body.bundle_id, + ); + if (existing) return Response.json(existing, { status: 201 }); + const now = Date.now(); + const application = { + object: "ios_application" as const, + id: `iosapp_${nextIOSApplication++}`, + app_id_prefix: body.app_id_prefix, + bundle_id: body.bundle_id, + created_at: now, + updated_at: now, + }; + iosApplications.push(application); + return Response.json(application, { status: 201 }); + } + } + if (url.pathname === `${nativeBase}/config/schema` && request.method === "GET") { + return Response.json({ + type: "object", + properties: { + connection_oauth_apple: { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + bundle_id: { type: "string" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + team_id: { type: "string" }, + key_id: { type: "string" }, + }, + }, + }, + }); + } + if (url.pathname === `${nativeBase}/config`) { + if (request.method === "GET") { + return Response.json({ + config_version: appleConfigVersion, + connection_oauth_apple: appleConnection, + }); + } + if (request.method === "PATCH") { + const body = (await request.json()) as { + connection_oauth_apple?: Record; + }; + const update = body.connection_oauth_apple; + if (!update) return Response.json({ error: "invalid" }, { status: 422 }); + const before = { ...appleConnection }; + const after = { ...appleConnection, ...update }; + const dryRun = url.searchParams.get("dry_run") === "true"; + if (!dryRun) { + appleConnection = after; + appleConfigVersion = "v1_9876fedc"; + } + return Response.json({ + config_version: dryRun ? appleConfigVersion : "v1_9876fedc", + dry_run: dryRun, + before: { connection_oauth_apple: before }, + after: { connection_oauth_apple: after }, + }); + } + } + return new Response("Not found", { status: 404 }); + }, +}); + +afterAll(async () => authServer.stop(true)); + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map(async (path) => rm(path, { recursive: true })), + ); + nativeAPIEnabled = false; + nextIOSApplication = 1; + iosApplications.splice(0); + resetAppleConfiguration({ enabled: false, authenticatable: true }); +}); + +export async function createIsolatedCLIState(): Promise { + const configDir = await mkdtemp(join(tmpdir(), "clerk-ios-apply-config-")); + temporaryDirectories.push(configDir); + await Bun.write( + join(configDir, "config.json"), + JSON.stringify({ + profiles: {}, + telemetryNoticeShown: true, + machineUuid: "00000000-0000-4000-8000-000000000000", + }) + "\n", + ); + return configDir; +} + +function isolatedEnvironment(configDir: string): Record { + const env: Record = { ...Bun.env }; + for (const key of Object.keys(env)) { + if (key.includes("CLERK")) delete env[key]; + } + delete env.CI; + delete env.DO_NOT_TRACK; + delete env.NO_UPDATE_NOTIFIER; + return { + ...env, + NO_COLOR: "1", + CLERK_CONFIG_DIR: configDir, + CLERK_PLATFORM_API_KEY: "ak_test_ios_apply_fixture", + CLERK_PLATFORM_API_URL: authServer.url.origin, + CLERK_TELEMETRY_DISABLED: "1", + }; +} + +export async function runCLI(root: string, args: string[], configDir: string) { + const child = Bun.spawn([process.execPath, cliPath, ...args], { + cwd: root, + env: isolatedEnvironment(configDir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exitCode }; +} + +export async function runCommand(root: string, command: string[]): Promise { + const child = Bun.spawn(command, { cwd: root, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + if (exitCode !== 0) { + throw new Error(`${command.join(" ")} failed (${exitCode})\n${stdout}\n${stderr}`); + } +} + +export async function createUnconfiguredFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-apply-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + return root; +} + +export async function createCustomFlowWithStarterContent(): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-custom-flow-auth-view-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + return root; +} + +export async function addStarterContentViewToFixture(root: string): Promise { + const contentFileId = "616161616161616161616161"; + const contentBuildFileId = "626262626262626262626262"; + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await Bun.file(projectPath).text()); + const objects = (project as unknown as { objects: PbxObjects }).objects; + (objects[IOS_FIXTURE_IDS.appGroup]!.children as string[]).push(contentFileId); + (objects[IOS_FIXTURE_IDS.sourcesPhase]!.files as string[]).push(contentBuildFileId); + objects[contentFileId] = { + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + path: "ContentView.swift", + sourceTree: "", + }; + objects[contentBuildFileId] = { isa: "PBXBuildFile", fileRef: contentFileId }; + await Bun.write(projectPath, buildPbxProject(project)); + await cp( + join(canonicalSwiftUIFixture, "MyApp", "ContentView.swift"), + join(root, "MyApp", "ContentView.swift"), + ); +} + +export function developmentPublishableKey(host: string): string { + return `pk_test_${Buffer.from(`${host}$`).toString("base64")}`; +} + +export function resetAppleConfiguration(connection: Record): void { + appleConfigVersion = "v1_1234abcd"; + appleConnection = connection; +} + +export function currentAppleConnection(): Record { + return appleConnection; +} diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts new file mode 100644 index 000000000..0bc0c8988 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -0,0 +1,768 @@ +import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; +import { cp, mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { inspectIOSProject } from "./inspect.ts"; +import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; +import * as prompts from "../../../lib/prompts.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import type { PbxObjects } from "./pbx.ts"; +import { + addStarterContentViewToFixture, + authFixtureKey, + canonicalSwiftUIFixture, + createCustomFlowWithStarterContent, + createIsolatedCLIState, + createUnconfiguredFixture, + currentAppleConnection, + resetAppleConfiguration, + runCLI, + runCommand, + temporaryDirectories, +} from "./apply-cli.test-helpers.ts"; + +setDefaultTimeout(15_000); + +describe("clerk init iOS SDK apply", () => { + const captured = useCaptureLog(); + + test("applies the explicit prebuilt AuthView opt-in in the aggregate Swift transaction", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-prebuilt-auth-apply-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + const beforeApp = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }); + expect(setup.unverifiedAppIdPrefixSuggestion).toEqual({ + source: "xcode-development-team", + value: "ABCDE12345", + }); + expect(setup.prebuiltAuthPlan?.status).toBe("ready"); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + + const source = await Bun.file(join(root, "MyApp", "ContentView.swift")).text(); + expect(source).toContain("UserButton(signedOutContent:"); + expect(source).toContain('Button("Sign up")'); + expect(source).toContain("@State private var authIsPresented = false"); + expect(source).toContain(".prefetchClerkImages()"); + expect(source).toContain(".sheet(isPresented: $authIsPresented)"); + expect(source).toContain("AuthView()"); + expect(source).not.toContain("@Environment"); + expect(source).not.toContain(".onOpenURL"); + expect(source).not.toContain("clerk.auth.events"); + expect(source).not.toContain("clerk.session?.tasks"); + expect(source).not.toContain(".alert("); + expect(source).not.toContain("#Preview"); + expect(await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text()).not.toBe(beforeApp); + + const firstDigest = await treeDigest(root); + const rerun = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }); + expect(rerun.prebuiltAuthPlan?.status).toBe("satisfied"); + await applyIOSPlannedLocalSetup(rerun, authFixtureKey); + expect(await treeDigest(root)).toEqual(firstDigest); + expect(`${captured.out}\n${captured.err}`).not.toContain(authFixtureKey); + }); + + test("blocks an explicit prebuilt AuthView below iOS 17 before the aggregate write", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-prebuilt-auth-ios16-apply-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await Bun.write( + projectPath, + (await Bun.file(projectPath).text()).replaceAll( + "IPHONEOS_DEPLOYMENT_TARGET = 17.0", + "IPHONEOS_DEPLOYMENT_TARGET = 16.4", + ), + ); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + prebuiltAuthUI: true, + }), + ).rejects.toThrow("require iOS 17.0 or newer"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).not.toContain( + "AuthView()", + ); + }); + + test("links ClerkKitUI when a custom-flow target explicitly opts into AuthView", async () => { + const root = await createCustomFlowWithStarterContent(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }); + + expect(setup.prebuiltAuthPlan?.status).toBe("ready"); + expect(setup.directConfigPlan).toMatchObject({ + status: "ready", + changes: { + configuration: "insert-initializer", + environment: "insert", + }, + }); + expect(setup.requiresDevelopmentKey).toBe(true); + expect(setup.sdkInstallPlan?.products).toEqual(["ClerkKit", "ClerkKitUI"]); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "linked", + clerkKitUI: "linked", + }); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).toContain("AuthView()"); + const appSource = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(appSource).toContain("Clerk.configure(publishableKey:"); + expect(appSource).toContain(".environment(Clerk.shared)"); + }); + + test("links ClerkKitUI when a custom-flow target accepts the AuthView prompt", async () => { + const root = await createCustomFlowWithStarterContent(); + const confirmation = spyOn(prompts, "confirm").mockImplementation(async ({ message }) => { + if (message.startsWith("Add ClerkKitUI's prebuilt authentication UI")) return true; + if (message.startsWith("Enable native Sign in with Apple")) return false; + if (message === "Apply these local iOS changes?") return true; + throw new Error(`Unexpected confirmation: ${message}`); + }); + + try { + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: false, + agent: false, + allowDirty: true, + }); + + expect(setup.prebuiltAuthRequested).toBe(true); + expect(setup.directConfigPlan?.status).toBe("ready"); + expect(setup.sdkInstallPlan?.products).toEqual(["ClerkKit", "ClerkKitUI"]); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + } finally { + confirmation.mockRestore(); + } + + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages.clerkKitUI).toBe("linked"); + const appSource = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(appSource).toContain("Clerk.configure(publishableKey:"); + expect(appSource).toContain(".environment(Clerk.shared)"); + }); + + test("refuses a ProcessInfo compatibility path without proven SwiftUI environment injection", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-process-info-auth-view-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure( + publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "" + ) + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + await Bun.write( + join(schemeDirectory, "MyApp.xcscheme"), + ``, + ); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }), + ).rejects.toThrow("Clerk.shared is not proven in the shipping SwiftUI root environment"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).not.toContain( + "AuthView()", + ); + }); + + test("refuses a LocalSecrets compatibility path without proven SwiftUI environment injection", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-secrets-auth-view-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await addStarterContentViewToFixture(root); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + appPath, + (await Bun.file(appPath).text()).replace("import ClerkKitUI\n", "").replace( + `AuthView() + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } }`, + "ContentView()", + ), + ); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEY${authFixtureKey}`, + ); + await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n"); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }), + ).rejects.toThrow("Clerk.shared is not proven in the shipping SwiftUI root environment"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).not.toContain( + "AuthView()", + ); + }); + + test("revalidates AuthView runtime prerequisites before committing any planned file", async () => { + const root = await createCustomFlowWithStarterContent(); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: true, + }); + const before = await treeDigest(root); + + await expect( + applyIOSPlannedLocalSetup({ + ...setup, + directConfigPlan: undefined, + requiresDevelopmentKey: false, + }), + ).rejects.toThrow("no longer proves its Clerk runtime prerequisites"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "ContentView.swift")).text()).not.toContain( + "AuthView()", + ); + }); + + test("creates an iOS-only entitlements file in the aggregate SDK and Swift transaction", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app-id-prefix", "LEGACY1234"], + configDir, + ); + + expect(result.exitCode).toBe(0); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("Create MyApp/MyApp.entitlements with the linked development"); + expect(output).not.toContain(authFixtureKey); + const source = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(source).toContain("import ClerkKit"); + expect(source).toContain("Clerk.configure(publishableKey:"); + expect(source).toContain(".environment(Clerk.shared)"); + const entitlements = await Bun.file(join(root, "MyApp", "MyApp.entitlements")).text(); + expect(entitlements).toContain("webcredentials:ios-apply.clerk.example"); + expect(entitlements).not.toContain("application-identifier"); + expect(entitlements).not.toContain("com.apple.developer.applesignin"); + + const archive = parsePbxProject( + await Bun.file(join(root, "MyApp.xcodeproj", "project.pbxproj")).text(), + ) as unknown as { objects: PbxObjects }; + for (const id of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = archive.objects[id]!.buildSettings as Record; + expect(settings.CODE_SIGN_ENTITLEMENTS).toBeUndefined(); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]"]).toBe("MyApp/MyApp.entitlements"); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]"]).toBe( + "MyApp/MyApp.entitlements", + ); + expect(settings["CODE_SIGN_ENTITLEMENTS[sdk=macosx*]"]).toBe("MyApp/MyApp.mac.entitlements"); + } + + const digest = await treeDigest(root); + const second = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app-id-prefix", "LEGACY1234"], + configDir, + ); + expect(second.exitCode).toBe(0); + expect(`${second.stdout}\n${second.stderr}`).not.toContain(authFixtureKey); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("rolls back SDK, Swift, and a newly created entitlements file together", async () => { + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const before = await treeDigest(root); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + await expect( + applyIOSPlannedLocalSetup(setup, authFixtureKey, { + beforePostWriteValidation: () => { + throw new Error("injected aggregate validation failure"); + }, + }), + ).rejects.toThrow("restored byte-for-byte"); + + expect(await treeDigest(root)).toEqual(before); + expect(await Bun.file(join(root, "MyApp", "MyApp.entitlements")).exists()).toBe(false); + }); + + test("explicitly opts into native Apple without requesting hosted Apple credentials", async () => { + resetAppleConfiguration({ + enabled: false, + authenticatable: true, + client_id: "existing.web.service", + client_secret: "HOSTED_APPLE_SECRET_MUST_NOT_ESCAPE", + }); + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app-id-prefix", + "LEGACY1234", + "--sign-in-with-apple", + ], + configDir, + ); + + expect(result.exitCode).toBe(0); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("Native Sign in with Apple enabled in Clerk"); + expect(output).not.toContain(authFixtureKey); + expect(output).not.toContain("HOSTED_APPLE_SECRET_MUST_NOT_ESCAPE"); + const entitlements = await Bun.file(join(root, "MyApp", "MyApp.entitlements")).text(); + expect(entitlements).toContain("com.apple.developer.applesignin"); + expect(entitlements).toContain("Default"); + expect(currentAppleConnection()).toEqual({ + enabled: true, + authenticatable: true, + bundle_id: "com.example.MyApp", + client_id: "existing.web.service", + client_secret: "HOSTED_APPLE_SECRET_MUST_NOT_ESCAPE", + }); + + const digest = await treeDigest(root); + const second = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--sign-in-with-apple"], + configDir, + ); + expect(second.exitCode).toBe(0); + expect(`${second.stdout}\n${second.stderr}`).not.toContain(authFixtureKey); + expect(await treeDigest(root)).toEqual(digest); + }); + + test("does not treat an existing Apple entitlement plus --yes as Clerk Apple opt-in", async () => { + resetAppleConfiguration({ enabled: false, authenticatable: true }); + const root = await createUnconfiguredFixture(); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + + const optedIn = await runCLI( + root, + [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app-id-prefix", + "LEGACY1234", + "--sign-in-with-apple", + ], + configDir, + ); + expect(optedIn.exitCode).toBe(0); + expect(currentAppleConnection()).toMatchObject({ enabled: true, authenticatable: true }); + + // Keep the local entitlement as detection evidence while simulating a + // Clerk connection that has not been opted into for this invocation. + resetAppleConfiguration({ enabled: false, authenticatable: true }); + const withoutOptIn = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(withoutOptIn.exitCode).toBe(0); + expect(currentAppleConnection()).toEqual({ enabled: false, authenticatable: true }); + expect(`${withoutOptIn.stdout}\n${withoutOptIn.stderr}`).not.toContain( + "Native Sign in with Apple enabled in Clerk", + ); + }); + + test("links ClerkKit and ClerkKitUI to a clean target and is byte-idempotent", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "ClerkKit and ClerkKitUI linked to MyApp", + ); + expect(`${result.stdout}\n${result.stderr}`).not.toContain(authFixtureKey); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + const target = inspection.appTargets.find((candidate) => candidate.name === "MyApp"); + expect(target?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + expect(inspection.projects[0]?.packages[0]).toMatchObject({ + kind: "remote", + repository: "https://github.com/clerk/clerk-ios", + requirement: { kind: "upToNextMajorVersion", minimumVersion: "1.0.0" }, + isClerk: true, + }); + const source = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(source).toContain("import ClerkKit"); + expect(source.match(/Clerk\.configure\(publishableKey:/g)).toHaveLength(1); + expect(source).toContain(".environment(Clerk.shared)"); + const entitlements = await Bun.file(join(root, "MyApp", "MyApp.entitlements")).text(); + expect(entitlements).toContain("webcredentials:ios-apply.clerk.example"); + expect(`${result.stdout}\n${result.stderr}`).toContain("Clerk Associated Domain added"); + expect(await Bun.file(join(root, ".env")).exists()).toBe(false); + expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).exists()).toBe(false); + + const afterFirstRun = await treeDigest(root); + const second = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + expect(second.exitCode).toBe(0); + expect(`${second.stdout}\n${second.stderr}`).not.toContain(authFixtureKey); + expect(await treeDigest(root)).toEqual(afterFirstRun); + }); + + test("adds ClerkKitUI to a source-blank target left core-only by an earlier setup", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-core-only-migration-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + clerkSDK: "core-only", + complete: false, + includeKey: false, + }); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain( + "ClerkKit and ClerkKitUI linked to MyApp", + ); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "linked", + }); + }); + + test("preserves ClerkKit-only installation for an existing custom-flow source", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(0); + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toEqual({ + package: "remote", + clerkKit: "linked", + clerkKitUI: "absent", + }); + }); + + test("does not choose products when Swift source membership is incomplete", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectFile).text(); + const danglingBuildFile = "FEFEFEFEFEFEFEFEFEFEFEFE"; + await Bun.write( + projectFile, + project.replace( + `files = ( ${IOS_FIXTURE_IDS.sourceBuildFile}, );`, + `files = ( ${IOS_FIXTURE_IDS.sourceBuildFile}, ${danglingBuildFile}, );`, + ), + ); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("could not be inspected completely"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("links both products only to a fresh explicitly selected second target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-second-target-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false, secondTarget: true }); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "AdminApp", "--app-id-prefix", "ADMIN12345"], + configDir, + ); + + expect(result.exitCode).toBe(0); + const inspection = await inspectIOSProject(root); + const primary = inspection.appTargets.find((target) => target.name === "MyApp"); + const selected = inspection.appTargets.find((target) => target.name === "AdminApp"); + expect(primary?.packages).toMatchObject({ clerkKit: "absent", clerkKitUI: "absent" }); + expect(selected?.packages).toMatchObject({ clerkKit: "linked", clerkKitUI: "linked" }); + }); + + test("validates an apparently linked graph before treating it as a no-op", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-wrong-package-")); + temporaryDirectories.push(root); + await createIOSFixture(root); + const configDir = await createIsolatedCLIState(); + const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const wrongPackageId = "919191919191919191919191"; + const malformed = (await Bun.file(projectFile).text()) + .replace( + ` ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ` ${wrongPackageId} = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://example.com/not-clerk.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; };\n ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference;`, + ) + .replaceAll(`package = ${IOS_FIXTURE_IDS.clerkPackage};`, `package = ${wrongPackageId};`); + await Bun.write(projectFile, malformed); + + const result = await runCLI( + root, + ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + configDir, + ); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("verified clerk-ios reference"); + expect(await Bun.file(projectFile).text()).toBe(malformed); + }); + + test("agent mode requires explicit --yes before changing the Xcode project", async () => { + const root = await createUnconfiguredFixture(); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI(root, ["--mode", "agent", "init", "--target", "MyApp"], configDir); + + expect(result.exitCode).toBe(2); + expect(`${result.stdout}\n${result.stderr}`).toContain("requires explicit consent"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("accepts a clean project when Git canonicalizes an aliased project path", async () => { + const root = await mkdtemp(join("/tmp", "clerk-ios-apply-alias-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + await runCommand(root, ["git", "init"]); + await runCommand(root, ["git", "config", "user.name", "Clerk CLI Test"]); + await runCommand(root, ["git", "config", "user.email", "cli-test@clerk.test"]); + await runCommand(root, ["git", "add", "."]); + await runCommand(root, ["git", "commit", "-m", "fixture"]); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + + const inspection = await inspectIOSProject(root, { target: "MyApp" }); + expect(inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "linked", + clerkKitUI: "linked", + }); + }); + + test("an already-linked generated project is not source-edited", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-generated-satisfied-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { generated: "xcodegen" }); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI(root, ["--mode", "agent", "init", "--target", "MyApp"], configDir); + + expect(result.exitCode).toBe(1); + expect(`${result.stdout}\n${result.stderr}`).toContain("XcodeGen project"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("an already-linked SDK returns a read-only runtime verification without prompting or writing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-verification-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + await Bun.write( + entitlementsPath, + (await Bun.file(entitlementsPath).text()).replace( + "webcredentials:clerk.example.test", + "webcredentials:native.clerk.example", + ), + ); + const before = await treeDigest(root); + const confirmation = spyOn(prompts, "confirm").mockResolvedValue(false); + + try { + const result = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: false, + agent: true, + allowDirty: false, + }); + + expect(result.runtimeKeyVerificationPlan).toMatchObject({ + status: "ready", + localSecretsPath: "MyApp/LocalSecrets.plist", + }); + expect(confirmation).not.toHaveBeenCalled(); + expect(await treeDigest(root)).toEqual(before); + } finally { + confirmation.mockRestore(); + } + }); + + test("pre-authorizes a proven runtime sink without fetching or writing its key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-preflight-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + const before = await treeDigest(root); + + const result = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + expect(result.runtimeKeyPlan).toMatchObject({ + status: "ready", + localSecretsPath: "MyApp/LocalSecrets.plist", + }); + expect(await treeDigest(root)).toEqual(before); + expect(JSON.stringify(result)).not.toContain("pk_live_"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts new file mode 100644 index 000000000..58ab61570 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -0,0 +1,1551 @@ +import { lstat, realpath } from "node:fs/promises"; +import { basename, dirname, relative, resolve, sep } from "node:path"; +import { dim, yellow } from "../../../lib/color.ts"; +import { + CliError, + ERROR_CODE, + type ErrorCode, + throwUsageError, + throwUserAbort, +} from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { confirm } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { + planIOSSDKInstall, + prepareIOSSDKInstallMutation, + validateIOSSDKInstallPostcondition, + type IOSSDKInstallPlan, + type PreparedIOSSDKInstallMutation, +} from "./install-sdk.ts"; +import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "./plan.ts"; +import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./products.ts"; +import { + planIOSDirectConfig, + prepareIOSDirectConfigMutation, + validatePreparedIOSDirectConfig, + type IOSDirectConfigPlan, + type IOSDirectConfigPreparedMutation, +} from "./direct-config.ts"; +import { + applyIOSExistingFileTransaction, + applyIOSFileTransaction, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + applyIOSRuntimeKey, + planIOSRuntimeKey, + planIOSRuntimeKeyVerification, + verifyIOSRuntimeKey, + type IOSRuntimeKeyPlan, + type IOSRuntimeKeyVerificationPlan, +} from "./runtime-key.ts"; +import { + planIOSAssociatedDomain, + prepareIOSAssociatedDomainMutation, + validatePreparedIOSAssociatedDomain, + type IOSAssociatedDomainPlan, + type PreparedIOSAssociatedDomainMutation, +} from "./associated-domain.ts"; +import { + planIOSAppleEntitlement, + prepareIOSAppleEntitlementMutation, + validatePreparedIOSAppleEntitlement, + type IOSAppleEntitlementPlan, + type PreparedIOSAppleEntitlementMutation, +} from "./apple-entitlement.ts"; +import { + buildIOSNativeReadinessAudit, + suggestAppIdPrefixFromDevelopmentTeam, + type IOSNativeReadinessAudit, + type IOSUnverifiedAppIdPrefixSuggestion, +} from "./native-readiness.ts"; +import { + planIOSPrebuiltAuth, + prepareIOSPrebuiltAuthMutation, + validatePreparedIOSPrebuiltAuth, + type IOSPrebuiltAuthPlan, + type PreparedIOSPrebuiltAuthMutation, +} from "./prebuilt-auth.ts"; + +function iosSetupError(message: string, code: ErrorCode = ERROR_CODE.IOS_SETUP_BLOCKED): CliError { + return new CliError(message, { code }); +} + +export interface ApplyIOSLocalSetupOptions { + root: string; + target?: string; + yes: boolean; + agent: boolean; + allowDirty: boolean; + /** Explicit native Apple opt-in. Undefined allows a human prompt. */ + signInWithApple?: boolean; + /** Explicit prebuilt AuthView opt-in. Undefined allows a default-off human prompt. */ + prebuiltAuthUI?: boolean; +} + +/** Read-only SDK compatibility planner shared by the AuthView dry-run path. */ +export async function planIOSPrebuiltAuthSDKCompatibility(options: { + root: string; + projectPath: string; + targetId: string; +}): Promise { + return planIOSSDKInstall({ + ...options, + includeClerkKitUI: true, + requirePrebuiltAuthCompatibility: true, + }); +} + +export interface IOSLocalSetupResult { + targetName: string; + /** Redacted local identity used to audit the linked instance after authentication. */ + nativeReadiness: IOSNativeReadinessAudit; + /** Human-only Xcode signing-team suggestion; never treated as proven prefix evidence. */ + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; + sdkInstallPlan?: IOSSDKInstallPlan; + /** Fresh/default direct Swift configuration or existing inline verification. */ + directConfigPlan?: IOSDirectConfigPlan; + /** Pre-authorized, redacted plan whose key is resolved only after app linking. */ + runtimeKeyPlan?: IOSRuntimeKeyPlan; + /** Read-only proof for comparing an already configured sink after app linking. */ + runtimeKeyVerificationPlan?: IOSRuntimeKeyVerificationPlan; + /** Existing entitlements files that can receive the exact linked webcredentials host. */ + associatedDomainPlan?: IOSAssociatedDomainPlan; + /** Selected-target Sign in with Apple entitlement setup or verification. */ + appleEntitlementPlan?: IOSAppleEntitlementPlan; + /** Optional prebuilt AuthView source setup or exact generated-flow verification. */ + prebuiltAuthPlan?: IOSPrebuiltAuthPlan; + /** + * Pre-authorized local Apple capability candidate for the selected AuthView flow. + * It is applied only when a later environment audit proves Apple is enabled. + */ + prebuiltAuthAppleEntitlementPlan?: IOSAppleEntitlementPlan; + /** Explicit flag or AuthView-specific human confirmation; never inferred from --yes. */ + prebuiltAuthRequested: boolean; + /** Explicitly selected or byte-identical generated AuthView flow present on a rerun. */ + prebuiltAuthActive: boolean; + /** Explicit flag or Apple-specific human confirmation; never inferred from --yes. */ + nativeAppleRequested: boolean; + /** Authentication must return an exact app ID and development key before commit. */ + requiresLinkedApp: boolean; + /** The approved local transaction consumes the linked development publishable key. */ + requiresDevelopmentKey: boolean; + /** An existing runtime value must not be paired with an auto-created agent app. */ + verifiesExistingKey: boolean; +} + +/** @internal Test-only hook used to prove aggregate post-write rollback. */ +export interface ApplyIOSPlannedLocalSetupOptions { + beforePostWriteValidation?: () => void | Promise; +} + +type GitPathState = "clean" | "dirty" | "not-repository" | "unknown"; +const GIT_PATH_STATE_TIMEOUT_MS = 5_000; + +async function hasGitMarkerInAncestors(start: string): Promise { + let directory = resolve(start); + while (true) { + try { + await lstat(resolve(directory, ".git")); + return true; + } catch { + // Keep walking until the filesystem root. + } + const parent = dirname(directory); + if (parent === directory) return false; + directory = parent; + } +} + +async function gitPathState(absolutePath: string): Promise { + const projectDirectory = dirname(absolutePath); + try { + const repository = Bun.spawn(["git", "rev-parse", "--show-toplevel"], { + cwd: projectDirectory, + stdout: "pipe", + stderr: "ignore", + timeout: GIT_PATH_STATE_TIMEOUT_MS, + killSignal: "SIGKILL", + }); + const repositoryRoot = (await new Response(repository.stdout).text()).trim(); + const repositoryExitCode = await repository.exited; + if (repository.signalCode != null) return "unknown"; + if (repositoryExitCode !== 0) { + return (await hasGitMarkerInAncestors(projectDirectory)) ? "unknown" : "not-repository"; + } + if (repositoryRoot === "") return "unknown"; + + const canonicalRepositoryRoot = await realpath(repositoryRoot); + let canonicalAbsolutePath: string; + try { + canonicalAbsolutePath = await realpath(absolutePath); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) { + return "unknown"; + } + canonicalAbsolutePath = resolve( + await realpath(dirname(absolutePath)), + basename(absolutePath), + ); + } + const path = relative(canonicalRepositoryRoot, canonicalAbsolutePath); + if (path === "" || path === ".." || path.startsWith(`..${sep}`)) return "unknown"; + + const status = Bun.spawn( + ["git", "status", "--porcelain=v1", "--untracked-files=all", "--", path], + { + cwd: canonicalRepositoryRoot, + stdout: "pipe", + stderr: "ignore", + timeout: GIT_PATH_STATE_TIMEOUT_MS, + killSignal: "SIGKILL", + }, + ); + const output = await new Response(status.stdout).text(); + const statusExitCode = await status.exited; + if (status.signalCode != null || statusExitCode !== 0) return "unknown"; + return output.trim() === "" ? "clean" : "dirty"; + } catch { + return "unknown"; + } +} + +function formatProducts(products: string[]): string { + if (products.length === 1) return products[0]!; + return `${products.slice(0, -1).join(", ")} and ${products.at(-1)}`; +} + +function directConfigNeedsWrite(plan: IOSDirectConfigPlan | undefined): boolean { + const changes = plan?.changes; + return ( + plan?.status === "ready" && + changes != null && + (changes.clerkKitImport === "insert" || + changes.configuration !== "verify-existing" || + changes.environment === "insert") + ); +} + +function associatedDomainNeedsWrite( + plan: IOSAssociatedDomainPlan | undefined, +): plan is IOSAssociatedDomainPlan { + return plan?.status === "ready"; +} + +function blockerList(blockers: Array<{ message: string }>): string { + return blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); +} + +export function planIOSPrebuiltAuthRuntimeBlockers( + inspection: Awaited>, + directConfigPlan: IOSDirectConfigPlan | undefined, + runtimeKeyPlan: IOSRuntimeKeyPlan | undefined, +): string[] { + const setupPlan = buildIOSSetupPlan(inspection, { directConfigPlan, runtimeKeyPlan }); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); + const directConfigurationReady = + directConfigPlan?.status === "ready" && configureStep?.automatable === true; + const runtimeKeyConfigurationReady = + runtimeKeyPlan?.status === "ready" && configureStep?.automatable === true; + const directEnvironmentReady = + directConfigPlan?.status === "ready" && + (directConfigPlan.changes?.environment === "insert" || + directConfigPlan.changes?.environment === "satisfied"); + const blockers: string[] = []; + + if ( + configureStep?.status !== "satisfied" && + !directConfigurationReady && + !runtimeKeyConfigurationReady + ) { + blockers.push( + "Clerk.configure(publishableKey:) is neither proven at runtime nor included in the safe direct-configuration plan.", + ); + } + if (environmentStep?.status !== "satisfied" && !directEnvironmentReady) { + blockers.push( + "Clerk.shared is not proven in the shipping SwiftUI root environment, and the existing runtime abstraction cannot be rewritten safely.", + ); + } + + return blockers; +} + +async function validatePrebuiltAuthRuntimePostcondition( + setup: IOSLocalSetupResult, + allowPendingRuntimeKey: boolean, +): Promise { + if (!setup.prebuiltAuthActive) return true; + if (setup.nativeReadiness.target.status !== "selected") return false; + const target = setup.nativeReadiness.target; + const inspection = await inspectIOSProject(setup.nativeReadiness.root, { + target: target.targetId, + }); + const setupPlan = buildIOSSetupPlan(inspection, { + runtimeKeyPlan: allowPendingRuntimeKey ? setup.runtimeKeyPlan : undefined, + }); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); + const configurationReady = + configureStep?.status === "satisfied" || + (allowPendingRuntimeKey && + setup.runtimeKeyPlan?.status === "ready" && + configureStep?.automatable === true); + + return configurationReady && environmentStep?.status === "satisfied"; +} + +/** + * Inspects, previews, and authorizes the local iOS setup without writing it. + * The returned redacted plans are prepared again and committed only after an + * exact Clerk application and development publishable key have been resolved. + */ +export async function applyIOSLocalSetup( + options: ApplyIOSLocalSetupOptions, +): Promise { + const inspection = await withSpinner("Inspecting Xcode project...", async () => + inspectIOSProject(options.root, { target: options.target }), + ); + const selection = inspection.selection; + if (selection.state !== "selected") { + if (selection.state === "ambiguous") { + const candidates = selection.candidates + .map( + (candidate) => + `${candidate.targetName} (${candidate.targetId}, ${candidate.projectPath})`, + ) + .join(", "); + throwUsageError( + `More than one iOS application target is eligible: ${candidates}. Rerun with --target ; if IDs collide across copied projects, run the command from the intended project's directory.`, + ); + } + if (selection.state === "not-found") { + throwUsageError( + `The iOS target "${selection.requested}" was not found. Available targets: ${selection.candidates.join(", ") || "none"}.`, + ); + } + throw iosSetupError( + "No usable iOS application target was found.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + + const selectedTarget = inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); + if (!selectedTarget) { + throw iosSetupError( + "The selected iOS target could not be resolved safely.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + const unverifiedAppIdPrefixSuggestion = suggestAppIdPrefixFromDevelopmentTeam(selectedTarget); + const productDecision = clerkKitUIInstallDecision(selectedTarget); + if (productDecision === "unknown") { + throw iosSetupError( + "The selected target's Swift source membership could not be inspected completely, so Clerk cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the Xcode source-membership diagnostics, then rerun clerk init.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + const inspectedPrebuiltAuthPlan = await planIOSPrebuiltAuth({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowDirty: options.allowDirty, + }); + let prebuiltAuthRequested = options.prebuiltAuthUI === true; + if ( + !prebuiltAuthRequested && + options.prebuiltAuthUI == null && + inspectedPrebuiltAuthPlan.status === "ready" && + !options.agent && + !options.yes + ) { + prebuiltAuthRequested = await confirm({ + message: `Add ClerkKitUI's prebuilt authentication UI to ${selection.targetName}?`, + default: false, + }); + } + if (prebuiltAuthRequested && inspectedPrebuiltAuthPlan.status === "blocked") { + throw iosSetupError( + `The prebuilt AuthView flow could not be added safely. No local files were changed:\n${blockerList(inspectedPrebuiltAuthPlan.blockers)}`, + ); + } + const prebuiltAuthActive = + prebuiltAuthRequested || inspectedPrebuiltAuthPlan.status === "satisfied"; + const prebuiltAuthPlan = prebuiltAuthActive ? inspectedPrebuiltAuthPlan : undefined; + + // A source-proven custom flow remains core-only by default, but an explicit + // or interactive AuthView selection must link the product that generated + // source imports before the aggregate transaction is authorized. + const includeClerkKitUI = productDecision === "prebuilt" || prebuiltAuthActive; + + const installPlan = await planIOSSDKInstall({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + includeClerkKitUI, + requirePrebuiltAuthCompatibility: prebuiltAuthActive, + }); + + const configureStep = buildIOSSetupPlan(inspection).steps.find( + (candidate) => candidate.id === "configure-publishable-key", + ); + const needsRuntimeKeyHandoff = + configureStep?.status === "required" && + hasIOSRuntimeKeyHandoffShape(inspection, selectedTarget); + const plannedRuntimeKey = needsRuntimeKeyHandoff + ? await planIOSRuntimeKey({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const runtimeKeyPlan = plannedRuntimeKey?.status === "ready" ? plannedRuntimeKey : undefined; + const hasSatisfiedLocalRuntimeSink = + configureStep?.status === "satisfied" && + inspection.localPublishableKey.source != null && + selectedTarget.runtimeKeySinks.some( + (sink) => sink.path === inspection.localPublishableKey.source, + ); + const plannedRuntimeKeyVerification = hasSatisfiedLocalRuntimeSink + ? await planIOSRuntimeKeyVerification({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const runtimeKeyVerificationPlan = + plannedRuntimeKeyVerification?.status === "ready" ? plannedRuntimeKeyVerification : undefined; + const hasLocalSecretsConfigure = selectedTarget.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "local-secrets-loader", + ); + const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => + source.endsWith(".xcscheme"), + ); + const shouldPlanDirectConfig = shouldPlanIOSDirectConfig( + inspection, + selectedTarget, + prebuiltAuthActive ? "prebuilt" : productDecision, + ); + const directConfigPlan = shouldPlanDirectConfig + ? await planIOSDirectConfig({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowDirty: options.allowDirty, + }) + : undefined; + if ( + directConfigNeedsWrite(directConfigPlan) && + prebuiltAuthPlan?.status === "ready" && + directConfigPlan?.sourcePath === prebuiltAuthPlan.sourcePath + ) { + throw iosSetupError( + "The approved iOS setup resolved the Clerk initializer and prebuilt AuthView scaffold to the same Swift source unexpectedly. No local files were changed; review the app root and rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const plannedAssociatedDomain = await planIOSAssociatedDomain({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + deferToPublishableKey: + directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + // A LocalSecrets write is a specialized secret transaction that cannot + // yet share rollback ownership with a newly created entitlements file. + allowMissingEntitlementsCreation: runtimeKeyPlan == null, + }); + // Associated Domains is an independent additive improvement. Unsupported + // or ambiguous entitlements must not prevent the already-proven SDK/source + // setup; those cases remain an actionable manual step in the final plan. + const associatedDomainPlan = + plannedAssociatedDomain.status === "blocked" ? undefined : plannedAssociatedDomain; + const nativeReadiness = buildIOSNativeReadinessAudit(inspection, { + associatedDomainPlan: plannedAssociatedDomain, + }); + if ( + nativeReadiness.target.status !== "selected" || + nativeReadiness.target.bundleIdentifier.status !== "resolved" + ) { + throw iosSetupError( + "The selected iOS target does not have one proven Bundle ID across all build configurations. No local files were changed; resolve PRODUCT_BUNDLE_IDENTIFIER, then rerun clerk init.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + const hasLocalAppleEntitlement = selectedTarget.configurations.some( + (configuration) => configuration.entitlements?.signInWithApple === true, + ); + let nativeAppleRequested = options.signInWithApple === true; + if (!nativeAppleRequested && options.signInWithApple == null && !options.agent && !options.yes) { + nativeAppleRequested = await confirm({ + message: `Enable native Sign in with Apple for ${nativeReadiness.target.bundleIdentifier.value}?`, + default: false, + }); + } + const inspectedAppleEntitlementPlan = + nativeAppleRequested || hasLocalAppleEntitlement || prebuiltAuthActive + ? await planIOSAppleEntitlement({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + // New entitlements creation cannot be rolled back through the + // specialized LocalSecrets transaction. + allowMissingEntitlementsCreation: runtimeKeyPlan == null, + }) + : undefined; + // Existing entitlement evidence remains available for a read-only satisfied + // verification, but incomplete local Apple setup is never completed unless + // this invocation explicitly opted into the strategy. + const appleEntitlementPlan = nativeAppleRequested + ? inspectedAppleEntitlementPlan + : inspectedAppleEntitlementPlan?.status === "satisfied" + ? inspectedAppleEntitlementPlan + : undefined; + const prebuiltAuthAppleEntitlementPlan = prebuiltAuthActive + ? inspectedAppleEntitlementPlan + : undefined; + if (appleEntitlementPlan?.status === "blocked") { + throw iosSetupError( + `Native Sign in with Apple could not be configured safely. No local files were changed:\n${blockerList(appleEntitlementPlan.blockers)}`, + ); + } + const reviewOnlyUnattributedInstall = + !prebuiltAuthActive && + installPlan.requirePrebuiltAuthCompatibility !== true && + installPlan.status === "blocked" && + installPlan.blockers.length > 0 && + installPlan.blockers.every((blocker) => blocker.code === "unattributed-product") && + installPlan.products.every((product) => + product === "ClerkKit" + ? selectedTarget.packages.clerkKit === "linked" + : selectedTarget.packages.clerkKitUI === "linked", + ); + const sdkInstallPlan = reviewOnlyUnattributedInstall ? undefined : installPlan; + + if (plannedRuntimeKeyVerification?.status === "blocked") { + throw iosSetupError( + `The existing iOS runtime publishable key could not be verified safely. No local files were changed:\n${blockerList(plannedRuntimeKeyVerification.blockers)}`, + ); + } + if (plannedRuntimeKey?.status === "blocked") { + throw iosSetupError( + `The development publishable key could not be wired safely. No local files were changed:\n${blockerList(plannedRuntimeKey.blockers)}`, + ); + } + if (directConfigPlan?.status === "blocked") { + throw iosSetupError( + `The selected SwiftUI app could not be configured automatically. No local files were changed:\n${blockerList(directConfigPlan.blockers)}`, + ); + } + if ( + (productDecision === "prebuilt" || prebuiltAuthActive) && + selectedTarget.swift.configureCalls.length === 0 && + !directConfigPlan && + !runtimeKeyPlan + ) { + const reason = hasEnabledSchemeKey + ? "an enabled Run-scheme publishable key already indicates a custom runtime configuration" + : selectedTarget.runtimeKeySinks.length > 0 + ? "a target-owned LocalSecrets.plist exists without a proven loader" + : "the selected runtime configuration could not be proven"; + throw iosSetupError( + `The fresh SwiftUI target was not edited because ${reason}. Resolve that setup or configure Clerk directly in the @main initializer, then rerun clerk init. No local files were changed.`, + ); + } + if (hasLocalSecretsConfigure && !runtimeKeyPlan && !runtimeKeyVerificationPlan) { + throw iosSetupError( + "An existing LocalSecrets-based Clerk configuration was found, but its selected-target runtime sink could not be proven. No local files were changed; repair or confirm that compatibility path manually.", + ); + } + if (prebuiltAuthActive) { + const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers( + inspection, + directConfigPlan, + runtimeKeyPlan, + ); + if (runtimeBlockers.length > 0) { + throw iosSetupError( + `The prebuilt AuthView flow requires a proven Clerk runtime and SwiftUI environment before its source can be added. No local files were changed:\n${runtimeBlockers + .map((message) => ` • ${message}`) + .join("\n")}`, + ); + } + } + + if (installPlan.status === "satisfied") { + const verb = installPlan.products.length === 1 ? "is" : "are"; + log.info( + dim( + `\n${formatProducts(installPlan.products)} ${verb} already linked to ${selection.targetName}.`, + ), + ); + } + if (reviewOnlyUnattributedInstall) { + const verb = installPlan.products.length === 1 ? "is" : "are"; + log.info( + dim( + `\n${formatProducts(installPlan.products)} ${verb} already linked to ${selection.targetName}, but package attribution is not represented in this project graph. The existing Xcode package graph will be left unchanged.`, + ), + ); + } else if (installPlan.status === "blocked") { + throw iosSetupError( + `The Clerk iOS SDK could not be installed automatically:\n${blockerList(installPlan.blockers)}`, + ); + } + const plannedPaths: Array<{ absolutePath: string; displayPath: string }> = []; + if (installPlan.status === "ready") { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + if (runtimeKeyPlan?.localSecretsPath) { + plannedPaths.push({ + absolutePath: resolve(options.root, runtimeKeyPlan.localSecretsPath), + displayPath: runtimeKeyPlan.localSecretsPath, + }); + } + const changesGitignore = runtimeKeyPlan?.changesGitignore === true; + if (changesGitignore && runtimeKeyPlan?.gitignorePath) { + plannedPaths.push({ + absolutePath: resolve(options.root, runtimeKeyPlan.gitignorePath), + displayPath: runtimeKeyPlan.gitignorePath, + }); + } + if (directConfigNeedsWrite(directConfigPlan) && directConfigPlan?.sourcePath) { + plannedPaths.push({ + absolutePath: resolve(options.root, directConfigPlan.sourcePath), + displayPath: directConfigPlan.sourcePath, + }); + } + if (prebuiltAuthPlan?.status === "ready" && prebuiltAuthPlan.sourcePath) { + plannedPaths.push({ + absolutePath: resolve(options.root, prebuiltAuthPlan.sourcePath), + displayPath: prebuiltAuthPlan.sourcePath, + }); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of associatedDomainPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if (appleEntitlementPlan?.status === "ready") { + if (appleEntitlementPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of appleEntitlementPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if ( + prebuiltAuthAppleEntitlementPlan?.status === "ready" && + prebuiltAuthAppleEntitlementPlan !== appleEntitlementPlan + ) { + if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { + plannedPaths.push({ + absolutePath: resolve(options.root, selection.projectPath, "project.pbxproj"), + displayPath: `${selection.projectPath}/project.pbxproj`, + }); + } + for (const file of prebuiltAuthAppleEntitlementPlan.files) { + plannedPaths.push({ + absolutePath: resolve(options.root, file.path), + displayPath: file.path, + }); + } + } + if (!options.allowDirty) { + const uniquePaths = [ + ...new Map(plannedPaths.map((path) => [path.absolutePath, path])).values(), + ]; + for (const path of uniquePaths) { + const state = await gitPathState(path.absolutePath); + if (state === "dirty") { + throw iosSetupError( + `${path.displayPath} already has local changes. Commit or stash them, or rerun with --allow-dirty to preserve and build on those exact bytes.`, + ERROR_CODE.IOS_WORKTREE_UNSAFE, + ); + } + if (state === "unknown") { + throw iosSetupError( + `Git could not verify whether ${path.displayPath} has local changes. Resolve the Git error, or rerun with --allow-dirty to build on the current exact bytes.`, + ERROR_CODE.IOS_WORKTREE_UNSAFE, + ); + } + } + } + + const hasLocalWrites = + installPlan.status === "ready" || + runtimeKeyPlan != null || + directConfigNeedsWrite(directConfigPlan) || + prebuiltAuthPlan?.status === "ready" || + associatedDomainNeedsWrite(associatedDomainPlan) || + appleEntitlementPlan?.status === "ready" || + prebuiltAuthAppleEntitlementPlan?.status === "ready"; + if (hasLocalWrites) { + log.info("\nclerk init will make the following local iOS changes:\n"); + } else if ( + directConfigPlan || + runtimeKeyVerificationPlan || + appleEntitlementPlan || + prebuiltAuthPlan + ) { + log.info("\nclerk init will perform the following read-only iOS verification:\n"); + } + if (installPlan.status === "ready") { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + for (const action of installPlan.actions) log.info(` ${action}`); + } + if (runtimeKeyPlan) { + if (changesGitignore && runtimeKeyPlan.gitignorePath) { + const operation = runtimeKeyPlan.expectedGitignoreHash == null ? "CREATE" : "MODIFY"; + log.info(` ${yellow(operation)} ${runtimeKeyPlan.gitignorePath}`); + } + log.info(` ${yellow("MODIFY")} ${runtimeKeyPlan.localSecretsPath}`); + for (const action of runtimeKeyPlan.actions) log.info(` ${action}`); + log.info( + dim( + " The linked development publishable key will be fetched after authentication and will never be printed.", + ), + ); + } + if (directConfigPlan) { + const operation = directConfigNeedsWrite(directConfigPlan) ? "MODIFY" : "VERIFY"; + log.info(` ${yellow(operation)} ${directConfigPlan.sourcePath}`); + for (const action of directConfigPlan.actions) log.info(` ${action}`); + log.info( + dim( + " The linked development publishable key will remain in memory and is redacted from the preview and command output.", + ), + ); + } + if (prebuiltAuthPlan) { + const operation = prebuiltAuthPlan.status === "ready" ? "MODIFY" : "VERIFY"; + log.info(` ${yellow(operation)} ${prebuiltAuthPlan.sourcePath}`); + for (const action of prebuiltAuthPlan.actions) log.info(` ${action}`); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings && installPlan.status !== "ready") { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + } + for (const file of associatedDomainPlan.files) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + for (const action of associatedDomainPlan.actions) log.info(` ${action}`); + if (associatedDomainPlan.requiresPublishableKey) { + log.info( + dim( + " The exact linked development host will be resolved after authentication and is redacted from this preview.", + ), + ); + } + } + if (appleEntitlementPlan?.status === "ready") { + const alreadyPreviewedEntitlements = new Set( + associatedDomainNeedsWrite(associatedDomainPlan) + ? associatedDomainPlan.files.map((file) => file.path) + : [], + ); + if ( + appleEntitlementPlan.missingEntitlementsSettings && + installPlan.status !== "ready" && + !associatedDomainPlan?.missingEntitlementsSettings + ) { + log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); + } + for (const file of appleEntitlementPlan.files) { + if (!alreadyPreviewedEntitlements.has(file.path)) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + } + for (const action of appleEntitlementPlan.actions) log.info(` ${action}`); + } else if (appleEntitlementPlan?.status === "satisfied") { + log.info(dim("\n The selected target already has the native Sign in with Apple entitlement.")); + } + if ( + prebuiltAuthAppleEntitlementPlan?.status === "ready" && + prebuiltAuthAppleEntitlementPlan !== appleEntitlementPlan + ) { + log.info( + dim( + "\n Conditional AuthView capability change (only if Apple is enabled for the linked instance):", + ), + ); + const alreadyPreviewedPaths = new Set(); + if (installPlan.status === "ready") { + alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + } + if (associatedDomainNeedsWrite(associatedDomainPlan)) { + if (associatedDomainPlan.missingEntitlementsSettings) { + alreadyPreviewedPaths.add(`${selection.projectPath}/project.pbxproj`); + } + for (const file of associatedDomainPlan.files) alreadyPreviewedPaths.add(file.path); + } + if (prebuiltAuthAppleEntitlementPlan.missingEntitlementsSettings) { + const projectFile = `${selection.projectPath}/project.pbxproj`; + if (!alreadyPreviewedPaths.has(projectFile)) { + log.info(` ${yellow("MODIFY")} ${projectFile}`); + } + } + for (const file of prebuiltAuthAppleEntitlementPlan.files) { + if (!alreadyPreviewedPaths.has(file.path)) { + log.info(` ${yellow(file.operation === "create" ? "CREATE" : "MODIFY")} ${file.path}`); + } + } + for (const action of prebuiltAuthAppleEntitlementPlan.actions) { + log.info(` If Apple is enabled: ${action}`); + } + } + if (prebuiltAuthActive) { + log.info( + dim( + "\n After authentication, clerk init will inspect the methods available to AuthView. If Apple is enabled for this instance, it will add or verify the required local Sign in with Apple entitlement without enabling or changing the Clerk Apple connection.", + ), + ); + } + if (installPlan.status === "ready") { + log.info(dim("\n Package resolution and xcodebuild will not run.")); + } + log.info( + dim( + nativeAppleRequested + ? "\n After authentication, clerk init will inspect Native API, iOS registration, and the native Apple connection before separately previewing additive remote changes." + : "\n After authentication, clerk init will inspect Native API and iOS registration state and separately preview any additive remote changes.", + ), + ); + log.blank(); + + if (hasLocalWrites && options.agent && !options.yes) { + throwUsageError( + "Changing an Xcode project in agent mode requires explicit consent. Review `clerk init --dry-run`, then rerun `clerk init --yes`.", + ); + } + if (hasLocalWrites && !options.yes) { + const proceed = await confirm({ message: "Apply these local iOS changes?", default: false }); + if (!proceed) throwUserAbort(); + } + + return { + targetName: selection.targetName, + nativeReadiness, + ...(unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion } : {}), + sdkInstallPlan, + directConfigPlan, + runtimeKeyPlan, + runtimeKeyVerificationPlan, + associatedDomainPlan, + appleEntitlementPlan, + prebuiltAuthPlan, + prebuiltAuthAppleEntitlementPlan, + prebuiltAuthRequested, + prebuiltAuthActive, + nativeAppleRequested, + requiresLinkedApp: true, + requiresDevelopmentKey: + directConfigPlan != null || + runtimeKeyPlan != null || + runtimeKeyVerificationPlan != null || + associatedDomainPlan?.requiresPublishableKey === true, + verifiesExistingKey: + directConfigPlan?.changes?.configuration === "verify-existing" || + runtimeKeyVerificationPlan != null, + }; +} + +function directFileMutation( + prepared: Extract, +): IOSExistingFileMutation { + return { + path: prepared.mutation.absolutePath, + originalBytes: prepared.mutation.originalBytes, + originalHash: prepared.mutation.expectedHash, + candidateBytes: prepared.mutation.candidateBytes, + candidateHash: prepared.mutation.candidateHash, + mode: prepared.mutation.mode, + }; +} + +function prebuiltAuthFileMutation( + prepared: Extract, +): IOSExistingFileMutation { + return { + path: prepared.mutation.absolutePath, + originalBytes: prepared.mutation.originalBytes, + originalHash: prepared.mutation.expectedHash, + candidateBytes: prepared.mutation.candidateBytes, + candidateHash: prepared.mutation.candidateHash, + mode: prepared.mutation.mode, + }; +} + +function reverseFileMutation(mutation: IOSExistingFileMutation): IOSExistingFileMutation { + return { + path: mutation.path, + originalBytes: mutation.candidateBytes, + originalHash: mutation.candidateHash, + candidateBytes: mutation.originalBytes, + candidateHash: mutation.originalHash, + mode: mutation.mode, + }; +} + +function preparedSDKBlockers(prepared: PreparedIOSSDKInstallMutation): string { + return prepared.status === "blocked" ? blockerList(prepared.plan.blockers) : ""; +} + +async function prepareSDKForCommit( + plan: IOSSDKInstallPlan | undefined, +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSSDKInstallMutation(plan); + if (prepared.status === "stale") { + throw iosSetupError( + "The Xcode project changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + throw iosSetupError( + `The Clerk iOS SDK could no longer be prepared safely. No local setup changes were written:\n${preparedSDKBlockers(prepared)}`, + ); + } + return prepared; +} + +async function preparePrebuiltAuthForCommit( + plan: IOSPrebuiltAuthPlan | undefined, +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + if (prepared.status === "stale") { + throw iosSetupError( + "The Swift authentication view changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + throw iosSetupError( + `The prebuilt AuthView flow could no longer be prepared safely. No local setup changes were written:\n${blockerList(prepared.plan.blockers)}`, + ); + } + return prepared; +} + +async function prepareAssociatedDomainForCommit( + plan: IOSAssociatedDomainPlan | undefined, + publishableKey: string | undefined, + basePbxMutation?: IOSExistingFileMutation, +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSAssociatedDomainMutation(plan, publishableKey, { + basePbxMutation, + }); + if (prepared.status === "stale") { + throw iosSetupError( + "An entitlements file changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + const reasons = blockerList(prepared.plan.blockers); + throw iosSetupError( + `The Clerk Associated Domain could no longer be prepared safely. No local setup changes were written${reasons ? `:\n${reasons}` : "."}`, + ); + } + return prepared; +} + +async function prepareAppleEntitlementForCommit( + plan: IOSAppleEntitlementPlan | undefined, + baseMutations: readonly IOSFileMutation[], +): Promise { + if (!plan) return undefined; + const prepared = await prepareIOSAppleEntitlementMutation(plan, { baseMutations }); + if (prepared.status === "stale") { + throw iosSetupError( + "An iOS entitlements file changed after the Sign in with Apple preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (prepared.status === "blocked") { + throw iosSetupError( + `The Sign in with Apple entitlement could no longer be prepared safely. No local setup changes were written:\n${blockerList(prepared.plan.blockers)}`, + ); + } + return prepared; +} + +function composeAppleMutations( + baseMutations: readonly IOSFileMutation[], + prepared: PreparedIOSAppleEntitlementMutation | undefined, +): IOSFileMutation[] { + if (prepared?.status !== "ready") return [...baseMutations]; + const consumed = new Set(prepared.consumedBaseMutationPaths); + return [ + ...baseMutations.filter((mutation) => !consumed.has(resolve(mutation.path))), + ...prepared.mutations, + ]; +} + +function existingMutationsOnly(mutations: readonly IOSFileMutation[]): IOSExistingFileMutation[] { + if (mutations.some((mutation) => "kind" in mutation && mutation.kind === "create")) { + throw iosSetupError( + "The approved iOS setup attempted to combine incompatible runtime and file-creation transactions. No additional local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + return mutations as IOSExistingFileMutation[]; +} + +function assertUniqueMutationPaths(mutations: readonly IOSFileMutation[]): void { + const paths = mutations.map((mutation) => resolve(mutation.path)); + if (new Set(paths).size !== paths.length) { + throw iosSetupError( + "The approved iOS setup produced overlapping file mutations. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } +} + +async function validateSatisfiedAssociatedDomain(plan: IOSAssociatedDomainPlan): Promise { + const current = await planIOSAssociatedDomain({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + return ( + current.status === "satisfied" && + (plan.expectedDomain == null || current.expectedDomain === plan.expectedDomain) + ); +} + +async function validateSatisfiedAppleEntitlement(plan: IOSAppleEntitlementPlan): Promise { + const current = await planIOSAppleEntitlement({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + }); + return current.status === "satisfied"; +} + +async function validateSatisfiedPrebuiltAuth(plan: IOSPrebuiltAuthPlan): Promise { + const current = await planIOSPrebuiltAuth({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: true, + }); + return current.status === "satisfied" && current.sourcePath === plan.sourcePath; +} + +async function rollbackPreparedLocalMutations( + mutations: readonly IOSExistingFileMutation[], +): Promise { + if (mutations.length === 0) return; + const result = await applyIOSExistingFileTransaction( + [...mutations].reverse().map(reverseFileMutation), + [], + ); + if (result.status !== "applied") { + throw iosSetupError( + "The publishable-key update failed, and a concurrent local edit prevented the approved iOS setup from being restored completely. Inspect the previewed project and entitlements files before retrying.", + ERROR_CODE.IOS_LOCAL_ROLLBACK_FAILED, + ); + } +} + +function requireDevelopmentKey( + setup: IOSLocalSetupResult, + publishableKey: string | undefined, +): string { + const planNeedsKey = Boolean( + setup.directConfigPlan || + setup.runtimeKeyPlan || + setup.runtimeKeyVerificationPlan || + setup.associatedDomainPlan?.requiresPublishableKey, + ); + if (planNeedsKey !== setup.requiresDevelopmentKey) { + throw iosSetupError( + "The approved iOS setup plan is internally inconsistent. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (!planNeedsKey) return ""; + if (!publishableKey) { + throw iosSetupError( + "The linked Clerk application's development publishable key was not available. No local setup changes were written.", + ERROR_CODE.IOS_PUBLISHABLE_KEY_UNAVAILABLE, + ); + } + return publishableKey; +} + +function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { + if (setup.prebuiltAuthRequested && !setup.prebuiltAuthPlan) { + throw iosSetupError( + "The approved iOS setup selected prebuilt authentication without a validated source plan. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const expectedPrebuiltAuthActive = + setup.prebuiltAuthRequested || setup.prebuiltAuthPlan?.status === "satisfied"; + if (setup.prebuiltAuthActive !== expectedPrebuiltAuthActive) { + throw iosSetupError( + "The approved iOS setup contains inconsistent prebuilt authentication state. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if (setup.prebuiltAuthAppleEntitlementPlan && !setup.prebuiltAuthActive) { + throw iosSetupError( + "The approved iOS setup contains an unselected AuthView capability plan. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if ( + setup.directConfigPlan?.sourcePath && + setup.prebuiltAuthPlan?.status === "ready" && + setup.directConfigPlan.sourcePath === setup.prebuiltAuthPlan.sourcePath + ) { + throw iosSetupError( + "The approved iOS setup contains overlapping Swift source mutations. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + if ( + setup.runtimeKeyPlan && + (setup.associatedDomainPlan?.missingEntitlementsSettings || + setup.appleEntitlementPlan?.missingEntitlementsSettings) + ) { + throw iosSetupError( + "The approved iOS setup cannot combine a LocalSecrets write with new entitlements-file creation. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const runtimePlans = [ + setup.directConfigPlan, + setup.runtimeKeyPlan, + setup.runtimeKeyVerificationPlan, + ].filter((plan) => plan != null); + if (runtimePlans.length > 1) { + throw iosSetupError( + "The approved iOS setup contains conflicting runtime configuration routes. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const plans: Array<{ root: string; projectPath: string; targetId: string }> = [ + setup.sdkInstallPlan, + ...runtimePlans, + ].filter((plan) => plan != null); + if (setup.prebuiltAuthPlan) plans.push(setup.prebuiltAuthPlan); + if (setup.associatedDomainPlan) plans.push(setup.associatedDomainPlan); + if (setup.appleEntitlementPlan) plans.push(setup.appleEntitlementPlan); + if (setup.prebuiltAuthAppleEntitlementPlan) { + plans.push(setup.prebuiltAuthAppleEntitlementPlan); + } + if (setup.nativeReadiness.target.status !== "selected") { + throw iosSetupError( + "The approved iOS setup no longer identifies one selected native target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + plans.push({ + root: setup.nativeReadiness.root, + projectPath: setup.nativeReadiness.target.projectPath, + targetId: setup.nativeReadiness.target.targetId, + }); + const selection = plans[0]; + if ( + selection && + plans.some( + (plan) => + plan.root !== selection.root || + plan.projectPath !== selection.projectPath || + plan.targetId !== selection.targetId, + ) + ) { + throw iosSetupError( + "The approved iOS setup no longer identifies one consistent Xcode target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } +} + +/** + * Commits a previously previewed iOS setup after authentication. Fresh direct + * configuration combines project.pbxproj and the Swift entry source in one + * guarded local transaction. Existing LocalSecrets integrations retain their + * specialized compatibility transaction. + */ +export async function applyIOSPlannedLocalSetup( + setup: IOSLocalSetupResult, + publishableKey?: string, + options: ApplyIOSPlannedLocalSetupOptions = {}, +): Promise { + assertCoherentLocalSetup(setup); + if (setup.prebuiltAuthActive) { + if (setup.nativeReadiness.target.status !== "selected") { + throw iosSetupError( + "The approved prebuilt AuthView setup no longer identifies one selected iOS target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const inspection = await inspectIOSProject(setup.nativeReadiness.root, { + target: setup.nativeReadiness.target.targetId, + }); + const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers( + inspection, + setup.directConfigPlan, + setup.runtimeKeyPlan, + ); + if (runtimeBlockers.length > 0) { + throw iosSetupError( + `The approved prebuilt AuthView setup no longer proves its Clerk runtime prerequisites. No local setup changes were written:\n${runtimeBlockers + .map((message) => ` • ${message}`) + .join("\n")}`, + ERROR_CODE.IOS_SETUP_STALE, + ); + } + } + const key = requireDevelopmentKey(setup, publishableKey); + + // Existing LocalSecrets values are verified before any PBX mutation. A + // mismatched application can therefore never change the selected target. + if (setup.runtimeKeyVerificationPlan) { + const result = await withSpinner("Verifying the existing iOS publishable key...", async () => + verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan!, key), + ); + assertRuntimeKeyVerificationMatched(result); + } + + const preparedSDK = await prepareSDKForCommit(setup.sdkInstallPlan); + const preparedPrebuiltAuth = await preparePrebuiltAuthForCommit(setup.prebuiltAuthPlan); + + if (setup.directConfigPlan) { + const preparedDirect = await prepareIOSDirectConfigMutation(setup.directConfigPlan, key); + if (preparedDirect.status === "stale") { + throw iosSetupError( + "The Swift app entry source changed after the preview. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (preparedDirect.status === "blocked") { + throw iosSetupError( + `The Swift app entry source could no longer be configured safely. No local setup changes were written:\n${blockerList(preparedDirect.plan.blockers)}`, + ); + } + // Verify an existing inline key before using the supplied key to derive + // its entitlements candidate. A mismatch must retain the dedicated + // wrong-application error and leave every file untouched. + const preparedAssociatedDomain = await prepareAssociatedDomainForCommit( + setup.associatedDomainPlan, + key || undefined, + preparedSDK?.status === "ready" ? preparedSDK.mutation : undefined, + ); + + const baseMutations: IOSFileMutation[] = []; + const postconditions: Array<() => boolean | Promise> = []; + if (options.beforePostWriteValidation) { + postconditions.push(async () => { + await options.beforePostWriteValidation?.(); + return true; + }); + } + if ( + preparedSDK?.status === "ready" && + !( + preparedAssociatedDomain?.status === "ready" && + preparedAssociatedDomain.consumesBasePbxMutation + ) + ) { + baseMutations.push(preparedSDK.mutation); + } + if (preparedSDK) { + postconditions.push(async () => validateIOSSDKInstallPostcondition(preparedSDK.plan)); + } + if (preparedAssociatedDomain?.status === "ready") { + baseMutations.push(...preparedAssociatedDomain.mutations); + postconditions.push(async () => + validatePreparedIOSAssociatedDomain(preparedAssociatedDomain), + ); + } else if (preparedAssociatedDomain?.status === "satisfied") { + postconditions.push(async () => + validateSatisfiedAssociatedDomain(preparedAssociatedDomain.plan), + ); + } + const preparedAppleEntitlement = await prepareAppleEntitlementForCommit( + setup.appleEntitlementPlan, + baseMutations, + ); + const mutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); + if (preparedAppleEntitlement?.status === "ready") { + postconditions.push(async () => + validatePreparedIOSAppleEntitlement(preparedAppleEntitlement), + ); + } else if (preparedAppleEntitlement?.status === "satisfied") { + postconditions.push(async () => + validateSatisfiedAppleEntitlement(preparedAppleEntitlement.plan), + ); + } + // Commit the entitlements file and its Xcode settings before Swift starts + // depending on the configured SDK. A process interruption can then leave + // only harmless project prerequisites, never source that imports an + // unlinked package. + if (preparedDirect.status === "ready") { + mutations.push(directFileMutation(preparedDirect)); + postconditions.push(async () => validatePreparedIOSDirectConfig(preparedDirect)); + } else { + postconditions.push(async () => { + const verified = await prepareIOSDirectConfigMutation(setup.directConfigPlan!, key); + return verified.status === "satisfied"; + }); + } + if (preparedPrebuiltAuth?.status === "ready") { + mutations.push(prebuiltAuthFileMutation(preparedPrebuiltAuth)); + postconditions.push(async () => validatePreparedIOSPrebuiltAuth(preparedPrebuiltAuth)); + } else if (preparedPrebuiltAuth?.status === "satisfied") { + postconditions.push(async () => validateSatisfiedPrebuiltAuth(preparedPrebuiltAuth.plan)); + } + if (setup.prebuiltAuthActive) { + postconditions.push(async () => validatePrebuiltAuthRuntimePostcondition(setup, false)); + } + assertUniqueMutationPaths(mutations); + + if (mutations.length > 0) { + const result = await withSpinner("Applying the local iOS setup...", async () => + applyIOSFileTransaction(mutations, postconditions), + ); + if (result.status === "stale") { + throw iosSetupError( + "An iOS setup file changed while the approved changes were being committed. Any partial write was restored; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (result.status === "rolled-back") { + throw iosSetupError( + "The local iOS setup failed post-write validation and was restored byte-for-byte.", + ERROR_CODE.IOS_LOCAL_APPLY_FAILED, + ); + } + } + + if (preparedSDK?.status === "ready") { + log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); + } + if (preparedDirect.status === "ready") { + log.success(`Clerk configured in ${preparedDirect.plan.sourcePath}`); + } else { + log.info(dim("The existing inline publishable key matches the linked Clerk application.")); + } + if (preparedPrebuiltAuth?.status === "ready") { + log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); + } + if (!setup.runtimeKeyPlan && preparedAssociatedDomain?.status === "ready") { + log.success("Clerk Associated Domain added to the selected target entitlements"); + } + if (preparedAppleEntitlement?.status === "ready") { + log.success("Sign in with Apple entitlement added to the selected target"); + } + return; + } + + const preparedAssociatedDomain = await prepareAssociatedDomainForCommit( + setup.associatedDomainPlan, + key || undefined, + preparedSDK?.status === "ready" ? preparedSDK.mutation : undefined, + ); + const baseMutations: IOSFileMutation[] = [ + ...(preparedAssociatedDomain?.status === "ready" ? preparedAssociatedDomain.mutations : []), + ...(preparedSDK?.status === "ready" && + !( + preparedAssociatedDomain?.status === "ready" && + preparedAssociatedDomain.consumesBasePbxMutation + ) + ? [preparedSDK.mutation] + : []), + ...(preparedPrebuiltAuth?.status === "ready" + ? [prebuiltAuthFileMutation(preparedPrebuiltAuth)] + : []), + ]; + const preparedAppleEntitlement = await prepareAppleEntitlementForCommit( + setup.appleEntitlementPlan, + baseMutations, + ); + const localMutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); + assertUniqueMutationPaths(localMutations); + + // SDK-only and LocalSecrets compatibility routes apply the PBX candidate + // after authentication. If the specialized key transaction subsequently + // fails, restore the PBX bytes when they are still untouched. + if (localMutations.length > 0) { + const postconditions: Array<() => boolean | Promise> = [ + ...(preparedSDK ? [async () => validateIOSSDKInstallPostcondition(preparedSDK.plan)] : []), + ...(preparedAssociatedDomain?.status === "ready" + ? [async () => validatePreparedIOSAssociatedDomain(preparedAssociatedDomain)] + : preparedAssociatedDomain?.status === "satisfied" + ? [async () => validateSatisfiedAssociatedDomain(preparedAssociatedDomain.plan)] + : []), + ...(preparedAppleEntitlement?.status === "ready" + ? [async () => validatePreparedIOSAppleEntitlement(preparedAppleEntitlement)] + : preparedAppleEntitlement?.status === "satisfied" + ? [async () => validateSatisfiedAppleEntitlement(preparedAppleEntitlement.plan)] + : []), + ...(preparedPrebuiltAuth?.status === "ready" + ? [async () => validatePreparedIOSPrebuiltAuth(preparedPrebuiltAuth)] + : preparedPrebuiltAuth?.status === "satisfied" + ? [async () => validateSatisfiedPrebuiltAuth(preparedPrebuiltAuth.plan)] + : []), + ...(setup.prebuiltAuthActive + ? [ + async () => + validatePrebuiltAuthRuntimePostcondition(setup, setup.runtimeKeyPlan != null), + ] + : []), + ]; + if (setup.runtimeKeyVerificationPlan) { + postconditions.push(async () => { + await options.beforePostWriteValidation?.(); + return ( + (await verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan!, key)).status === "matched" + ); + }); + } + const result = await withSpinner("Applying the local iOS setup...", async () => + applyIOSFileTransaction(localMutations, postconditions), + ); + if (result.status === "stale") { + throw iosSetupError( + "The Xcode project changed after the preview. No SDK change was written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (result.status === "rolled-back") { + throw iosSetupError( + "The local iOS setup changed during post-write validation. The Clerk iOS SDK change was restored byte-for-byte; rerun clerk init.", + ERROR_CODE.IOS_LOCAL_APPLY_FAILED, + ); + } + if (!setup.runtimeKeyPlan && preparedSDK?.status === "ready") { + log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); + } + if (!setup.runtimeKeyPlan && preparedAssociatedDomain?.status === "ready") { + log.success("Clerk Associated Domain added to the selected target entitlements"); + } + if (!setup.runtimeKeyPlan && preparedAppleEntitlement?.status === "ready") { + log.success("Sign in with Apple entitlement added to the selected target"); + } + if (!setup.runtimeKeyPlan && preparedPrebuiltAuth?.status === "ready") { + log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); + } + } + + if (setup.runtimeKeyPlan) { + try { + await applyIOSRuntimeKeySetup(setup.runtimeKeyPlan, key); + } catch (error) { + await rollbackPreparedLocalMutations(existingMutationsOnly(localMutations)); + throw error; + } + if (preparedSDK?.status === "ready") { + log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); + } + if (preparedAssociatedDomain?.status === "ready") { + log.success("Clerk Associated Domain added to the selected target entitlements"); + } + if (preparedAppleEntitlement?.status === "ready") { + log.success("Sign in with Apple entitlement added to the selected target"); + } + if (preparedPrebuiltAuth?.status === "ready") { + log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); + } + } else if (setup.runtimeKeyVerificationPlan) { + log.info(dim("The existing publishable key matches the linked Clerk application.")); + } +} + +export async function applyIOSRuntimeKeySetup( + plan: IOSRuntimeKeyPlan, + publishableKey: string, +): Promise { + const result = await withSpinner("Wiring the development publishable key...", async () => + applyIOSRuntimeKey(plan, publishableKey), + ); + if (result.status === "applied") { + log.success(`Publishable key wired to ${plan.localSecretsPath}`); + return; + } + if (result.status === "satisfied") { + log.info(dim(`The linked publishable key is already wired to ${plan.localSecretsPath}.`)); + return; + } + if (result.status === "stale") { + throw iosSetupError( + "LocalSecrets.plist or .gitignore changed after the preview. Nothing new was written; rerun clerk init to build a fresh plan.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (result.status === "rolled-back") { + throw iosSetupError( + result.message ?? "The runtime-key update failed validation and was restored.", + ERROR_CODE.IOS_LOCAL_APPLY_FAILED, + ); + } + const reasons = result.plan.blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); + throw iosSetupError( + result.message ?? `The development publishable key could not be wired safely:\n${reasons}`, + ERROR_CODE.IOS_LOCAL_APPLY_FAILED, + ); +} + +export async function verifyIOSRuntimeKeySetup( + plan: IOSRuntimeKeyVerificationPlan, + linkedPublishableKey: string, +): Promise { + const result = await withSpinner("Verifying the existing iOS publishable key...", async () => + verifyIOSRuntimeKey(plan, linkedPublishableKey), + ); + assertRuntimeKeyVerificationMatched(result); + log.info(dim("The existing publishable key matches the linked Clerk application.")); +} + +function assertRuntimeKeyVerificationMatched( + result: Awaited>, +): void { + if (result.status === "matched") return; + if (result.status === "mismatched") { + throw iosSetupError( + "The existing iOS runtime publishable key does not match the linked Clerk application's development key. No key was changed; link the matching application or clear the existing runtime key intentionally before rerunning clerk init.", + ERROR_CODE.IOS_PUBLISHABLE_KEY_MISMATCH, + ); + } + if (result.status === "stale") { + throw iosSetupError( + "LocalSecrets.plist changed after the read-only verification preflight. No key was changed; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + const reasons = result.plan.blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); + throw iosSetupError( + `The existing iOS runtime publishable key could not be verified safely. No key was changed:\n${reasons}`, + ); +} 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 0609f0864..66aa12e9e 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 @@ -4,7 +4,8 @@ import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { inspectTargetBuildConfigurations } from "./build-settings.ts"; import type { PbxObject, PbxObjects } from "./pbx.ts"; -import type { IOSDiagnostic } from "./types.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import type { IOSDiagnostic, IOSProjectInspectionResult } from "./types.ts"; const temporaryDirectories: string[] = []; @@ -695,7 +696,7 @@ describe("inspectTargetBuildConfigurations", () => { }); test("preserves dangling target configurations as blocking placeholders", async () => { - const { configurations, diagnostics } = await inspectFixture({ + const { configurations, diagnostics, root } = await inspectFixture({ targetConfigurationIds: ["target-debug", "missing-target-release"], }); @@ -711,6 +712,55 @@ describe("inspectTargetBuildConfigurations", () => { message: expect.stringContaining("missing-target-release"), }), ); + + const inspection: IOSProjectInspectionResult = { + schemaVersion: 1, + platform: "ios", + root, + workspaces: [], + projects: [], + appTargets: [ + { + id: "target", + name: "Example", + projectPath: "Example.xcodeproj", + configurations: configurations.map(({ model }) => model), + packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, + runtimeKeySinks: [], + swift: { + sourceFilesScanned: 0, + evidenceComplete: true, + entryPoints: [], + importsClerkKit: [], + importsClerkKitUI: [], + configureCalls: [], + localSecretsRuntimeBindings: [], + environmentInjections: [], + environmentConsumers: [], + authFlowReferences: [], + openURLHandlers: [], + status: "absent", + }, + }, + ], + selection: { + state: "selected", + targetId: "target", + targetName: "Example", + projectPath: "Example.xcodeproj", + }, + localPublishableKey: { + found: false, + conflict: false, + candidateSources: [], + invalidSources: [], + }, + generatedProject: null, + diagnostics, + }; + expect( + buildIOSSetupPlan(inspection).steps.find(({ id }) => id === "register-native-application"), + ).toMatchObject({ status: "blocked" }); }); test("taints target settings when the project configuration list is incomplete", async () => { diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts new file mode 100644 index 000000000..467c276c8 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -0,0 +1,559 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { cp, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { + convertIOSFixtureToSynchronizedMissingEntitlements, + createIOSFixture, + IOS_FIXTURE_IDS, + treeDigest, +} from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; +const cliPath = resolve(import.meta.dir, "../../../cli.ts"); +const canonicalSwiftUIFixture = resolve(import.meta.dir, "../../../../../../test/e2e/fixtures/ios"); + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +function isolatedCLIEnvironment( + configDir: string, + overrides: Record = {}, +): Record { + const env: Record = { ...Bun.env }; + + // The subprocess must not inherit credentials, mode, telemetry opt-outs, or + // a user's real Clerk config. The fixture's .env remains inspector input, + // but it is never copied into this explicit process environment. + for (const key of Object.keys(env)) { + if (key.includes("CLERK")) delete env[key]; + } + delete env.CI; + delete env.DO_NOT_TRACK; + delete env.NO_UPDATE_NOTIFIER; + + return { + ...env, + NO_COLOR: "1", + CLERK_CONFIG_DIR: configDir, + CLERK_TELEMETRY_DISABLED: "1", + ...overrides, + }; +} + +async function createIsolatedCLIState(): Promise { + const configDir = await mkdtemp(join(tmpdir(), "clerk-ios-cli-config-")); + temporaryDirectories.push(configDir); + await Bun.write( + join(configDir, "config.json"), + JSON.stringify({ + profiles: {}, + telemetryNoticeShown: true, + machineUuid: "00000000-0000-4000-8000-000000000000", + }) + "\n", + ); + return configDir; +} + +async function runCLI(root: string, args: string[], env: Record) { + const child = Bun.spawn([process.execPath, cliPath, ...args], { + cwd: root, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exitCode }; +} + +describe("clerk init --dry-run", () => { + test("non-TTY mode emits JSON without network requests or local/global writes", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + let requestCount = 0; + const requestTrap = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestCount += 1; + return Response.json({ ok: true }); + }, + }); + + try { + const result = await runCLI( + root, + ["init", "--dry-run"], + isolatedCLIEnvironment(configDir, { + // Dev builds normally suppress telemetry. Pointing it at the trap + // makes a leaked global telemetry hook observable. + CLERK_TELEMETRY_URL: requestTrap.url.href, + CLERK_TELEMETRY_DISABLED: undefined, + }), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output).toMatchObject({ + schemaVersion: 1, + mode: "read-only", + status: "ready", + inspection: { platform: "ios", selection: { state: "selected", targetName: "MyApp" } }, + plan: { kind: "clerk-ios-setup", status: "ready" }, + nativeReadiness: { + kind: "clerk-ios-native-readiness", + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + }, + }, + }); + expect(result.stdout).not.toContain("CLERK_PUBLISHABLE_KEY="); + expect(requestCount).toBe(0); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + } finally { + await requestTrap.stop(true); + } + }); + + test("explicit JSON output stays free of human-mode UI", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + mode: "read-only", + inspection: { selection: { state: "selected", targetName: "MyApp" } }, + }); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + + test("fresh SwiftUI output advertises direct configuration and environment automation", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-direct-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const configure = output.plan.steps.find( + (step: { id: string }) => step.id === "configure-publishable-key", + ); + const environment = output.plan.steps.find( + (step: { id: string }) => step.id === "inject-clerk-environment", + ); + const associatedDomain = output.plan.steps.find( + (step: { id: string }) => step.id === "add-associated-domain", + ); + expect(configure).toMatchObject({ status: "required", automatable: true }); + expect(environment).toMatchObject({ status: "required", automatable: true }); + expect(associatedDomain).toMatchObject({ status: "required", automatable: true }); + expect(output.nativeReadiness.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp.entitlements"], + }); + expect(configure.description).toContain("directly"); + expect(result.stdout).not.toContain("LocalSecrets"); + expect(result.stdout).not.toContain("pk_test_"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("explicit AuthView dry-run includes safe direct setup for an import-only core target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-auth-direct-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--prebuilt-auth-ui"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const configure = output.plan.steps.find( + (step: { id: string }) => step.id === "configure-publishable-key", + ); + const environment = output.plan.steps.find( + (step: { id: string }) => step.id === "inject-clerk-environment", + ); + const auth = output.plan.steps.find( + (step: { id: string }) => step.id === "add-authentication-flow", + ); + expect(configure).toMatchObject({ status: "required", automatable: true }); + expect(environment).toMatchObject({ status: "required", automatable: true }); + expect(auth).toMatchObject({ status: "required", automatable: true }); + expect(configure.description).toContain("directly"); + expect(environment.description).toContain(".environment(Clerk.shared)"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("explicit AuthView dry-run blocks a ProcessInfo runtime without root environment injection", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-auth-process-info-")); + temporaryDirectories.push(root); + await cp(canonicalSwiftUIFixture, root, { recursive: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure( + publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "" + ) + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + const publishableKey = `pk_test_${Buffer.from("dry-run-process-info.clerk.example$").toString("base64")}`; + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + await Bun.write( + join(schemeDirectory, "MyApp.xcscheme"), + ``, + ); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--prebuilt-auth-ui"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const configure = output.plan.steps.find( + (step: { id: string }) => step.id === "configure-publishable-key", + ); + const environment = output.plan.steps.find( + (step: { id: string }) => step.id === "inject-clerk-environment", + ); + const auth = output.plan.steps.find( + (step: { id: string }) => step.id === "add-authentication-flow", + ); + expect(configure).toMatchObject({ status: "satisfied" }); + expect(environment).toMatchObject({ status: "required", automatable: false }); + expect(auth).toMatchObject({ status: "blocked", automatable: false }); + expect(auth.description).toContain( + "Clerk.shared is not proven in the shipping SwiftUI root environment", + ); + expect(result.stdout).not.toContain(publishableKey); + expect(await treeDigest(root)).toEqual(before); + }); + + test("explicit Apple opt-in previews only the local entitlement during a network-free dry-run", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-apple-dry-run-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + let requestCount = 0; + const requestTrap = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestCount += 1; + return Response.json({ ok: true }); + }, + }); + + try { + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--sign-in-with-apple"], + isolatedCLIEnvironment(configDir, { CLERK_PLATFORM_API_URL: requestTrap.url.origin }), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const apple = output.plan.steps.find( + (step: { id: string }) => step.id === "enable-native-apple", + ); + expect(apple).toMatchObject({ status: "required", automatable: true }); + expect(apple.description).toContain("native Sign in with Apple entitlement"); + expect(result.stdout).not.toContain("Services ID"); + expect(result.stdout).not.toContain("private key"); + expect(requestCount).toBe(0); + expect(await treeDigest(root)).toEqual(before); + } finally { + await requestTrap.stop(true); + } + }); + + test("explicit prebuilt AuthView dry-run refuses to overwrite a partial existing flow without network access", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-prebuilt-auth-dry-run-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false }); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + let requestCount = 0; + const requestTrap = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestCount += 1; + return Response.json({ ok: true }); + }, + }); + + try { + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--prebuilt-auth-ui"], + isolatedCLIEnvironment(configDir, { CLERK_PLATFORM_API_URL: requestTrap.url.origin }), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const auth = output.plan.steps.find( + (step: { id: string }) => step.id === "add-authentication-flow", + ); + expect(auth).toMatchObject({ status: "blocked", automatable: false }); + expect(auth.description).toContain("not safe to rewrite automatically"); + expect(auth.description).toContain("network-free local plan"); + expect(JSON.stringify(output)).not.toContain("connection_oauth_apple"); + expect(requestCount).toBe(0); + expect(await treeDigest(root)).toEqual(before); + } finally { + await requestTrap.stop(true); + } + }); + + test("explicit prebuilt AuthView dry-run blocks a target below iOS 17 without network access", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-prebuilt-auth-ios16-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + await Bun.write( + projectPath, + (await Bun.file(projectPath).text()).replaceAll( + "IPHONEOS_DEPLOYMENT_TARGET = 17.0", + "IPHONEOS_DEPLOYMENT_TARGET = 16.4", + ), + ); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + let requestCount = 0; + const requestTrap = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch() { + requestCount += 1; + return Response.json({ ok: true }); + }, + }); + + try { + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json", "--prebuilt-auth-ui"], + isolatedCLIEnvironment(configDir, { CLERK_PLATFORM_API_URL: requestTrap.url.origin }), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const auth = output.plan.steps.find( + (step: { id: string }) => step.id === "add-authentication-flow", + ); + expect(auth).toMatchObject({ status: "blocked", automatable: false }); + expect(auth.description).toContain("require iOS 17.0 or newer"); + expect(auth.description).toContain("IPHONEOS_DEPLOYMENT_TARGET"); + expect(requestCount).toBe(0); + expect(await treeDigest(root)).toEqual(before); + } finally { + await requestTrap.stop(true); + } + }); + + test("advertises missing-entitlements creation for a satisfied LocalSecrets integration", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-local-secrets-domain-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); + await convertIOSFixtureToSynchronizedMissingEntitlements(root); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "configure-publishable-key", + status: "satisfied", + }), + ); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "add-associated-domain", + status: "required", + automatable: true, + }), + ); + expect(output.nativeReadiness.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp.entitlements"], + blockers: [], + }); + expect(result.stdout).not.toContain("pk_live_"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not advertise runtime-key automation when the strict plist preflight blocks", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), ""); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const configure = output.plan.steps.find( + (step: { id: string }) => step.id === "configure-publishable-key", + ); + expect(configure).toMatchObject({ status: "blocked", automatable: false }); + expect(configure.description).toContain("readable XML property-list dictionary"); + expect(configure.description).not.toContain("clerk init can fetch"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("rejects remote-state flags before authentication or linking", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["init", "--dry-run", "--app", "app_never_contact"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("--dry-run cannot be combined"); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + + test("human output labels an ambiguous target as incomplete without implying failure", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { secondTarget: true }); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("Setup incomplete"); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("Plan blocked"); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + + test("human output distinguishes an actionable plan from a ready plan", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); + temporaryDirectories.push(root); + await createIOSFixture(root); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("Setup incomplete"); + expect(output).not.toContain("Setup looks ready"); + expect(output).toContain("not inspected during this local-only dry-run"); + expect(output).toContain("Regular"); + expect(output).toContain("audits and safely reconciles both"); + expect(output).not.toContain("does not expose these resources"); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); +}); 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 new file mode 100644 index 000000000..4d1fa891b --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-apple.test.ts @@ -0,0 +1,680 @@ +import { describe, expect, test } from "bun:test"; +import { ERROR_CODE, UserAbortError } from "../../../lib/errors.ts"; +import type { InstanceConfigSchema } from "../../../lib/plapi.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { + applyIOSNativeAppleConnection, + buildIOSNativeApplePlan, + prepareIOSNativeAppleConnection, + type IOSNativeAppleAPI, + type IOSNativeApplePatchOptions, + type IOSNativeApplePrompts, +} from "./native-apple.ts"; + +const APPLICATION_ID = "app_native_apple"; +const INSTANCE_ID = "ins_native_apple"; +const BUNDLE_IDENTIFIER = "com.example.NativeApple"; +const CONFIG_VERSION = "v1_1234abcd"; +const NEXT_CONFIG_VERSION = "v1_9876fedc"; +const SERVICES_ID = "com.example.web.sign-in"; +const TEAM_ID = "APPLE_TEAM_ID_MUST_NOT_ESCAPE"; +const KEY_ID = "APPLE_KEY_ID_MUST_NOT_ESCAPE"; +const PRIVATE_KEY = "APPLE_PRIVATE_KEY_MUST_NOT_ESCAPE"; +const API_SECRET = "Bearer ak_PLATFORM_TOKEN_MUST_NOT_ESCAPE"; + +const captured = useCaptureLog(); + +type AppleConnection = Record & { + enabled: boolean; + authenticatable: boolean; +}; + +function appleSchema(): InstanceConfigSchema { + return { + type: "object", + properties: { + connection_oauth_apple: { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + client_id: { type: "string" }, + client_secret: { type: "string", "x-clerk-sensitive": true }, + team_id: { type: "string" }, + key_id: { type: "string" }, + bundle_id: { type: "string" }, + }, + }, + }, + }; +} + +function connection( + enabled = false, + authenticatable = true, + extras: Record = {}, +): AppleConnection { + return { enabled, authenticatable, ...extras }; +} + +function config(value: AppleConnection, configVersion: string | undefined = CONFIG_VERSION) { + return { + ...(configVersion ? { config_version: configVersion } : {}), + connection_oauth_apple: { ...value }, + }; +} + +function baseOptions( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + requested: true, + agent: false, + yes: true, + ...overrides, + }; +} + +function unexpectedPrompts(overrides: Partial = {}): IOSNativeApplePrompts { + return { + enableNativeApple: + overrides.enableNativeApple ?? + (async () => { + throw new Error("unexpected Apple opt-in prompt"); + }), + confirmChanges: + overrides.confirmChanges ?? + (async () => { + throw new Error("unexpected Apple mutation prompt"); + }), + }; +} + +type PatchCall = { + config: Record; + options: IOSNativeApplePatchOptions; +}; + +function statefulAPI( + options: { + initial?: AppleConnection; + schema?: InstanceConfigSchema; + supportsIfMatch?: boolean; + version?: string | undefined; + failFetch?: unknown; + failDryRun?: unknown; + failActual?: unknown; + malformedDryRun?: boolean; + replaceProjection?: boolean; + persistActual?: boolean; + } = {}, +): { + api: IOSNativeAppleAPI; + calls: string[]; + patchCalls: PatchCall[]; + actualWrites(): number; + current(): AppleConnection; + setCurrent(value: AppleConnection): void; + setVersion(value: string | undefined): void; +} { + let current = { + ...(options.initial ?? connection()), + } as AppleConnection; + let version: string | undefined = + options.version === undefined ? CONFIG_VERSION : options.version; + let writes = 0; + const calls: string[] = []; + const patchCalls: PatchCall[] = []; + + const api: IOSNativeAppleAPI = { + supportsIfMatch: options.supportsIfMatch ?? false, + async fetchInstanceConfig(applicationId, instanceId, keys) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET config"); + if (options.failFetch) throw options.failFetch; + return config(current, version); + }, + async fetchInstanceConfigSchema(applicationId, instanceId, keys) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(keys).toEqual(["connection_oauth_apple"]); + calls.push("GET schema"); + if (options.failFetch) throw options.failFetch; + return options.schema ?? appleSchema(); + }, + async patchInstanceConfig(applicationId, instanceId, patch, patchOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push(patchOptions.dryRun ? "PATCH dry-run" : "PATCH apply"); + patchCalls.push({ + config: structuredClone(patch), + options: { ...patchOptions }, + }); + + if (patchOptions.ifMatch && patchOptions.ifMatch !== version) { + throw new Error("config version conflict"); + } + if (patchOptions.dryRun && options.failDryRun) throw options.failDryRun; + if (!patchOptions.dryRun && options.failActual) throw options.failActual; + + const update = patch.connection_oauth_apple; + if (typeof update !== "object" || update == null || Array.isArray(update)) { + throw new Error("invalid test patch"); + } + const before = { ...current }; + const after = ( + options.replaceProjection + ? { ...(update as Record) } + : { ...current, ...(update as Record) } + ) as AppleConnection; + if (patchOptions.dryRun && options.malformedDryRun) { + return { config_version: version, dry_run: true, before: {}, after: {} }; + } + if (!patchOptions.dryRun) { + writes += 1; + if (options.persistActual !== false) current = after; + version = NEXT_CONFIG_VERSION; + } + return { + config_version: patchOptions.dryRun ? version : NEXT_CONFIG_VERSION, + dry_run: patchOptions.dryRun, + before: { connection_oauth_apple: before }, + after: { connection_oauth_apple: after }, + }; + }, + }; + + return { + api, + calls, + patchCalls, + actualWrites: () => writes, + current: () => ({ ...current }), + setCurrent(value) { + current = { ...value }; + }, + setVersion(value) { + version = value; + }, + }; +} + +describe("native Sign in with Apple remote setup", () => { + test("builds a narrow redacted plan without retaining web credentials", () => { + const sensitiveConnection = connection(false, true, { + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + team_id: TEAM_ID, + key_id: KEY_ID, + }); + const plan = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: config(sensitiveConnection), + schema: appleSchema(), + }); + + expect(plan).toMatchObject({ + status: "ready", + connection: "required", + bundleIdentifierConfiguration: "required", + current: { enabled: false, authenticatable: true }, + desired: { enabled: true, authenticatable: true }, + configVersion: CONFIG_VERSION, + blockers: [], + }); + expect(plan.actions).toHaveLength(1); + const serialized = JSON.stringify(plan); + for (const sensitive of [SERVICES_ID, PRIVATE_KEY, TEAM_ID, KEY_ID]) { + expect(serialized).not.toContain(sensitive); + } + }); + + test("treats an existing enabled and authenticatable connection as a no-op", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(plan.status).toBe("satisfied"); + expect(harness.patchCalls).toHaveLength(0); + if (plan.status === "satisfied") { + await applyIOSNativeAppleConnection(plan, harness.api); + } + expect(harness.patchCalls).toHaveLength(0); + expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); + expect(harness.calls.filter((call) => call === "GET schema")).toHaveLength(2); + expect(captured.err).toContain("already enabled"); + }); + + test("rejects a satisfied plan when the connection changes after prepare", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (plan.status !== "satisfied") throw new Error("expected satisfied plan"); + + harness.setCurrent( + connection(false, true, { + bundle_id: BUNDLE_IDENTIFIER, + client_secret: PRIVATE_KEY, + }), + ); + + let thrown: unknown; + try { + await applyIOSNativeAppleConnection(plan, harness.api); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining("changed after the approved preview"), + }); + expect(String(thrown)).not.toContain(PRIVATE_KEY); + expect(captured.err).not.toContain(PRIVATE_KEY); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); + expect(harness.calls.filter((call) => call === "GET schema")).toHaveLength(2); + }); + + test("prepares before a planned native registration, then preserves web credentials on apply", async () => { + const initial = connection(false, false, { + client_id: SERVICES_ID, + client_secret: "REDACTED", + team_id: TEAM_ID, + key_id: KEY_ID, + unrelated_provider_setting: "keep-me", + }); + const harness = statefulAPI({ + initial, + supportsIfMatch: true, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + // The exact iOS registration may still be an approved prerequisite here. + // Server validation is intentionally deferred until apply, after the + // registration transaction has run. + expect(harness.patchCalls).toHaveLength(0); + + await applyIOSNativeAppleConnection(prepared, harness.api); + + expect(harness.actualWrites()).toBe(1); + expect(harness.patchCalls).toHaveLength(2); + for (const call of harness.patchCalls) { + expect(call.config).toEqual({ + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }); + expect(call.options.ifMatch).toBe(CONFIG_VERSION); + expect(JSON.stringify(call.config)).not.toContain(SERVICES_ID); + expect(JSON.stringify(call.config)).not.toContain(TEAM_ID); + expect(JSON.stringify(call.config)).not.toContain(KEY_ID); + } + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]); + expect(harness.current()).toEqual({ + ...initial, + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }); + }); + + test("uses config-version rereads when an injected transport cannot send If-Match", async () => { + const harness = statefulAPI({ supportsIfMatch: false }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(prepared.status).toBe("ready"); + expect(harness.patchCalls).toHaveLength(0); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await applyIOSNativeAppleConnection(prepared, harness.api); + + expect(harness.actualWrites()).toBe(1); + expect(harness.patchCalls).toHaveLength(2); + for (const call of harness.patchCalls) expect(call.options.ifMatch).toBeUndefined(); + + const staleHarness = statefulAPI({ supportsIfMatch: false }); + const stalePrepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: staleHarness.api, + prompts: unexpectedPrompts(), + }); + if (stalePrepared.status !== "ready") throw new Error("expected ready plan"); + staleHarness.setVersion(NEXT_CONFIG_VERSION); + + await expect(applyIOSNativeAppleConnection(stalePrepared, staleHarness.api)).rejects.toThrow( + "changed after the approved preview", + ); + expect(staleHarness.patchCalls).toHaveLength(0); + expect(staleHarness.actualWrites()).toBe(0); + }); + + test("requires the exact native Bundle ID even when Apple is already authenticatable", async () => { + const harness = statefulAPI({ initial: connection(true, true) }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(prepared).toMatchObject({ + status: "ready", + connection: "required", + bundleIdentifierConfiguration: "required", + }); + expect(harness.patchCalls).toHaveLength(0); + }); + + test("keeps global --yes from opting an agent into Apple", async () => { + const harness = statefulAPI(); + const prepared = await prepareIOSNativeAppleConnection( + baseOptions({ requested: undefined, agent: true, yes: true }), + { api: harness.api, prompts: unexpectedPrompts() }, + ); + + expect(prepared).toEqual({ + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "skipped", + reason: "not-requested", + }); + expect(harness.calls).toEqual([]); + }); + + test("lets a human decline the opt-in before any remote read", async () => { + const harness = statefulAPI(); + let optInCalls = 0; + const prepared = await prepareIOSNativeAppleConnection( + baseOptions({ requested: undefined, yes: true }), + { + api: harness.api, + prompts: unexpectedPrompts({ + enableNativeApple: async (bundleIdentifier) => { + optInCalls += 1; + expect(bundleIdentifier).toBe(BUNDLE_IDENTIFIER); + return false; + }, + }), + }, + ); + + expect(prepared.status).toBe("skipped"); + expect(optInCalls).toBe(1); + expect(harness.calls).toEqual([]); + }); + + test("requires separate human mutation consent without calling the mutation endpoint", async () => { + const harness = statefulAPI(); + let consentCalls = 0; + + await expect( + prepareIOSNativeAppleConnection(baseOptions({ yes: false }), { + api: harness.api, + prompts: unexpectedPrompts({ + confirmChanges: async () => { + consentCalls += 1; + return false; + }, + }), + }), + ).rejects.toBeInstanceOf(UserAbortError); + + expect(consentCalls).toBe(1); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("requires --yes for an explicitly requested agent mutation", async () => { + const harness = statefulAPI(); + + await expect( + prepareIOSNativeAppleConnection(baseOptions({ agent: true, yes: false }), { + api: harness.api, + prompts: unexpectedPrompts(), + }), + ).rejects.toThrow("requires explicit mutation consent"); + + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test.each([ + { + name: "the exact native application is not ready", + nativeApplicationReady: false, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(), + schema: appleSchema(), + blocker: "native-application-not-ready", + }, + { + name: "the Bundle ID is missing", + nativeApplicationReady: true, + bundleIdentifier: " ", + value: connection(), + schema: appleSchema(), + blocker: "bundle-identifier-unavailable", + }, + { + name: "the schema does not prove the exact native Bundle ID patch", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(), + schema: { + type: "object", + properties: { + connection_oauth_apple: { + type: "object", + properties: { + enabled: { type: "boolean" }, + authenticatable: { type: "boolean" }, + }, + }, + }, + } as InstanceConfigSchema, + blocker: "apple-config-unsupported", + }, + { + name: "the current config is malformed", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: { enabled: "yes", authenticatable: true } as unknown as AppleConnection, + schema: appleSchema(), + blocker: "apple-config-invalid", + }, + { + name: "Apple is enabled but deliberately not authenticatable", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(true, false), + schema: appleSchema(), + blocker: "apple-authenticatable-conflict", + }, + { + name: "an existing Apple Bundle ID conflicts", + nativeApplicationReady: true, + bundleIdentifier: BUNDLE_IDENTIFIER, + value: connection(false, true, { bundle_id: "com.example.OtherApp" }), + schema: appleSchema(), + blocker: "apple-bundle-identifier-conflict", + }, + ])("fails closed when $name", (fixture) => { + const plan = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: fixture.bundleIdentifier, + nativeApplicationReady: fixture.nativeApplicationReady, + config: config(fixture.value), + schema: fixture.schema, + }); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toContainEqual(expect.objectContaining({ code: fixture.blocker })); + }); + + test("fails before writing when the approved config version becomes stale", async () => { + const harness = statefulAPI({ supportsIfMatch: true }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + harness.setVersion(NEXT_CONFIG_VERSION); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "changed after the approved preview", + ); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("requires a valid server dry-run projection before the actual write", async () => { + const harness = statefulAPI({ malformedDryRun: true }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "could not safely validate native Sign in with Apple", + ); + expect(harness.actualWrites()).toBe(0); + }); + + test("rejects a dry-run projection that drops existing Apple credential fields", async () => { + const harness = statefulAPI({ + initial: connection(false, false, { + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + team_id: TEAM_ID, + key_id: KEY_ID, + }), + replaceProjection: true, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "could not safely validate native Sign in with Apple", + ); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]); + expect(harness.actualWrites()).toBe(0); + expect(captured.err).not.toContain(PRIVATE_KEY); + }); + + test("rereads final state and rejects a write that did not persist", async () => { + const harness = statefulAPI({ persistActual: false }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("did not pass final verification"), + }); + expect(harness.actualWrites()).toBe(1); + expect(harness.current().enabled).toBe(false); + }); + + test("sanitizes read, dry-run, and write API failures", async () => { + const readHarness = statefulAPI({ failFetch: new Error(API_SECRET) }); + let readError: unknown; + try { + await prepareIOSNativeAppleConnection(baseOptions(), { + api: readHarness.api, + prompts: unexpectedPrompts(), + }); + } catch (error) { + readError = error; + } + expect(String(readError)).not.toContain(API_SECRET); + + const dryRunHarness = statefulAPI({ failDryRun: new Error(PRIVATE_KEY) }); + const dryRunPrepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: dryRunHarness.api, + prompts: unexpectedPrompts(), + }); + if (dryRunPrepared.status !== "ready") throw new Error("expected ready plan"); + let dryRunError: unknown; + try { + await applyIOSNativeAppleConnection(dryRunPrepared, dryRunHarness.api); + } catch (error) { + dryRunError = error; + } + expect(String(dryRunError)).not.toContain(PRIVATE_KEY); + + const writeHarness = statefulAPI({ failActual: new Error(TEAM_ID) }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: writeHarness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + let writeError: unknown; + try { + await applyIOSNativeAppleConnection(prepared, writeHarness.api); + } catch (error) { + writeError = error; + } + expect(String(writeError)).not.toContain(TEAM_ID); + + const allOutput = `${captured.err}\n${JSON.stringify({ readError, dryRunError, writeError })}`; + for (const sensitive of [API_SECRET, PRIVATE_KEY, TEAM_ID, KEY_ID, SERVICES_ID]) { + expect(allOutput).not.toContain(sensitive); + } + }); + + test("accepts a missing config version but blocks malformed version material", () => { + const withoutVersion = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: { connection_oauth_apple: connection() }, + schema: appleSchema(), + }); + expect(withoutVersion.status).toBe("ready"); + expect(withoutVersion.configVersion).toBeUndefined(); + + const sensitiveVersion = `v1_${PRIVATE_KEY}`; + const malformedVersion = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: config(connection(), sensitiveVersion), + schema: appleSchema(), + }); + expect(malformedVersion.status).toBe("blocked"); + expect(JSON.stringify(malformedVersion)).not.toContain(PRIVATE_KEY); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-apple.ts b/packages/cli-core/src/commands/init/ios/native-apple.ts new file mode 100644 index 000000000..b491ea81c --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -0,0 +1,669 @@ +import { dim, yellow } from "../../../lib/color.ts"; +import { + ApiError, + CliError, + ERROR_CODE, + type ErrorCode, + throwUsageError, + throwUserAbort, +} from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { + fetchInstanceConfig, + fetchInstanceConfigSchema, + patchInstanceConfig, + type InstanceConfigSchema, +} from "../../../lib/plapi.ts"; +import { confirm } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; + +const APPLE_CONNECTION_KEY = "connection_oauth_apple"; +const CONFIG_VERSION_PATTERN = /^v1_[0-9a-f]{8}$/; + +function iosAppleError( + message: string, + code: ErrorCode = ERROR_CODE.IOS_REMOTE_APPLY_FAILED, +): CliError { + return new CliError(message, { code }); +} + +function rethrowKnownAppleError(error: unknown): void { + if (error instanceof CliError || error instanceof ApiError) throw error; +} + +type AppleConnectionState = { + enabled: boolean; + authenticatable: boolean; +}; + +export type IOSNativeAppleBlockerCode = + | "native-application-not-ready" + | "bundle-identifier-unavailable" + | "apple-config-unsupported" + | "apple-config-invalid" + | "apple-authenticatable-conflict" + | "apple-bundle-identifier-conflict"; + +export interface IOSNativeAppleBlocker { + code: IOSNativeAppleBlockerCode; + message: string; +} + +/** + * Serializable, credential-free preview of the remote Apple connection work. + * The raw Platform Config response must never be attached to this value. + */ +export type IOSNativeApplePlan = { + schemaVersion: 1; + kind: "clerk-ios-native-apple-connection"; + status: "ready" | "satisfied" | "blocked"; + applicationId: string; + instanceId: string; + bundleIdentifier: string; + configVersion?: string; + connection: "required" | "satisfied" | "blocked"; + bundleIdentifierConfiguration: "required" | "satisfied" | "blocked"; + current?: AppleConnectionState; + desired: AppleConnectionState; + actions: string[]; + blockers: IOSNativeAppleBlocker[]; +}; + +export type IOSNativeAppleSkipped = { + schemaVersion: 1; + kind: "clerk-ios-native-apple-connection"; + status: "skipped"; + reason: "not-requested" | "declined"; +}; + +export type IOSNativeApplePreparation = IOSNativeApplePlan | IOSNativeAppleSkipped; + +export interface IOSNativeApplePatchOptions { + dryRun: boolean; + /** Forwarded only by clients which explicitly advertise support. */ + ifMatch?: string; +} + +export interface IOSNativeAppleAPI { + /** + * PLAPI supports both server dry-run and If-Match. Test or alternate + * adapters may opt out of If-Match; config-version revalidation remains + * mandatory either way. + */ + supportsIfMatch?: boolean; + fetchInstanceConfig( + applicationId: string, + instanceId: string, + keys?: string[], + ): Promise>; + fetchInstanceConfigSchema( + applicationId: string, + instanceId: string, + keys?: string[], + ): Promise; + patchInstanceConfig( + applicationId: string, + instanceId: string, + config: Record, + options: IOSNativeApplePatchOptions, + ): Promise>; +} + +const defaultAPI: IOSNativeAppleAPI = { + supportsIfMatch: true, + fetchInstanceConfig, + fetchInstanceConfigSchema, + patchInstanceConfig: async (applicationId, instanceId, config, options) => + patchInstanceConfig(applicationId, instanceId, config, { + dryRun: options.dryRun, + ifMatch: options.ifMatch, + }), +}; + +export interface IOSNativeApplePrompts { + enableNativeApple(bundleIdentifier: string): Promise; + confirmChanges(): Promise; +} + +const defaultPrompts: IOSNativeApplePrompts = { + enableNativeApple: async (bundleIdentifier) => + confirm({ + message: `Enable native Sign in with Apple for ${bundleIdentifier}?`, + default: false, + }), + confirmChanges: async () => + confirm({ + message: "Apply this remote Clerk Sign in with Apple change?", + default: false, + }), +}; + +export interface IOSNativeAppleOptions { + applicationId: string; + instanceId: string; + bundleIdentifier: string; + /** + * The exact selected target's registration is already satisfied or is an + * approved prerequisite which the caller will apply before this plan. + */ + nativeApplicationReady: boolean; +} + +export interface PrepareIOSNativeAppleOptions extends IOSNativeAppleOptions { + /** + * `undefined` prompts a human but defaults to skipped in agent mode. `--yes` + * is mutation consent only and never opts a project into Apple by itself. + */ + requested?: boolean; + agent: boolean; + yes: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function blocker(code: IOSNativeAppleBlockerCode, message: string): IOSNativeAppleBlocker { + return { code, message }; +} + +function schemaSupportsNarrowApplePatch(schema: InstanceConfigSchema): boolean { + const connection = schema.properties?.[APPLE_CONNECTION_KEY]; + return ( + connection?.type === "object" && + connection.properties?.enabled?.type === "boolean" && + connection.properties?.authenticatable?.type === "boolean" && + connection.properties?.bundle_id?.type === "string" + ); +} + +type ParsedConnection = + | { status: "valid"; value: AppleConnectionState; bundleIdentifier?: string } + | { status: "invalid" }; + +function parseConnection(container: unknown): ParsedConnection { + if (!isRecord(container)) return { status: "invalid" }; + const connection = container[APPLE_CONNECTION_KEY]; + if (!isRecord(connection)) return { status: "invalid" }; + if (typeof connection.enabled !== "boolean" || typeof connection.authenticatable !== "boolean") { + return { status: "invalid" }; + } + + const bundleIdentifier = connection.bundle_id; + if (bundleIdentifier !== undefined && typeof bundleIdentifier !== "string") { + return { status: "invalid" }; + } + return { + status: "valid", + value: { + enabled: connection.enabled, + authenticatable: connection.authenticatable, + }, + ...(typeof bundleIdentifier === "string" && bundleIdentifier.trim() + ? { bundleIdentifier: bundleIdentifier.trim() } + : {}), + }; +} + +function parseConfigVersion( + container: Record, +): { status: "missing" } | { status: "valid"; value: string } | { status: "invalid" } { + const value = container.config_version; + if (value == null) return { status: "missing" }; + if (typeof value !== "string" || !CONFIG_VERSION_PATTERN.test(value)) { + return { status: "invalid" }; + } + return { status: "valid", value }; +} + +export function buildIOSNativeApplePlan( + options: IOSNativeAppleOptions & { + config: Record; + schema: InstanceConfigSchema; + }, +): IOSNativeApplePlan { + const blockers: IOSNativeAppleBlocker[] = []; + const bundleIdentifier = options.bundleIdentifier.trim(); + if (!bundleIdentifier) { + blockers.push( + blocker( + "bundle-identifier-unavailable", + "Resolve one Bundle ID for the selected iOS target before enabling native Sign in with Apple.", + ), + ); + } + if (!options.nativeApplicationReady) { + blockers.push( + blocker( + "native-application-not-ready", + "Verify the exact selected iOS target's Clerk Native Application registration before enabling native Sign in with Apple.", + ), + ); + } + if (!schemaSupportsNarrowApplePatch(options.schema)) { + blockers.push( + blocker( + "apple-config-unsupported", + "This Clerk instance does not expose the narrow native Apple connection configuration required by clerk init.", + ), + ); + } + + const parsed = parseConnection(options.config); + if (parsed.status === "invalid") { + blockers.push( + blocker( + "apple-config-invalid", + "The existing Apple connection configuration could not be interpreted safely. Review it in the Clerk Dashboard before continuing.", + ), + ); + } + + const configVersion = parseConfigVersion(options.config); + if (configVersion.status === "invalid") { + blockers.push( + blocker( + "apple-config-invalid", + "The Apple connection configuration version could not be interpreted safely. Rerun clerk init before making remote changes.", + ), + ); + } + + if ( + parsed.status === "valid" && + parsed.bundleIdentifier && + bundleIdentifier && + parsed.bundleIdentifier !== bundleIdentifier + ) { + blockers.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) { + blockers.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 desired: AppleConnectionState = { enabled: true, authenticatable: true }; + const bundleIdentifierConfiguration = + blockers.length > 0 + ? "blocked" + : parsed.status !== "valid" + ? "blocked" + : parsed.bundleIdentifier === bundleIdentifier + ? "satisfied" + : "required"; + const connection = + blockers.length > 0 + ? "blocked" + : current?.enabled === true && + current.authenticatable === true && + bundleIdentifierConfiguration === "satisfied" + ? "satisfied" + : "required"; + const status = + connection === "blocked" ? "blocked" : connection === "satisfied" ? "satisfied" : "ready"; + const actions = + status === "ready" + ? [ + `Enable native Sign in with Apple for ${bundleIdentifier} by setting enabled, authenticatable, and the exact registered Bundle ID; preserve all existing web credential fields.`, + ] + : []; + + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status, + applicationId: options.applicationId, + instanceId: options.instanceId, + bundleIdentifier, + ...(configVersion.status === "valid" ? { configVersion: configVersion.value } : {}), + connection, + bundleIdentifierConfiguration, + ...(current ? { current } : {}), + desired, + actions, + blockers, + }; +} + +export async function auditIOSNativeAppleConnection( + options: IOSNativeAppleOptions, + api: IOSNativeAppleAPI = defaultAPI, +): Promise { + let config: Record; + let schema: InstanceConfigSchema; + try { + [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, + ]), + ]), + ); + } catch (error) { + rethrowKnownAppleError(error); + throw iosAppleError( + "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.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + + return buildIOSNativeApplePlan({ ...options, config, schema }); +} + +function skipped(reason: IOSNativeAppleSkipped["reason"]): IOSNativeAppleSkipped { + return { + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "skipped", + reason, + }; +} + +function formatBlockers(plan: IOSNativeApplePlan): string { + return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); +} + +function patchOptions( + api: IOSNativeAppleAPI, + plan: IOSNativeApplePlan, + dryRun: boolean, +): IOSNativeApplePatchOptions { + return { + dryRun, + ...(api.supportsIfMatch && plan.configVersion ? { ifMatch: plan.configVersion } : {}), + }; +} + +function applePatch(bundleIdentifier: string): Record { + // This intentionally excludes client_id, client_secret, team_id, key_id, + // and every other hosted/web credential field. The exact registered native + // Bundle ID is the only provider setting written. PLAPI's nested merge + // semantics preserve fields which are not explicitly provided. + return { + [APPLE_CONNECTION_KEY]: { + enabled: true, + authenticatable: true, + bundle_id: bundleIdentifier, + }, + }; +} + +function validatePatchProjection( + response: Record, + expectedBefore: AppleConnectionState, + expectedBundleConfiguration: IOSNativeApplePlan["bundleIdentifierConfiguration"], + bundleIdentifier: string, + dryRun: boolean, +): void { + if (response.dry_run !== dryRun || !isRecord(response.before) || !isRecord(response.after)) { + throw iosAppleError( + "Clerk returned an invalid Apple configuration projection.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + const beforeConnection = response.before[APPLE_CONNECTION_KEY]; + const afterConnection = response.after[APPLE_CONNECTION_KEY]; + if ( + !isRecord(beforeConnection) || + !isRecord(afterConnection) || + Object.keys(beforeConnection).some((key) => !Object.hasOwn(afterConnection, key)) + ) { + throw iosAppleError( + "Clerk returned an Apple configuration projection that removed existing fields.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + const before = parseConnection(response.before); + const after = parseConnection(response.after); + const beforeBundleConfiguration = + before.status !== "valid" + ? "blocked" + : before.bundleIdentifier === bundleIdentifier + ? "satisfied" + : before.bundleIdentifier == null + ? "required" + : "blocked"; + if ( + before.status !== "valid" || + after.status !== "valid" || + before.value.enabled !== expectedBefore.enabled || + before.value.authenticatable !== expectedBefore.authenticatable || + beforeBundleConfiguration !== expectedBundleConfiguration || + !after.value.enabled || + !after.value.authenticatable || + after.bundleIdentifier !== bundleIdentifier + ) { + throw iosAppleError( + "Clerk returned an Apple configuration projection that did not match the approved change.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + if (parseConfigVersion(response).status === "invalid") { + throw iosAppleError( + "Clerk returned an invalid Apple configuration version.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } +} + +async function validateServerPatch( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI, + dryRun: boolean, +): Promise { + if (!plan.current) { + throw iosAppleError( + "The approved native Apple connection plan is missing its current state.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const response = await api.patchInstanceConfig( + plan.applicationId, + plan.instanceId, + applePatch(plan.bundleIdentifier), + patchOptions(api, plan, dryRun), + ); + validatePatchProjection( + response, + plan.current, + plan.bundleIdentifierConfiguration, + plan.bundleIdentifier, + dryRun, + ); +} + +async function preflightIOSNativeAppleConnection( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI, +): Promise { + try { + await withSpinner("Validating the native Apple connection change...", async () => + validateServerPatch(plan, api, true), + ); + } catch (error) { + if (error instanceof ApiError) throw error; + throw iosAppleError( + "Clerk could not safely validate native Sign in with Apple. No remote Apple connection changes were made; verify the Native Application registration and existing Apple connection, then rerun clerk init.", + error instanceof CliError && error.code ? error.code : ERROR_CODE.IOS_REMOTE_APPLY_FAILED, + ); + } +} + +export async function prepareIOSNativeAppleConnection( + options: PrepareIOSNativeAppleOptions, + dependencies: { + api?: IOSNativeAppleAPI; + prompts?: IOSNativeApplePrompts; + } = {}, +): Promise { + const api = dependencies.api ?? defaultAPI; + const prompts = dependencies.prompts ?? defaultPrompts; + + if (options.requested === false || (options.requested == null && options.agent)) { + return skipped("not-requested"); + } + if ( + options.requested == null && + !(await prompts.enableNativeApple(options.bundleIdentifier.trim())) + ) { + return skipped("declined"); + } + + const plan = await auditIOSNativeAppleConnection(options, api); + if (plan.status === "blocked") { + throw iosAppleError( + `Native Sign in with Apple could not be enabled safely. No remote Apple connection changes were made:\n${formatBlockers(plan)}`, + ERROR_CODE.IOS_SETUP_BLOCKED, + ); + } + if (plan.status === "satisfied") { + log.info(dim("Native Sign in with Apple is already enabled in Clerk.")); + return plan; + } + + log.info("\nclerk init will make the following remote Clerk change:\n"); + for (const action of plan.actions) log.info(` ${yellow("REMOTE")} ${action}`); + log.info( + dim( + "\n This native-only setup will not request, replace, or print an Apple Services ID, Team ID, Key ID, or private key.", + ), + ); + log.blank(); + + if (options.agent && !options.yes) { + throwUsageError( + "Changing the Clerk Apple connection in agent mode requires explicit mutation consent. Rerun the same command with --yes after reviewing the plan.", + ); + } + if (!options.yes && !(await prompts.confirmChanges())) throwUserAbort(); + return plan; +} + +function planIdentityMatches(approved: IOSNativeApplePlan, current: IOSNativeApplePlan): boolean { + return ( + current.applicationId === approved.applicationId && + current.instanceId === approved.instanceId && + current.bundleIdentifier === approved.bundleIdentifier + ); +} + +function planVersionMatches(approved: IOSNativeApplePlan, current: IOSNativeApplePlan): boolean { + if (!approved.configVersion) return true; + return current.configVersion === approved.configVersion; +} + +export async function applyIOSNativeAppleConnection( + plan: IOSNativeApplePlan, + api: IOSNativeAppleAPI = defaultAPI, +): Promise { + if (plan.status === "blocked" || !plan.current || !plan.bundleIdentifier) { + throw iosAppleError( + "The approved native Apple connection plan is incomplete. No remote Apple connection changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + const approvedWasSatisfied = plan.status === "satisfied"; + + let current: IOSNativeApplePlan; + try { + current = await auditIOSNativeAppleConnection( + { + applicationId: plan.applicationId, + instanceId: plan.instanceId, + bundleIdentifier: plan.bundleIdentifier, + nativeApplicationReady: true, + }, + api, + ); + } catch (error) { + rethrowKnownAppleError(error); + throw iosAppleError( + "Clerk Sign in with Apple settings could not be rechecked. No remote Apple connection changes were made; rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + + if (!planIdentityMatches(plan, current)) { + throw iosAppleError( + "The approved native Apple connection target changed. No remote Apple connection changes were made; rerun clerk init to review the new plan.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + if (approvedWasSatisfied) { + if (current.status !== "satisfied") { + throw iosAppleError( + "The Clerk Apple connection changed after the approved preview. No remote Apple connection changes were made; rerun clerk init to review the current state.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + return; + } + if (current.status === "satisfied") return; + if ( + current.status !== "ready" || + !current.current || + !planVersionMatches(plan, current) || + current.current.enabled !== plan.current.enabled || + current.current.authenticatable !== plan.current.authenticatable + ) { + throw iosAppleError( + "The Clerk Apple connection changed after the approved preview. No remote Apple connection changes were made; rerun clerk init to review the current state.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + + await preflightIOSNativeAppleConnection(current, api); + + try { + await withSpinner("Enabling native Sign in with Apple in Clerk...", async () => + validateServerPatch(current, api, false), + ); + } catch (error) { + rethrowKnownAppleError(error); + throw iosAppleError( + "Native Sign in with Apple could not be enabled or confirmed. No credential material was exposed; rerun clerk init to reconcile the remote state safely.", + ); + } + + let finalPlan: IOSNativeApplePlan; + try { + finalPlan = await auditIOSNativeAppleConnection( + { + applicationId: plan.applicationId, + instanceId: plan.instanceId, + bundleIdentifier: plan.bundleIdentifier, + nativeApplicationReady: true, + }, + api, + ); + } catch (error) { + rethrowKnownAppleError(error); + throw iosAppleError( + "Native Sign in with Apple was submitted but its final Clerk state could not be verified. Rerun clerk init to inspect it safely.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + if (finalPlan.status !== "satisfied") { + throw iosAppleError( + "Native Sign in with Apple did not pass final verification. Rerun clerk init to reconcile the remote state safely.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + log.success("Native Sign in with Apple enabled in Clerk"); +} diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts new file mode 100644 index 000000000..36f6dcca0 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -0,0 +1,331 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectIOSProject } from "./inspect.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { + buildIOSNativeReadinessAudit, + IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + suggestAppIdPrefixFromDevelopmentTeam, +} from "./native-readiness.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +async function inspectionFor( + options: Parameters[1] = {}, + target?: string, +) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-native-readiness-")); + temporaryDirectories.push(root); + await createIOSFixture(root, options); + return inspectIOSProject(root, { target }); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("buildIOSNativeReadinessAudit", () => { + test("reports a redacted selected-target identity and the exact authenticated PLAPI bridge", async () => { + const inspection = await inspectionFor({ complete: true }); + const selected = inspection.appTargets[0]!; + for (const configuration of selected.configurations) { + configuration.developmentTeam = { + state: "resolved", + value: "DEVELOPMENT_TEAM_MUST_NOT_ESCAPE", + evidence: [], + }; + if (configuration.entitlements) { + configuration.entitlements.teamIdentifier = "ENTITLEMENTS_TEAM_MUST_NOT_ESCAPE"; + } + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit).toMatchObject({ + schemaVersion: 1, + kind: "clerk-ios-native-readiness", + root: inspection.root, + target: { + status: "selected", + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + targetName: "MyApp", + bundleIdentifier: { status: "resolved", value: "com.example.MyApp" }, + appIdPrefix: { + status: "resolved", + source: "literal-entitlements", + value: "LEGACY1234", + }, + }, + associatedDomain: { + status: "review", + expectedDomain: "webcredentials:clerk.example.test", + files: ["MyApp/MyApp.entitlements"], + automatable: false, + }, + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + }, + }); + expect(audit.remote.requirement).toEqual({ + applicationId: "linked-application-id", + instanceId: "linked-development-instance-id", + authentication: "clerk-cli-bearer-token", + scope: "applications:read", + reads: [ + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_settings", + provides: "native-api-state", + }, + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_applications/ios", + provides: "ios-native-applications", + }, + ], + }); + expect(JSON.stringify(audit)).not.toContain("DEVELOPMENT_TEAM_MUST_NOT_ESCAPE"); + expect(JSON.stringify(audit)).not.toContain("ENTITLEMENTS_TEAM_MUST_NOT_ESCAPE"); + }); + + test("offers one unanimous Xcode Development Team only as an unverified suggestion", async () => { + const inspection = await inspectionFor({ complete: true }); + const target = inspection.appTargets[0]!; + + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toEqual({ + source: "xcode-development-team", + value: "ABCDE12345", + }); + expect(JSON.stringify(buildIOSNativeReadinessAudit(inspection))).not.toContain("ABCDE12345"); + }); + + test("withholds the Xcode Development Team suggestion unless every configuration agrees", async () => { + const inspection = await inspectionFor({ complete: true }); + const target = inspection.appTargets[0]!; + target.configurations[1]!.developmentTeam = { + state: "resolved", + value: "ZZZZZ99999", + evidence: [], + }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + target.configurations[1]!.developmentTeam = { state: "missing", evidence: [] }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + target.configurations[1]!.developmentTeam = { + state: "unresolved", + raw: "$(APPLE_TEAM)", + missingVariables: ["APPLE_TEAM"], + evidence: [], + }; + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + + for (const configuration of target.configurations) { + configuration.developmentTeam = { + state: "resolved", + value: "NOT-A-TEAM", + evidence: [], + }; + } + expect(suggestAppIdPrefixFromDevelopmentTeam(target)).toBeUndefined(); + }); + + test("requires the bare domain when only Apple's developer-mode entry is present", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = [ + "webcredentials:native.clerk.example?mode=developer", + ]; + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toEqual({ + status: "required", + expectedDomain: "webcredentials:native.clerk.example", + files: ["MyApp/MyApp.entitlements"], + automatable: true, + blockers: [], + }); + }); + + test("recognizes the exact bare domain as locally satisfied", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["webcredentials:native.clerk.example"]; + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toEqual({ + status: "satisfied", + expectedDomain: "webcredentials:native.clerk.example", + files: ["MyApp/MyApp.entitlements"], + automatable: false, + blockers: [], + }); + }); + + test("blocks automation when configurations have mixed entitlements evidence", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + const target = inspection.appTargets[0]!; + target.configurations[1]!.entitlements = undefined; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain.status).toBe("required"); + expect(audit.associatedDomain.automatable).toBe(false); + expect(audit.associatedDomain.files).toEqual(["MyApp/MyApp.entitlements"]); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "missing-or-unreadable-entitlements" }), + ); + }); + + test("carries a strict Associated Domains blocker into native readiness", async () => { + const inspection = await inspectionFor({ complete: true }); + const associatedDomainPlan: IOSAssociatedDomainPlan = { + schemaVersion: 1, + kind: "clerk-ios-associated-domain", + status: "blocked", + root: inspection.root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + targetName: "MyApp", + requiresPublishableKey: false, + files: [], + actions: [], + blockers: [{ code: "generated-project", message: "Update the project source definition." }], + }; + + const audit = buildIOSNativeReadinessAudit(inspection, { associatedDomainPlan }); + + expect(audit.associatedDomain).toMatchObject({ status: "review", automatable: false }); + expect(audit.associatedDomain.blockers).toContainEqual({ + code: "manual-review-required", + message: "Update the project source definition.", + }); + }); + + test("preserves all distinct existing XML entitlements routes", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + const target = inspection.appTargets[0]!; + const release = target.configurations[1]!; + release.entitlements = { + ...release.entitlements!, + path: "MyApp/MyApp-Release.entitlements", + associatedDomains: [], + }; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toMatchObject({ + status: "required", + automatable: true, + files: ["MyApp/MyApp-Release.entitlements", "MyApp/MyApp.entitlements"], + blockers: [], + }); + }); + + test("does not claim a single bundle identifier or App ID Prefix when they conflict", async () => { + const inspection = await inspectionFor({ complete: true, conflictingBundle: true }); + const target = inspection.appTargets[0]!; + target.configurations[1]!.entitlements = { + ...target.configurations[1]!.entitlements!, + literalAppIdentifierPrefix: "OTHERPREFIX", + }; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toMatchObject({ + status: "selected", + bundleIdentifier: { + status: "conflicting", + candidates: ["com.example.MyApp", "com.example.MyApp.release"], + }, + appIdPrefix: { + status: "conflicting", + source: "literal-entitlements", + candidates: ["LEGACY1234", "OTHERPREFIX"], + }, + }); + }); + + test("preserves a partial App ID Prefix candidate when one selected configuration lacks it", async () => { + const inspection = await inspectionFor({ complete: true }); + const releaseEntitlements = inspection.appTargets[0]!.configurations[1]!.entitlements!; + delete releaseEntitlements.literalAppIdentifierPrefix; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toMatchObject({ + status: "selected", + appIdPrefix: { + status: "missing", + source: "literal-entitlements", + candidates: ["LEGACY1234"], + }, + }); + }); + + test("blocks identity and entitlement routing when target selection is ambiguous", async () => { + const inspection = await inspectionFor({ secondTarget: true }); + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toEqual({ status: "blocked", reason: "target-not-selected" }); + expect(audit.associatedDomain).toMatchObject({ + status: "blocked", + files: [], + automatable: false, + }); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "target-not-selected" }), + ); + }); + + test("does not invent a domain without redacted publishable-key metadata", async () => { + const inspection = await inspectionFor({ complete: false, includeKey: false }); + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain.expectedDomain).toBeUndefined(); + expect(audit.associatedDomain.status).toBe("blocked"); + expect(audit.associatedDomain.automatable).toBe(false); + expect(audit.associatedDomain.blockers).toContainEqual( + expect.objectContaining({ code: "expected-domain-unavailable" }), + ); + }); + + test("never copies an unexpected raw publishable-key property", async () => { + const inspection = await inspectionFor({ complete: true }); + const key = `pk_test_${Buffer.from("must-not-escape.example$").toString("base64")}`; + (inspection.localPublishableKey as unknown as Record).publishableKey = key; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(JSON.stringify(audit)).not.toContain(key); + expect(JSON.stringify(audit)).not.toContain("publishableKey"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.ts b/packages/cli-core/src/commands/init/ios/native-readiness.ts new file mode 100644 index 000000000..b02a3390c --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -0,0 +1,364 @@ +import { buildIOSSetupPlan } from "./plan.ts"; +import type { + IOSAppTarget, + IOSProjectInspectionResult, + IOSSetupStepStatus, + IOSValueResolution, +} from "./types.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; + +export const IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT = { + applicationId: "linked-application-id", + instanceId: "linked-development-instance-id", + authentication: "clerk-cli-bearer-token", + scope: "applications:read", + reads: [ + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_settings", + provides: "native-api-state", + }, + { + method: "GET", + path: "/v1/platform/applications/{applicationId}/instances/{instanceId}/native_applications/ios", + provides: "ios-native-applications", + }, + ], +} as const; + +export type IOSNativeReadinessBundleIdentifier = + | { status: "resolved"; value: string } + | { status: "missing" } + | { status: "unresolved" } + | { status: "conflicting"; candidates: string[] }; + +export type IOSNativeReadinessAppIdPrefix = + | { status: "resolved"; source: "literal-entitlements"; value: string } + | { + status: "missing"; + source: "literal-entitlements"; + /** Literal values observed in only part of the selected target's configuration set. */ + candidates?: string[]; + } + | { + status: "conflicting"; + source: "literal-entitlements"; + candidates: string[]; + }; + +/** + * A human-only convenience value from Xcode signing configuration. This is + * never treated as proven App ID Prefix evidence because legacy Apple + * accounts can use a prefix that differs from DEVELOPMENT_TEAM. + */ +export type IOSUnverifiedAppIdPrefixSuggestion = { + source: "xcode-development-team"; + value: string; +}; + +export type IOSNativeReadinessTarget = + | { + status: "selected"; + projectPath: string; + targetId: string; + targetName: string; + bundleIdentifier: IOSNativeReadinessBundleIdentifier; + appIdPrefix: IOSNativeReadinessAppIdPrefix; + } + | { + status: "blocked"; + reason: "target-not-selected" | "selected-target-not-found"; + }; + +export type IOSAssociatedDomainAutomationBlockerCode = + | "target-not-selected" + | "expected-domain-unavailable" + | "manual-review-required" + | "generated-project" + | "missing-build-configurations" + | "unresolved-entitlements-path" + | "missing-or-unreadable-entitlements" + | "unresolved-associated-domains"; + +export interface IOSAssociatedDomainAutomationBlocker { + code: IOSAssociatedDomainAutomationBlockerCode; + message: string; +} + +export interface IOSAssociatedDomainReadiness { + /** The local status from the canonical iOS setup plan. */ + status: IOSSetupStepStatus; + /** Exact entitlement value derived from redacted publishable-key metadata. */ + expectedDomain?: string; + /** Existing, inspected XML entitlements files owned by the selected target. */ + files: string[]; + /** True only when a future writer has a complete, unambiguous local route. */ + automatable: boolean; + blockers: IOSAssociatedDomainAutomationBlocker[]; +} + +export interface IOSNativeReadinessAudit { + schemaVersion: 1; + kind: "clerk-ios-native-readiness"; + root: string; + target: IOSNativeReadinessTarget; + associatedDomain: IOSAssociatedDomainReadiness; + remote: { + status: "not-inspected"; + reason: "dry-run-does-not-read-remote-state"; + requirement: typeof IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT; + }; +} + +export interface BuildIOSNativeReadinessAuditOptions { + associatedDomainPlan?: IOSAssociatedDomainPlan; +} + +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, + ); +} + +function resolvedValues( + target: IOSAppTarget, + select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, +): string[] { + return [ + ...new Set( + target.configurations.flatMap((configuration) => { + const value = select(configuration); + return value.state === "resolved" ? [value.value] : []; + }), + ), + ].sort(); +} + +export function suggestAppIdPrefixFromDevelopmentTeam( + target: IOSAppTarget, +): IOSUnverifiedAppIdPrefixSuggestion | undefined { + if (target.configurations.length === 0) return undefined; + + const values = target.configurations.map((configuration) => configuration.developmentTeam); + if (values.some((value) => value.state !== "resolved")) return undefined; + + const candidates = [ + ...new Set(values.map((value) => (value.state === "resolved" ? value.value.trim() : ""))), + ]; + if (candidates.length !== 1 || !/^[A-Z0-9]{10}$/.test(candidates[0]!)) return undefined; + + return { source: "xcode-development-team", value: candidates[0]! }; +} + +function bundleIdentifier(target: IOSAppTarget): IOSNativeReadinessBundleIdentifier { + if (target.configurations.length === 0) return { status: "missing" }; + if ( + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state === "missing", + ) + ) { + return { status: "missing" }; + } + if ( + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state === "unresolved", + ) + ) { + return { status: "unresolved" }; + } + + const candidates = resolvedValues(target, (configuration) => configuration.bundleIdentifier); + if (candidates.length === 1) return { status: "resolved", value: candidates[0]! }; + if (candidates.length === 0) return { status: "missing" }; + return { status: "conflicting", candidates }; +} + +function appIdPrefix(target: IOSAppTarget): IOSNativeReadinessAppIdPrefix { + const candidates = [ + ...new Set( + target.configurations.flatMap((configuration) => { + const value = configuration.entitlements?.literalAppIdentifierPrefix; + return value == null ? [] : [value]; + }), + ), + ].sort(); + + if ( + candidates.length === 1 && + target.configurations.length > 0 && + target.configurations.every( + (configuration) => configuration.entitlements?.literalAppIdentifierPrefix === candidates[0], + ) + ) { + return { status: "resolved", source: "literal-entitlements", value: candidates[0]! }; + } + if (candidates.length > 1) { + return { status: "conflicting", source: "literal-entitlements", candidates }; + } + return { status: "missing", source: "literal-entitlements", candidates }; +} + +function targetIdentity( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget | undefined, +): IOSNativeReadinessTarget { + if (inspection.selection.state !== "selected") { + return { status: "blocked", reason: "target-not-selected" }; + } + if (!target) return { status: "blocked", reason: "selected-target-not-found" }; + + return { + status: "selected", + projectPath: target.projectPath, + targetId: target.id, + targetName: target.name, + bundleIdentifier: bundleIdentifier(target), + appIdPrefix: appIdPrefix(target), + }; +} + +function associatedDomainReadiness( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget | undefined, + associatedDomainPlan: IOSAssociatedDomainPlan | undefined, +): IOSAssociatedDomainReadiness { + const plan = buildIOSSetupPlan(inspection, { associatedDomainPlan }); + const planStep = plan.steps.find((step) => step.id === "add-associated-domain"); + const host = inspection.localPublishableKey.frontendApiHost; + const expectedDomain = host ? `webcredentials:${host}` : undefined; + const files = + associatedDomainPlan?.files.map((file) => file.path) ?? + (target + ? [ + ...new Set( + target.configurations.flatMap((configuration) => + configuration.entitlements ? [configuration.entitlements.path] : [], + ), + ), + ].sort() + : []); + const everyConfigurationHasExactDomain = + expectedDomain != null && + target != null && + target.configurations.length > 0 && + target.configurations.every((configuration) => + configuration.entitlements?.associatedDomains.some( + (domain) => domain.toLowerCase() === expectedDomain.toLowerCase(), + ), + ); + // The legacy planner accepts Apple's ?mode=developer suffix. Native setup + // automation intentionally requires the bare production-capable entry. + const status = associatedDomainPlan + ? associatedDomainPlan.status === "ready" + ? "required" + : associatedDomainPlan.status === "satisfied" + ? "satisfied" + : (planStep?.status ?? "blocked") + : planStep?.status === "satisfied" && !everyConfigurationHasExactDomain + ? "required" + : (planStep?.status ?? "blocked"); + const blockers: IOSAssociatedDomainAutomationBlocker[] = []; + const strictPlanOwnsLocalReadiness = + associatedDomainPlan?.status === "ready" || associatedDomainPlan?.status === "satisfied"; + + if (!target) { + blockers.push({ + code: "target-not-selected", + message: "Select exactly one iOS application target before editing entitlements.", + }); + } else if (!strictPlanOwnsLocalReadiness) { + if (target.configurations.length === 0) { + blockers.push({ + code: "missing-build-configurations", + message: "The selected target has no inspected build configurations.", + }); + } + if ( + target.configurations.some( + (configuration) => configuration.entitlementsPath.state !== "resolved", + ) + ) { + blockers.push({ + code: "unresolved-entitlements-path", + message: "Resolve CODE_SIGN_ENTITLEMENTS for every selected-target configuration.", + }); + } + if (target.configurations.some((configuration) => configuration.entitlements == null)) { + blockers.push({ + code: "missing-or-unreadable-entitlements", + message: "Every selected-target configuration must use an existing XML entitlements file.", + }); + } + if ( + target.configurations.some( + (configuration) => + (configuration.entitlements?.unresolvedAssociatedDomains.length ?? 0) > 0, + ) + ) { + blockers.push({ + code: "unresolved-associated-domains", + message: "Resolve existing associated-domain build variables before editing entitlements.", + }); + } + } + + if (!expectedDomain && associatedDomainPlan?.requiresPublishableKey !== true) { + blockers.push({ + code: "expected-domain-unavailable", + message: "A proven local publishable key is required to derive the webcredentials domain.", + }); + } + if (inspection.generatedProject !== null) { + blockers.push({ + code: "generated-project", + message: `The Xcode project is owned by ${inspection.generatedProject}; update its source definition instead.`, + }); + } + if (status === "review") { + blockers.push({ + code: "manual-review-required", + message: "The canonical iOS setup plan requires review before this domain can be edited.", + }); + } + + const strictPlanBlockers = + associatedDomainPlan?.blockers.map((item) => ({ + code: "manual-review-required" as const, + message: item.message, + })) ?? []; + return { + status, + expectedDomain: associatedDomainPlan?.expectedDomain ?? expectedDomain, + files, + automatable: + associatedDomainPlan != null + ? associatedDomainPlan.status === "ready" && strictPlanBlockers.length === 0 + : status === "required" && blockers.length === 0, + blockers: [...strictPlanBlockers, ...blockers], + }; +} + +/** + * Builds a synchronous, serializable readiness snapshot without authentication, + * network access, or filesystem writes. Publishable-key values are never copied. + */ +export function buildIOSNativeReadinessAudit( + inspection: IOSProjectInspectionResult, + options: BuildIOSNativeReadinessAuditOptions = {}, +): IOSNativeReadinessAudit { + const target = selectedTarget(inspection); + return { + schemaVersion: 1, + kind: "clerk-ios-native-readiness", + root: inspection.root, + target: targetIdentity(inspection, target), + associatedDomain: associatedDomainReadiness(inspection, target, options.associatedDomainPlan), + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + }, + }; +} 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 new file mode 100644 index 000000000..1ba35a8c8 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-remote.test.ts @@ -0,0 +1,638 @@ +import { describe, expect, test } from "bun:test"; +import { ERROR_CODE, UserAbortError } from "../../../lib/errors.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import type { IOSNativeReadinessTarget } from "./native-readiness.ts"; +import { + applyIOSNativeRemoteSetup, + buildIOSNativeRemotePlan, + prepareIOSNativeRemoteSetup, + validateAppIdPrefix, + type IOSNativeRemoteAPI, + type IOSNativeRemotePlan, + type IOSNativeRemotePrompts, +} from "./native-remote.ts"; +import type { IOSApplication, NativeSettings } from "../../../lib/plapi.ts"; + +const APPLICATION_ID = "app_native_test"; +const INSTANCE_ID = "ins_native_development"; +const BUNDLE_IDENTIFIER = "com.example.NativeApp"; +const LOCAL_PREFIX = "LEGACY1234"; +const EXPLICIT_PREFIX = "EXPLICIT12"; + +const captured = useCaptureLog(); + +function nativeSettings(apiEnabled: boolean): NativeSettings { + return { object: "native_settings", api_enabled: apiEnabled }; +} + +function registration( + appIdPrefix = LOCAL_PREFIX, + bundleId = BUNDLE_IDENTIFIER, + id = `iosapp_${appIdPrefix}`, +): IOSApplication { + return { + object: "ios_application", + id, + app_id_prefix: appIdPrefix, + bundle_id: bundleId, + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }; +} + +function selectedTarget( + options: { + bundleIdentifier?: string; + appIdPrefix?: string | null; + appIdPrefixCandidates?: string[]; + } = {}, +): IOSNativeReadinessTarget { + const appIdPrefix = options.appIdPrefix === undefined ? LOCAL_PREFIX : options.appIdPrefix; + return { + status: "selected", + projectPath: "NativeApp.xcodeproj", + targetId: "TARGET_NATIVE_APP", + targetName: "NativeApp", + bundleIdentifier: { + status: "resolved", + value: options.bundleIdentifier ?? BUNDLE_IDENTIFIER, + }, + appIdPrefix: + appIdPrefix == null + ? { + status: "missing", + source: "literal-entitlements", + ...(options.appIdPrefixCandidates ? { candidates: options.appIdPrefixCandidates } : {}), + } + : { status: "resolved", source: "literal-entitlements", value: appIdPrefix }, + }; +} + +function plan(options: { + nativeApi: "required" | "satisfied"; + registration: "required" | "satisfied"; + appIdPrefix?: string; +}): IOSNativeRemotePlan { + const appIdPrefix = options.appIdPrefix ?? LOCAL_PREFIX; + return { + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status: + options.nativeApi === "satisfied" && options.registration === "satisfied" + ? "satisfied" + : "ready", + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix, + nativeApi: options.nativeApi, + registration: options.registration, + actions: [ + ...(options.nativeApi === "required" + ? ["Enable the Native API for the linked development instance."] + : []), + ...(options.registration === "required" + ? [`Register iOS Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${appIdPrefix}.`] + : []), + ], + blockers: [], + }; +} + +interface ScriptedAPIOptions { + nativeReads?: NativeSettings[]; + registrationReads?: IOSApplication[][]; + expectedAppIdPrefix?: string; + enable?: IOSNativeRemoteAPI["enableNativeApi"]; + create?: IOSNativeRemoteAPI["createIOSApplication"]; +} + +function scriptedAPI(options: ScriptedAPIOptions = {}): { + api: IOSNativeRemoteAPI; + calls: string[]; +} { + const calls: string[] = []; + const nativeReads = options.nativeReads ?? [nativeSettings(false)]; + const registrationReads = options.registrationReads ?? [[]]; + let nativeReadIndex = 0; + let registrationReadIndex = 0; + + const nextNativeSettings = () => + nativeReads[Math.min(nativeReadIndex++, nativeReads.length - 1)]!; + const nextRegistrations = () => + registrationReads[Math.min(registrationReadIndex++, registrationReads.length - 1)]!.map( + (item) => ({ ...item }), + ); + + return { + calls, + api: { + async getNativeSettings(applicationId, instanceId) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push("GET native settings"); + return nextNativeSettings(); + }, + async listIOSApplications(applicationId, instanceId) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + calls.push("GET iOS registrations"); + return nextRegistrations(); + }, + async enableNativeApi(applicationId, instanceId, mutationOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(mutationOptions.idempotencyKey).toStartWith("clerk-init-ios-native-api-"); + calls.push("PATCH native settings"); + if (options.enable) { + return options.enable(applicationId, instanceId, mutationOptions); + } + return nativeSettings(true); + }, + async createIOSApplication(applicationId, instanceId, params, mutationOptions) { + expect(applicationId).toBe(APPLICATION_ID); + expect(instanceId).toBe(INSTANCE_ID); + expect(params).toEqual({ + appIdPrefix: options.expectedAppIdPrefix ?? LOCAL_PREFIX, + bundleId: BUNDLE_IDENTIFIER, + }); + expect(mutationOptions.idempotencyKey).toStartWith("clerk-init-ios-registration-"); + calls.push("POST iOS registration"); + if (options.create) { + return options.create(applicationId, instanceId, params, mutationOptions); + } + return registration(params.appIdPrefix, params.bundleId); + }, + }, + }; +} + +function prepareOptions( + overrides: Partial[0]> = {}, +): Parameters[0] { + return { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + agent: false, + yes: true, + ...overrides, + }; +} + +function prompts( + options: { + appIdPrefix?: IOSNativeRemotePrompts["appIdPrefix"]; + confirmChanges?: () => Promise; + } = {}, +): IOSNativeRemotePrompts { + return { + appIdPrefix: + options.appIdPrefix ?? + (async () => { + throw new Error("unexpected App ID Prefix prompt"); + }), + confirmChanges: + options.confirmChanges ?? + (async () => { + throw new Error("unexpected remote-consent prompt"); + }), + }; +} + +describe("Clerk Native Application remote setup", () => { + 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(); + expect(validateAppIdPrefix("x".repeat(256))).toBeUndefined(); + }); + + test("revalidates a satisfied plan without prompting or writing", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[exactRegistration]], + }); + + const result = await prepareIOSNativeRemoteSetup(prepareOptions({ yes: false }), { + api, + prompts: prompts(), + }); + + expect(result).toMatchObject({ + status: "satisfied", + nativeApi: "satisfied", + registration: "satisfied", + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix: LOCAL_PREFIX, + actions: [], + blockers: [], + }); + await applyIOSNativeRemoteSetup(result, api); + expect(calls).toEqual([ + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + ]); + expect(captured.err).toContain("already configured"); + }); + + test.each([ + { + name: "Native API was disabled", + nativeReads: [nativeSettings(true), nativeSettings(false)], + registrationReads: [[registration()], [registration()]], + }, + { + name: "the exact iOS registration was deleted", + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[registration()], []], + }, + { + name: "the exact iOS registration prefix changed", + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[registration()], [registration(EXPLICIT_PREFIX)]], + }, + ])( + "fails closed without writing when $name after prepare", + async ({ nativeReads, registrationReads }) => { + const { api, calls } = scriptedAPI({ + nativeReads: [...nativeReads], + registrationReads: registrationReads.map((items) => [...items]), + }); + const approved = await prepareIOSNativeRemoteSetup(prepareOptions(), { + api, + prompts: prompts(), + }); + + await expect(applyIOSNativeRemoteSetup(approved, api)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: + "Clerk Native Application settings changed after the approved preview. No remote changes were made; rerun clerk init to review the new plan.", + }); + + expect(calls).toEqual([ + "GET native settings", + "GET iOS registrations", + "GET native settings", + "GET iOS registrations", + ]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }, + ); + + test("uses an explicit prefix when the registration is missing", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + appIdPrefix: EXPLICIT_PREFIX, + agent: true, + }), + { api, prompts: prompts() }, + ); + + expect(result).toMatchObject({ + status: "ready", + nativeApi: "satisfied", + registration: "required", + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix: EXPLICIT_PREFIX, + blockers: [], + }); + expect(result.actions).toEqual([ + `Register iOS Bundle ID ${BUNDLE_IDENTIFIER} with Apple App ID Prefix ${EXPLICIT_PREFIX}.`, + ]); + }); + + test("asks a human for a missing App ID Prefix before asking for remote consent", async () => { + const promptOrder: string[] = []; + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: [LOCAL_PREFIX], + }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + yes: false, + }), + { + api, + prompts: prompts({ + appIdPrefix: async (_bundleIdentifier, suggested) => { + promptOrder.push("prefix"); + expect(suggested).toEqual({ + source: "partial-literal-entitlements", + value: LOCAL_PREFIX, + }); + return LOCAL_PREFIX; + }, + confirmChanges: async () => { + promptOrder.push("remote consent"); + return true; + }, + }), + }, + ); + + expect(result.status).toBe("ready"); + expect(result.appIdPrefix).toBe(LOCAL_PREFIX); + expect(promptOrder).toEqual(["prefix", "remote consent"]); + }); + + test("offers the unanimous Xcode Development Team but adopts only the human's choice", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + }), + { + api, + prompts: prompts({ + appIdPrefix: async (bundleIdentifier, suggested) => { + expect(bundleIdentifier).toBe(BUNDLE_IDENTIFIER); + expect(suggested).toEqual({ + source: "xcode-development-team", + value: "ABCDE12345", + }); + return EXPLICIT_PREFIX; + }, + }), + }, + ); + + expect(result).toMatchObject({ + status: "ready", + appIdPrefix: EXPLICIT_PREFIX, + registration: "required", + }); + }); + + test("requires an explicit prefix in agent mode instead of prompting", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + await expect( + prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + agent: true, + }), + { api, prompts: prompts() }, + ), + ).rejects.toThrow("requires --app-id-prefix"); + }); + + test("blocks an explicit prefix that conflicts with a partial local candidate", () => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: [LOCAL_PREFIX], + }), + requestedAppIdPrefix: EXPLICIT_PREFIX, + nativeSettings: nativeSettings(false), + registrations: [], + }); + + expect(result.status).toBe("blocked"); + expect(result.blockers).toContainEqual( + expect.objectContaining({ code: "app-id-prefix-conflict" }), + ); + }); + + test("adopts the sole existing registration prefix when local evidence is absent", () => { + const existing = registration(EXPLICIT_PREFIX); + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ appIdPrefix: null }), + nativeSettings: nativeSettings(false), + registrations: [existing], + }); + + expect(result).toMatchObject({ + status: "ready", + appIdPrefix: EXPLICIT_PREFIX, + nativeApi: "required", + registration: "satisfied", + blockers: [], + }); + }); + + test.each([ + { + name: "duplicate prefixes for one Bundle ID", + target: selectedTarget({ appIdPrefix: null }), + registrations: [registration(LOCAL_PREFIX), registration(EXPLICIT_PREFIX)], + blocker: "duplicate-bundle-registration", + }, + { + name: "an existing prefix that conflicts with the selected prefix", + target: selectedTarget(), + registrations: [registration(EXPLICIT_PREFIX)], + blocker: "app-id-prefix-conflict", + }, + ])("blocks $name", ({ target, registrations, blocker }) => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target, + nativeSettings: nativeSettings(false), + registrations: [...registrations], + }); + + expect(result.status).toBe("blocked"); + expect(result.blockers).toContainEqual(expect.objectContaining({ code: blocker })); + }); + + test("requires separate consent for the remote mutations", async () => { + let consentCalls = 0; + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + await expect( + prepareIOSNativeRemoteSetup(prepareOptions({ yes: false }), { + api, + prompts: prompts({ + confirmChanges: async () => { + consentCalls += 1; + return false; + }, + }), + }), + ).rejects.toBeInstanceOf(UserAbortError); + + expect(consentCalls).toBe(1); + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(captured.err).toContain("remote Clerk changes"); + }); + + test("re-reads before writing and permits the approved action set to shrink", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + // Native API was enabled by another actor after consent. + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api); + + expect(calls).toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("blocks before writing when the pre-write re-read expands the approved action set", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + await expect( + applyIOSNativeRemoteSetup(plan({ nativeApi: "satisfied", registration: "required" }), api), + ).rejects.toThrow(); + + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("creates the iOS registration before enabling Native API", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api); + + expect(calls.indexOf("POST iOS registration")).toBeGreaterThan(-1); + expect(calls.indexOf("POST iOS registration")).toBeLessThan( + calls.indexOf("PATCH native settings"), + ); + }); + + test("reconciles an ambiguous registration-create error when the exact row now exists", async () => { + const exactRegistration = registration(); + const ambiguousError = new Error("connection reset after create"); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration], [exactRegistration]], + create: async () => { + throw ambiguousError; + }, + }); + + await expect( + applyIOSNativeRemoteSetup(plan({ nativeApi: "satisfied", registration: "required" }), api), + ).resolves.toBeUndefined(); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("reconciles an ambiguous Native API error when a re-read shows it enabled", async () => { + const exactRegistration = registration(); + const ambiguousError = new Error("connection reset after enable"); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true), nativeSettings(true)], + registrationReads: [[exactRegistration], [exactRegistration]], + enable: async () => { + throw ambiguousError; + }, + }); + + await expect( + applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "satisfied" }), api), + ).resolves.toBeUndefined(); + expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); + }); + + test("fails final verification when the approved remote postcondition is not present", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[], []], + }); + + await expect( + applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("did not pass the final verification"), + }); + }); + + test("does not expose credential or publishable-key material in plans, output, or errors", async () => { + const sensitivePublishableKey = "pk_test_PUBLISHABLE_KEY_MUST_NOT_ESCAPE"; + const sensitiveBearer = "Bearer ak_API_TOKEN_MUST_NOT_ESCAPE"; + const settingsWithUnexpectedSecret = { + ...nativeSettings(false), + publishable_key: sensitivePublishableKey, + } as NativeSettings; + const { api: prepareAPI } = scriptedAPI({ + nativeReads: [settingsWithUnexpectedSecret], + registrationReads: [[]], + }); + + const prepared = await prepareIOSNativeRemoteSetup(prepareOptions(), { + api: prepareAPI, + prompts: prompts(), + }); + expect(JSON.stringify(prepared)).not.toContain(sensitivePublishableKey); + expect(captured.err).not.toContain(sensitivePublishableKey); + + captured.clear(); + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], []], + create: async () => { + throw new Error(`request failed with ${sensitiveBearer}`); + }, + }); + + let thrown: unknown; + try { + await applyIOSNativeRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + ); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeDefined(); + expect(String(thrown)).not.toContain(sensitiveBearer); + expect(JSON.stringify(thrown)).not.toContain(sensitiveBearer); + expect(captured.err).not.toContain(sensitiveBearer); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-remote.ts b/packages/cli-core/src/commands/init/ios/native-remote.ts new file mode 100644 index 000000000..0a9d51f39 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -0,0 +1,618 @@ +import { randomUUID } from "node:crypto"; +import { dim, yellow } from "../../../lib/color.ts"; +import { + ApiError, + CliError, + ERROR_CODE, + type ErrorCode, + errorMessage, + throwUsageError, + throwUserAbort, +} from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { select } from "../../../lib/listage.ts"; +import { + createIOSApplication, + enableNativeApi, + getNativeSettings, + listIOSApplications, + type IOSApplication, + type NativeSettings, +} from "../../../lib/plapi.ts"; +import { confirm, text } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; +import type { + IOSNativeReadinessTarget, + IOSUnverifiedAppIdPrefixSuggestion, +} from "./native-readiness.ts"; + +const APP_ID_PREFIX_MAX_LENGTH = 255; + +function iosRemoteError( + message: string, + code: ErrorCode = ERROR_CODE.IOS_REMOTE_APPLY_FAILED, +): CliError { + return new CliError(message, { code }); +} + +function rethrowKnownRemoteError(error: unknown): void { + if (error instanceof CliError || error instanceof ApiError) throw error; +} + +export type IOSNativeRemoteBlockerCode = + | "target-not-selected" + | "bundle-identifier-unavailable" + | "app-id-prefix-required" + | "app-id-prefix-conflict" + | "duplicate-bundle-registration"; + +export interface IOSNativeRemoteBlocker { + code: IOSNativeRemoteBlockerCode; + message: string; +} + +export type IOSNativeRemotePlan = { + schemaVersion: 1; + kind: "clerk-ios-native-remote-setup"; + status: "ready" | "satisfied" | "blocked"; + applicationId: string; + instanceId: string; + bundleIdentifier?: string; + appIdPrefix?: string; + nativeApi: "required" | "satisfied"; + registration: "required" | "satisfied" | "blocked"; + actions: string[]; + blockers: IOSNativeRemoteBlocker[]; +}; + +export interface IOSNativeRemoteAPI { + getNativeSettings(applicationId: string, instanceId: string): Promise; + enableNativeApi( + applicationId: string, + instanceId: string, + options: { idempotencyKey: string }, + ): Promise; + listIOSApplications(applicationId: string, instanceId: string): Promise; + createIOSApplication( + applicationId: string, + instanceId: string, + params: { appIdPrefix: string; bundleId: string }, + options: { idempotencyKey: string }, + ): Promise; +} + +const defaultAPI: IOSNativeRemoteAPI = { + getNativeSettings, + enableNativeApi, + listIOSApplications, + createIOSApplication, +}; + +export interface PrepareIOSNativeRemoteSetupOptions { + applicationId: string; + instanceId: string; + target: IOSNativeReadinessTarget; + appIdPrefix?: string; + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; + agent: boolean; + yes: boolean; +} + +export type IOSNativeRemoteAppIdPrefixSuggestion = + | IOSUnverifiedAppIdPrefixSuggestion + | { source: "partial-literal-entitlements"; value: string }; + +export interface IOSNativeRemotePrompts { + appIdPrefix( + bundleIdentifier: string, + suggested?: IOSNativeRemoteAppIdPrefixSuggestion, + ): Promise; + confirmChanges(): Promise; +} + +const defaultPrompts: IOSNativeRemotePrompts = { + appIdPrefix: async (bundleIdentifier, suggested) => { + if (suggested?.source === "xcode-development-team") { + const choice = await select({ + message: `Apple App ID Prefix for ${bundleIdentifier}`, + choices: [ + { + name: `Use ${suggested.value}`, + value: "use-suggested" as const, + description: + "Suggested from Xcode DEVELOPMENT_TEAM; usually matches, but legacy Apple accounts can differ.", + }, + { + name: "Enter a different App ID Prefix", + value: "enter-different" as const, + }, + ], + default: "use-suggested" as const, + }); + if (choice === "use-suggested") return suggested.value; + } + + return text({ + message: `Apple App ID Prefix for ${bundleIdentifier}`, + default: suggested?.source === "partial-literal-entitlements" ? suggested.value : undefined, + placeholder: suggested?.value ?? "ABCDE12345", + validate: (value) => + validateAppIdPrefix(value) ?? + `Enter an App ID Prefix between 1 and ${APP_ID_PREFIX_MAX_LENGTH} characters. Verify it in Apple Developer; it can differ from your Team ID.`, + }); + }, + confirmChanges: async () => + confirm({ message: "Apply these remote Clerk Native Application changes?", default: false }), +}; + +function blocker(code: IOSNativeRemoteBlockerCode, message: string): IOSNativeRemoteBlocker { + return { code, message }; +} + +export function validateAppIdPrefix(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized && normalized.length <= APP_ID_PREFIX_MAX_LENGTH ? normalized : undefined; +} + +function localIdentity(target: IOSNativeReadinessTarget): { + bundleIdentifier?: string; + appIdPrefix?: string; + appIdPrefixCandidates: string[]; + blockers: IOSNativeRemoteBlocker[]; +} { + if (target.status !== "selected") { + return { + appIdPrefixCandidates: [], + blockers: [ + blocker( + "target-not-selected", + "Select exactly one iOS application target before registering it with Clerk.", + ), + ], + }; + } + + if (target.bundleIdentifier.status !== "resolved") { + return { + appIdPrefixCandidates: [], + blockers: [ + blocker( + "bundle-identifier-unavailable", + "Resolve one Bundle ID across every selected-target build configuration before registering the iOS app with Clerk.", + ), + ], + }; + } + + const appIdPrefixCandidates = + target.appIdPrefix.status === "resolved" + ? [target.appIdPrefix.value] + : target.appIdPrefix.status === "conflicting" + ? target.appIdPrefix.candidates + : (target.appIdPrefix.candidates ?? []); + const blockers: IOSNativeRemoteBlocker[] = []; + if (target.appIdPrefix.status === "conflicting") { + blockers.push( + blocker( + "app-id-prefix-conflict", + "The selected target contains conflicting literal App ID Prefix evidence across its build configurations.", + ), + ); + } + + return { + bundleIdentifier: target.bundleIdentifier.value, + appIdPrefix: target.appIdPrefix.status === "resolved" ? target.appIdPrefix.value : undefined, + appIdPrefixCandidates, + blockers, + }; +} + +export function buildIOSNativeRemotePlan(options: { + applicationId: string; + instanceId: string; + target: IOSNativeReadinessTarget; + requestedAppIdPrefix?: string; + nativeSettings: NativeSettings; + registrations: IOSApplication[]; +}): IOSNativeRemotePlan { + const identity = localIdentity(options.target); + const blockers = [...identity.blockers]; + const bundleIdentifier = identity.bundleIdentifier; + const explicitPrefix = validateAppIdPrefix(options.requestedAppIdPrefix); + if (options.requestedAppIdPrefix != null && !explicitPrefix) { + blockers.push( + blocker( + "app-id-prefix-required", + `The Apple App ID Prefix must contain between 1 and ${APP_ID_PREFIX_MAX_LENGTH} characters after trimming.`, + ), + ); + } + if ( + explicitPrefix && + identity.appIdPrefixCandidates.some((candidate) => candidate !== explicitPrefix) + ) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `The supplied App ID Prefix does not match the literal prefix proven for ${bundleIdentifier ?? "the selected target"}.`, + ), + ); + } + + const matchingBundle = bundleIdentifier + ? options.registrations.filter((registration) => registration.bundle_id === bundleIdentifier) + : []; + const registeredPrefixes = [...new Set(matchingBundle.map((item) => item.app_id_prefix))].sort(); + const selectedPrefix = explicitPrefix ?? identity.appIdPrefix; + let appIdPrefix = selectedPrefix; + let registration: IOSNativeRemotePlan["registration"] = "blocked"; + + if (bundleIdentifier) { + if (selectedPrefix) { + const conflicts = registeredPrefixes.filter((prefix) => prefix !== selectedPrefix); + if (conflicts.length > 0) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `${bundleIdentifier} is already registered with a different App ID Prefix. Review the Native Applications page; clerk init will not replace it.`, + ), + ); + } else { + registration = registeredPrefixes.includes(selectedPrefix) ? "satisfied" : "required"; + } + } else if (registeredPrefixes.length === 1) { + appIdPrefix = registeredPrefixes[0]; + if (identity.appIdPrefixCandidates.some((candidate) => candidate !== appIdPrefix)) { + blockers.push( + blocker( + "app-id-prefix-conflict", + `The existing Clerk registration for ${bundleIdentifier} conflicts with literal App ID Prefix evidence in the selected target.`, + ), + ); + } else { + registration = "satisfied"; + } + } else if (registeredPrefixes.length > 1) { + blockers.push( + blocker( + "duplicate-bundle-registration", + `${bundleIdentifier} has more than one App ID Prefix registration. Review the Native Applications page before continuing.`, + ), + ); + } else { + blockers.push( + blocker( + "app-id-prefix-required", + `An Apple App ID Prefix is required to register ${bundleIdentifier}.`, + ), + ); + } + } + + const nativeApi = options.nativeSettings.api_enabled ? "satisfied" : "required"; + const actions: string[] = []; + if (registration === "required" && appIdPrefix && bundleIdentifier) { + actions.push( + `Register iOS Bundle ID ${bundleIdentifier} with Apple App ID Prefix ${appIdPrefix}.`, + ); + } + if (nativeApi === "required") { + actions.push("Enable the Native API for the linked development instance."); + } + + const status = + blockers.length > 0 + ? "blocked" + : nativeApi === "satisfied" && registration === "satisfied" + ? "satisfied" + : "ready"; + return { + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status, + applicationId: options.applicationId, + instanceId: options.instanceId, + bundleIdentifier, + appIdPrefix, + nativeApi, + registration, + actions, + blockers, + }; +} + +async function readRemoteState( + applicationId: string, + instanceId: string, + api: IOSNativeRemoteAPI, +): Promise<{ nativeSettings: NativeSettings; registrations: IOSApplication[] }> { + const [nativeSettings, registrations] = await Promise.all([ + api.getNativeSettings(applicationId, instanceId), + api.listIOSApplications(applicationId, instanceId), + ]); + return { nativeSettings, registrations }; +} + +function formatBlockers(plan: IOSNativeRemotePlan): string { + return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); +} + +export async function prepareIOSNativeRemoteSetup( + options: PrepareIOSNativeRemoteSetupOptions, + dependencies: { + api?: IOSNativeRemoteAPI; + prompts?: IOSNativeRemotePrompts; + } = {}, +): Promise { + const api = dependencies.api ?? defaultAPI; + const prompts = dependencies.prompts ?? defaultPrompts; + let state: Awaited>; + try { + state = await withSpinner("Auditing Clerk Native Application settings...", async () => + readRemoteState(options.applicationId, options.instanceId, api), + ); + } catch (error) { + log.debug(`Could not inspect Clerk Native Application settings: ${errorMessage(error)}`); + rethrowKnownRemoteError(error); + throw iosRemoteError( + "Clerk Native Application settings could not be inspected. No local or remote setup changes were written; verify your application access and rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + 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 && + plan.blockers[0]?.code === "app-id-prefix-required" && + options.appIdPrefix == null && + plan.bundleIdentifier != null; + if (onlyMissingPrefix) { + if (options.agent) { + throwUsageError( + `Registering ${plan.bundleIdentifier} in agent mode requires --app-id-prefix . Verify the App ID Prefix in Apple Developer, then rerun. No local or remote setup changes were written.`, + ); + } + const literalSuggestion = + options.target.status === "selected" && options.target.appIdPrefix.status === "missing" + ? options.target.appIdPrefix.candidates?.length === 1 + ? { + source: "partial-literal-entitlements" as const, + value: options.target.appIdPrefix.candidates[0]!, + } + : undefined + : undefined; + const appIdPrefix = await prompts.appIdPrefix( + plan.bundleIdentifier!, + literalSuggestion ?? options.unverifiedAppIdPrefixSuggestion, + ); + plan = buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + target: options.target, + requestedAppIdPrefix: appIdPrefix, + ...state, + }); + } + + if (plan.status === "blocked") { + throw iosRemoteError( + `Clerk Native Application readiness could not be completed safely. No local or remote setup changes were written:\n${formatBlockers(plan)}\n Review https://dashboard.clerk.com/~/native-applications`, + ERROR_CODE.IOS_SETUP_BLOCKED, + ); + } + + if (plan.status === "satisfied") { + log.info(dim("Clerk Native API and iOS application registration are already configured.")); + return plan; + } + + log.info("\nclerk init will make the following remote Clerk changes:\n"); + for (const action of plan.actions) log.info(` ${yellow("REMOTE")} ${action}`); + log.info( + dim( + "\n Remote changes are additive. clerk init will not update or delete an existing iOS registration.", + ), + ); + log.blank(); + + if (options.agent && !options.yes) { + throwUsageError( + "Changing Clerk Native Application settings in agent mode requires explicit consent. Rerun with --yes after reviewing the plan.", + ); + } + if (!options.yes && !(await prompts.confirmChanges())) throwUserAbort(); + return plan; +} + +async function reconciledPlan( + plan: IOSNativeRemotePlan, + api: IOSNativeRemoteAPI, +): Promise { + const state = await readRemoteState(plan.applicationId, plan.instanceId, api); + return buildIOSNativeRemotePlan({ + applicationId: plan.applicationId, + instanceId: plan.instanceId, + target: { + status: "selected", + projectPath: "", + targetId: "", + targetName: "", + bundleIdentifier: { status: "resolved", value: plan.bundleIdentifier! }, + appIdPrefix: plan.appIdPrefix + ? { status: "resolved", source: "literal-entitlements", value: plan.appIdPrefix } + : { status: "missing", source: "literal-entitlements", candidates: [] }, + }, + requestedAppIdPrefix: plan.appIdPrefix, + ...state, + }); +} + +function revalidatedActionSetIsAuthorized( + approved: IOSNativeRemotePlan, + current: IOSNativeRemotePlan, +): boolean { + if ( + current.status === "blocked" || + current.applicationId !== approved.applicationId || + current.instanceId !== approved.instanceId || + current.bundleIdentifier !== approved.bundleIdentifier || + current.appIdPrefix !== approved.appIdPrefix + ) { + return false; + } + // Concurrent completion is harmless. A newly-required action was never + // shown in the approved preview and must force a fresh plan instead. + if (approved.nativeApi === "satisfied" && current.nativeApi !== "satisfied") return false; + if (approved.registration === "satisfied" && current.registration !== "satisfied") { + return false; + } + return true; +} + +export async function applyIOSNativeRemoteSetup( + plan: IOSNativeRemotePlan, + api: IOSNativeRemoteAPI = defaultAPI, +): Promise { + if (plan.status === "blocked" || !plan.bundleIdentifier || !plan.appIdPrefix) { + throw iosRemoteError( + "The approved Clerk Native Application plan is incomplete. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + + let currentPlan: IOSNativeRemotePlan; + try { + currentPlan = await withSpinner("Rechecking Clerk Native Application settings...", async () => + reconciledPlan(plan, api), + ); + } catch (error) { + log.debug(`Could not recheck Clerk Native Application settings: ${errorMessage(error)}`); + rethrowKnownRemoteError(error); + throw iosRemoteError( + "Clerk Native Application settings could not be rechecked after the local setup. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + if (!revalidatedActionSetIsAuthorized(plan, currentPlan)) { + throw iosRemoteError( + "Clerk Native Application settings changed after the approved preview. No remote changes were made; rerun clerk init to review the new plan.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } + + const registrationIdempotencyKey = `clerk-init-ios-registration-${randomUUID()}`; + const nativeAPIIdempotencyKey = `clerk-init-ios-native-api-${randomUUID()}`; + + // Register first so Native API is never enabled by this command without a + // matching iOS application registration already present. + if (currentPlan.registration === "required") { + try { + const created = await withSpinner("Registering the iOS application with Clerk...", async () => + api.createIOSApplication( + plan.applicationId, + plan.instanceId, + { appIdPrefix: plan.appIdPrefix!, bundleId: plan.bundleIdentifier! }, + { idempotencyKey: registrationIdempotencyKey }, + ), + ); + if ( + created.bundle_id !== plan.bundleIdentifier || + created.app_id_prefix !== plan.appIdPrefix + ) { + throw iosRemoteError( + "Clerk returned an unexpected iOS application registration. The local setup remains intact; rerun clerk init to reconcile remote state.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + } catch (error) { + log.debug(`Could not create the iOS application registration: ${errorMessage(error)}`); + let registrations: IOSApplication[]; + try { + registrations = await api.listIOSApplications(plan.applicationId, plan.instanceId); + } catch (fallbackError) { + log.debug( + `Could not confirm the iOS application registration: ${errorMessage(fallbackError)}`, + ); + rethrowKnownRemoteError(fallbackError); + throw iosRemoteError( + "The iOS application registration could not be confirmed. The local setup remains intact; rerun clerk init to reconcile remote state.", + ); + } + const exact = registrations.some( + (registration) => + registration.bundle_id === plan.bundleIdentifier && + registration.app_id_prefix === plan.appIdPrefix, + ); + if (!exact) { + rethrowKnownRemoteError(error); + throw iosRemoteError( + "The iOS application could not be registered with Clerk. The local setup remains intact; rerun clerk init to retry safely.", + ); + } + } + log.success(`iOS application ${plan.bundleIdentifier} registered with Clerk`); + } + + if (currentPlan.nativeApi === "required") { + try { + const enabled = await withSpinner("Enabling the Clerk Native API...", async () => + api.enableNativeApi(plan.applicationId, plan.instanceId, { + idempotencyKey: nativeAPIIdempotencyKey, + }), + ); + if (!enabled.api_enabled) { + throw iosRemoteError( + "Clerk did not report the Native API as enabled. The local setup and any completed registration remain intact; rerun clerk init.", + ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + ); + } + } catch (error) { + log.debug(`Could not enable the Clerk Native API: ${errorMessage(error)}`); + let current: NativeSettings; + try { + current = await api.getNativeSettings(plan.applicationId, plan.instanceId); + } catch (fallbackError) { + log.debug(`Could not confirm Clerk Native API state: ${errorMessage(fallbackError)}`); + rethrowKnownRemoteError(fallbackError); + throw iosRemoteError( + "Native API enablement could not be confirmed. The local setup and any completed iOS registration remain intact; rerun clerk init.", + ); + } + if (!current.api_enabled) { + rethrowKnownRemoteError(error); + throw iosRemoteError( + "The Native API could not be enabled. The local setup and any completed iOS registration remain intact; rerun clerk init to retry safely.", + ); + } + } + log.success("Clerk Native API enabled for the development instance"); + } + + let finalPlan: IOSNativeRemotePlan; + try { + finalPlan = await withSpinner("Verifying Clerk Native Application settings...", async () => + reconciledPlan(plan, api), + ); + } catch (error) { + log.debug(`Could not verify Clerk Native Application settings: ${errorMessage(error)}`); + rethrowKnownRemoteError(error); + throw iosRemoteError( + "Clerk Native Application settings could not be verified. The local setup and any completed remote changes remain intact; rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + if (finalPlan.status !== "satisfied" || !revalidatedActionSetIsAuthorized(plan, finalPlan)) { + throw iosRemoteError( + "Clerk Native Application settings did not pass the final verification. The local iOS setup remains intact; rerun clerk init to reconcile the additive remote steps.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } +} diff --git a/packages/cli-core/src/commands/init/ios/output.ts b/packages/cli-core/src/commands/init/ios/output.ts new file mode 100644 index 000000000..a6247d03e --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/output.ts @@ -0,0 +1,131 @@ +import type { IOSProjectInspectionResult, IOSSetupPlan, IOSSetupStepStatus } from "./types.ts"; +import { buildIOSNativeReadinessAudit, type IOSNativeReadinessAudit } from "./native-readiness.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; + +const STATUS_MARKER: Record = { + satisfied: "✓", + required: "○", + review: "!", + blocked: "×", +}; + +export interface IOSDryRunOutput { + schemaVersion: 1; + mode: "read-only"; + status: IOSSetupPlan["status"]; + inspection: IOSProjectInspectionResult; + plan: IOSSetupPlan; + nativeReadiness: IOSNativeReadinessAudit; +} + +export interface IOSOutputOptions { + associatedDomainPlan?: IOSAssociatedDomainPlan; +} + +export function createIOSDryRunOutput( + inspection: IOSProjectInspectionResult, + plan: IOSSetupPlan, + options: IOSOutputOptions = {}, +): IOSDryRunOutput { + return { + schemaVersion: 1, + mode: "read-only", + status: plan.status, + inspection, + plan, + nativeReadiness: buildIOSNativeReadinessAudit(inspection, options), + }; +} + +export function formatIOSSetupPlan( + inspection: IOSProjectInspectionResult, + plan: IOSSetupPlan, + options: IOSOutputOptions = {}, +): string { + const lines = ["", "iOS setup plan (read-only)", ` Root: ${inspection.root}`]; + + if (inspection.selection.state === "selected") { + lines.push( + ` Target: ${inspection.selection.targetName} (${inspection.selection.projectPath})`, + ); + } else if (inspection.selection.state === "ambiguous") { + lines.push(" Targets:"); + for (const candidate of inspection.selection.candidates) { + lines.push( + ` - ${candidate.targetName} [${candidate.targetId}] in ${candidate.projectPath}`, + ); + } + } + + const selection = inspection.selection; + const selected = + selection.state === "selected" + ? inspection.appTargets.find( + (target) => + target.id === selection.targetId && target.projectPath === selection.projectPath, + ) + : undefined; + if (selected) { + const bundles = [ + ...new Set( + selected.configurations.flatMap((configuration) => + configuration.bundleIdentifier.state === "resolved" + ? [configuration.bundleIdentifier.value] + : [], + ), + ), + ]; + if (bundles.length > 0) lines.push(` Bundle ID: ${bundles.join(", ")}`); + lines.push( + ` ClerkKit: ${selected.packages.clerkKit}; ClerkKitUI: ${selected.packages.clerkKitUI}`, + ); + } + if (inspection.localPublishableKey.frontendApiHost) { + lines.push( + ` Publishable key: found (${inspection.localPublishableKey.instanceType}; ${inspection.localPublishableKey.frontendApiHost})`, + ); + } else { + const keyStatus = inspection.localPublishableKey.conflict + ? "conflicting local sources" + : inspection.localPublishableKey.candidateSources.length > 0 + ? "found but invalid" + : "not found"; + lines.push(` Publishable key: ${keyStatus}`); + } + + lines.push(""); + for (const item of plan.steps) { + lines.push(` ${STATUS_MARKER[item.status]} [${item.status}] ${item.title}`); + lines.push(` ${item.description}`); + if (item.automatable) lines.push(" `clerk init` can apply this step."); + for (const link of item.links ?? []) lines.push(` ${link.url}`); + } + + if (plan.diagnostics.length > 0) { + lines.push("", " Diagnostics:"); + for (const diagnostic of plan.diagnostics) { + lines.push(` - [${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`); + if (diagnostic.remedy) lines.push(` ${diagnostic.remedy}`); + } + } + + const nativeReadiness = buildIOSNativeReadinessAudit(inspection, options); + lines.push("", " Native iOS readiness:"); + lines.push( + ` - Associated Domains: ${nativeReadiness.associatedDomain.status}${nativeReadiness.associatedDomain.automatable ? " (clerk init can apply)" : ""}`, + ); + if (!nativeReadiness.associatedDomain.automatable) { + for (const blocker of nativeReadiness.associatedDomain.blockers) { + lines.push(` ${blocker.message}`); + } + } + lines.push( + " - Native API and Dashboard iOS registration: not inspected during this local-only dry-run. Regular `clerk init` audits and safely reconciles both on the linked development instance after authentication.", + ); + + lines.push( + "", + " No files, Xcode settings, Clerk applications, or remote resources were changed.", + ); + return lines.join("\n"); +} diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts new file mode 100644 index 000000000..82b67d4fe --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -0,0 +1,806 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { planIOSDirectConfig } from "./direct-config.ts"; +import { planIOSAssociatedDomain } from "./associated-domain.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { formatIOSSetupPlan } from "./output.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { planIOSRuntimeKey } from "./runtime-key.ts"; +import { createIOSFixture } from "./test-helpers.ts"; + +const temporaryDirectories: string[] = []; + +async function planFor(options: Parameters[1] = {}) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, options); + const inspection = await inspectIOSProject(root); + return buildIOSSetupPlan(inspection); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("buildIOSSetupPlan", () => { + test("returns stable ordered steps without treating a project env key as runtime wiring", async () => { + const plan = await planFor({ complete: true }); + + expect(plan.steps.map((step) => step.id)).toEqual([ + "select-target", + "install-clerk-sdk", + "configure-publishable-key", + "inject-clerk-environment", + "wire-auth-callbacks", + "register-native-application", + "add-associated-domain", + "add-authentication-flow", + "verify-integration", + ]); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + expect(plan.steps.filter((step) => step.automatable)).toEqual([]); + const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); + expect(configureStep?.status).toBe("review"); + expect(configureStep?.description).toContain("available to copy"); + const domainStep = plan.steps.find((step) => step.id === "add-associated-domain"); + expect(domainStep?.status).toBe("review"); + expect(domainStep?.description).toContain("not proven to be the selected target's runtime key"); + expect(plan.steps.find((step) => step.id === "register-native-application")?.status).toBe( + "review", + ); + expect(JSON.stringify(plan)).not.toContain("CLERK_PUBLISHABLE_KEY="); + }); + + test("satisfies configuration when a target LocalSecrets key has recognized loader wiring", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.configureCalls).toEqual([ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "local-secrets-loader", + startupBinding: "app-init", + localSecretsRuntimeBinding: "proven", + }, + ]); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + }); + + test("satisfies configuration and derives the domain from a redacted inline literal", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { includeKey: false }); + const publishableKey = `pk_test_${Buffer.from("inline.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "${publishableKey}") } + var body: some Scene { + WindowGroup { Text("Hello").environment(Clerk.shared) } + } +} +`, + ); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan.changes?.configuration).toBe("verify-existing"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "add-associated-domain")?.description).toContain( + "webcredentials:inline.clerk.example", + ); + expect(JSON.stringify(plan)).not.toContain(publishableKey); + }); + + test("marks safe fresh direct configuration and environment injection as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan).toMatchObject({ + status: "ready", + changes: { + clerkKitImport: "insert", + configuration: "insert-initializer", + environment: "insert", + }, + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "required", + automatable: true, + }); + expect( + plan.steps.find((step) => step.id === "configure-publishable-key")?.description, + ).toContain("directly"); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(JSON.stringify(plan)).not.toContain("pk_test_"); + }); + + test("advertises a proven prebuilt AuthView scaffold without selecting it", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "--prebuilt-auth-ui", + ); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ + status: "review", + automatable: false, + }); + }); + + test("uses the documented AuthView sheet without generating app-level callback code", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-selected-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "required", + automatable: true, + }); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")?.description).toContain( + "does not need generated app-level callback code", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "network-free local plan", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "only if Apple is enabled", + ); + }); + + test("blocks a selected AuthView scaffold when the SDK compatibility proof fails", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-old-prebuilt-sdk-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + const message = "ClerkKitUI's documented native components require clerk-ios 1.0.0 or newer."; + + const plan = buildIOSSetupPlan(inspection, { + sdkInstallPlan: { + status: "blocked", + blockers: [{ code: "incompatible-sdk", message }], + }, + prebuiltAuthPlan: { + status: "ready", + sourcePath: "MyApp/ContentView.swift", + actions: ["Add ClerkKitUI's prebuilt AuthView flow."], + blockers: [], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "blocked", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")?.description).toContain( + message, + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "blocked", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ + status: "review", + automatable: false, + }); + }); + + test("blocks an explicitly requested scaffold over a partial existing auth flow", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-partial-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.authFlowReferences = [{ path: "MyApp/ContentView.swift" }]; + + const plan = buildIOSSetupPlan(inspection, { + prebuiltAuthPlan: { + status: "blocked", + sourcePath: "MyApp/ContentView.swift", + actions: [], + blockers: [ + { + code: "existing-auth-integration", + message: "An existing or partial authentication flow must be reviewed manually.", + }, + ], + }, + prebuiltAuthSelected: true, + }); + + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "blocked", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( + "partial authentication flow", + ); + }); + + test("maps every native Apple entitlement plan state into the ordered setup plan", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-native-apple-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const inspection = await inspectIOSProject(root); + + for (const fixture of [ + { + status: "ready" as const, + actions: ["Add the Apple entitlement."], + blockers: [], + expectedStatus: "required", + automatable: true, + text: "exact Default value", + }, + { + status: "satisfied" as const, + actions: [], + blockers: [], + expectedStatus: "satisfied", + automatable: false, + text: "exact native Sign in with Apple entitlement", + }, + { + status: "blocked" as const, + actions: [], + blockers: [{ code: "unsupported-entitlements" as const, message: "Review this file." }], + expectedStatus: "blocked", + automatable: false, + text: "Review this file.", + }, + ]) { + const plan = buildIOSSetupPlan(inspection, { appleEntitlementPlan: fixture }); + const stepIndex = plan.steps.findIndex((step) => step.id === "enable-native-apple"); + const domainIndex = plan.steps.findIndex((step) => step.id === "add-associated-domain"); + const appleStep = plan.steps[stepIndex]; + + expect(stepIndex).toBeGreaterThan(-1); + expect(stepIndex).toBeLessThan(domainIndex); + expect(appleStep).toMatchObject({ + status: fixture.expectedStatus, + automatable: fixture.automatable, + }); + expect(appleStep?.description).toContain(fixture.text); + } + }); + + test("surfaces strict Associated Domains blockers instead of asking for a local key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: false, + includeKey: false, + releaseEntitlements: false, + }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + const associatedDomainPlan = await planIOSAssociatedDomain({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + deferToPublishableKey: true, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan, associatedDomainPlan }); + const domain = plan.steps.find((step) => step.id === "add-associated-domain"); + + expect(associatedDomainPlan.status).toBe("blocked"); + expect(domain).toMatchObject({ status: "review", automatable: false }); + expect(domain?.description).toContain( + "Some selected-target configurations have entitlements while others do not", + ); + expect(domain?.description).not.toContain("valid local publishable key is needed"); + }); + + test("renders strict direct-config blockers as actionable blocked steps", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + complete: false, + includeKey: false, + generated: "xcodegen", + }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan).toMatchObject({ status: "blocked" }); + expect(directConfigPlan.blockers.map((blocker) => blocker.code)).toContain("generated-project"); + const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); + expect(configureStep).toMatchObject({ status: "blocked", automatable: false }); + expect(configureStep?.description).toContain("XcodeGen"); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "blocked", + automatable: false, + }); + }); + + test("does not satisfy or automate LocalSecrets wiring from a same-file helper", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + const source = await Bun.file(appPath).text(); + await Bun.write( + appPath, + source.replace( + 'init() { Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") }', + `init() {} + func unusedConfigureHelper() { + Clerk.configure(publishableKey: QuickstartLocalSecrets.load().publishableKey ?? "") + }`, + ), + ); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const runtimeKeyPlan = await planIOSRuntimeKey({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { runtimeKeyPlan }); + + expect(inspection.appTargets[0]?.swift.configureCalls[0]).toMatchObject({ + publishableKeyWiring: "local-secrets-loader", + startupBinding: "unproven", + localSecretsRuntimeBinding: "proven", + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "review", + automatable: false, + }); + }); + + test("reviews a target runtime key when the configure expression has unknown wiring", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.configureCalls = [ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "unknown", + startupBinding: "app-init", + }, + ]; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + }); + + test("satisfies configuration for a selected-target Run scheme and ProcessInfo wiring", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + inspection.localPublishableKey.source = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; + inspection.localPublishableKey.candidateSources = [ + "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + ]; + inspection.appTargets[0]!.swift.configureCalls = [ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "process-info-environment", + startupBinding: "app-init", + }, + ]; + + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); + + expect(directConfigPlan.status).toBe("blocked"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + }); + + test("keeps non-runtime key sources as available-to-copy evidence", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + + for (const source of [ + ".env", + ".clerk/.tmp/keyless.json", + "CLERK_PUBLISHABLE_KEY environment variable", + ]) { + inspection.localPublishableKey.source = source; + const step = buildIOSSetupPlan(inspection).steps.find( + (candidate) => candidate.id === "configure-publishable-key", + ); + expect(step?.status).toBe("review"); + expect(step?.description).toContain("available to copy"); + } + }); + + test("reviews a malformed available-only key instead of treating it as runtime failure", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false }); + await Bun.write(join(root, ".env"), "CLERK_PUBLISHABLE_KEY=not-a-key\n"); + const inspection = await inspectIOSProject(root); + + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.localPublishableKey).toMatchObject({ + found: false, + source: ".env", + invalidSources: [".env"], + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + }); + + test("offers to replace a malformed key in a proven selected-target runtime sink", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYnot-a-key', + ); + const inspection = await inspectIOSProject(root); + if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); + const runtimeKeyPlan = await planIOSRuntimeKey({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + const directConfigPlan = await planIOSDirectConfig({ + root, + projectPath: inspection.selection.projectPath, + targetId: inspection.selection.targetId, + }); + + const plan = buildIOSSetupPlan(inspection, { runtimeKeyPlan, directConfigPlan }); + + expect(inspection.localPublishableKey).toMatchObject({ + found: false, + source: "MyApp/LocalSecrets.plist", + invalidSources: ["MyApp/LocalSecrets.plist"], + }); + expect(directConfigPlan.status).toBe("blocked"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "required", + automatable: true, + }); + expect( + plan.steps.find((step) => step.id === "configure-publishable-key")?.description, + ).toContain("LocalSecrets.plist"); + expect( + plan.steps.find((step) => step.id === "configure-publishable-key")?.description, + ).not.toContain("Automatic direct configuration stopped"); + }); + + test("does not automate a name-only LocalSecrets expression without an exact loader binding", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + enum LocalSecrets { static let key = "" } + @main struct MyApp: App { + init() { Clerk.configure(publishableKey: LocalSecrets.key) } + var body: some Scene { WindowGroup { Text("Hello") } } + }`, + ); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYnot-a-key', + ); + + const plan = buildIOSSetupPlan(await inspectIOSProject(root)); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "blocked", + automatable: false, + }); + }); + + test("reports genuinely missing Swift setup as required", async () => { + const plan = await planFor({ complete: false }); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "required", + ); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")?.status).toBe( + "required", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.status).toBe( + "required", + ); + }); + + test("plans ClerkKitUI by default for an untouched target", async () => { + const plan = await planFor({ clerkSDK: false, complete: false, includeKey: false }); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("ClerkKit and ClerkKitUI"); + expect(sdkStep?.description).toContain("prebuilt AuthView"); + }); + + test("plans ClerkKitUI for a source-blank core-only graph from an earlier setup", async () => { + const plan = await planFor({ clerkSDK: "core-only", complete: false, includeKey: false }); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("already has ClerkKit"); + expect(sdkStep?.description).toContain("Link ClerkKitUI"); + }); + + test("plans only ClerkKit when existing source shows custom-flow intent", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, complete: false, includeKey: false }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { WindowGroup { Text("Custom auth") } } +} +`, + ); + + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("custom-flow intent"); + expect(sdkStep?.description).toContain("ClerkKitUI is not required"); + const authStep = plan.steps.find((step) => step.id === "add-authentication-flow"); + expect(authStep?.description).toContain("custom ClerkKit"); + expect(authStep?.description).not.toContain("ClerkKitUI"); + }); + + test("requires ClerkKitUI when selected-target source imports its prebuilt UI", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + const output = formatIOSSetupPlan(inspection, plan); + expect(output).toContain("`clerk init` can apply this step."); + expect(output.match(/`clerk init` can apply this step\./g)).toHaveLength(1); + }); + + test("repairs a declared but unlinked ClerkKitUI product without source imports", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "declared"; + + const plan = buildIOSSetupPlan(inspection); + const sdkStep = plan.steps.find((step) => step.id === "install-clerk-sdk"); + + expect(sdkStep).toMatchObject({ status: "required", automatable: true }); + expect(sdkStep?.description).toContain("declared"); + expect(sdkStep?.description).toContain("not linked"); + }); + + test("does not mark generated-project SDK installation as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, generated: "xcodegen" }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "required", + automatable: false, + }); + }); + + test("does not mark unattributed SDK installation as automatable", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.package = "unattributed"; + inspection.appTargets[0]!.packages.clerkKitUI = "absent"; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "required", + automatable: false, + }); + }); + + test("reviews linked Clerk products when their package reference is unattributed", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.packages.package = "unattributed"; + + const plan = buildIOSSetupPlan(inspection); + const step = plan.steps.find((candidate) => candidate.id === "install-clerk-sdk"); + + expect(step?.status).toBe("review"); + expect(step?.description).toContain("could not be verified as clerk-ios"); + }); + + test("treats missing Swift evidence as review when source membership is incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, clerkSDK: false }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.evidenceComplete = false; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")).toMatchObject({ + status: "review", + automatable: false, + }); + expect(plan.steps.find((step) => step.id === "install-clerk-sdk")?.description).toContain( + "cannot safely choose", + ); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")?.status).toBe( + "review", + ); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.status).toBe("review"); + }); + + test("reviews an existing configure call when no usable local key can be validated", async () => { + const plan = await planFor({ complete: true, includeKey: false }); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "review", + ); + }); + + test("requires the bare domain when only Apple's developer-mode suffix is present", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = [ + "webcredentials:native.clerk.example?mode=developer", + ]; + } + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "add-associated-domain")?.status).toBe("required"); + }); + + test("blocks all dependent steps when target selection is ambiguous", async () => { + const plan = await planFor({ secondTarget: true }); + + expect(plan.steps[0]?.status).toBe("blocked"); + expect(plan.steps.slice(1).every((step) => step.status === "blocked")).toBe(true); + }); + + test("includes usable choices when the requested target is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { secondTarget: true }); + const inspection = await inspectIOSProject(root, { target: "MissingApp" }); + + const plan = buildIOSSetupPlan(inspection); + + const selectStep = plan.steps.find((step) => step.id === "select-target"); + expect(selectStep?.status).toBe("blocked"); + expect(selectStep?.description).toContain("AdminApp"); + expect(selectStep?.description).toContain("MyApp"); + }); + + test("is deterministic for identical inspection input", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + + expect(buildIOSSetupPlan(inspection)).toEqual(buildIOSSetupPlan(inspection)); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts new file mode 100644 index 000000000..a6d55b872 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -0,0 +1,644 @@ +import type { + IOSAppTarget, + IOSProjectInspectionResult, + IOSSetupPlan, + IOSSetupStep, + IOSSetupStepStatus, + IOSSourceEvidence, + IOSValueResolution, +} from "./types.ts"; +import { hasIOSDirectConfigCompatibility } from "./products.ts"; +import { clerkKitUIInstallDecision } from "./products.ts"; +import type { IOSDirectConfigPlan } from "./direct-config.ts"; +import type { IOSRuntimeKeyPlan } from "./runtime-key.ts"; +import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import type { IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; +import type { IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; +import type { IOSSDKInstallPlan } from "./install-sdk.ts"; + +const NATIVE_APPLICATIONS_URL = "https://dashboard.clerk.com/~/native-applications"; +const QUICKSTART_URL = "https://clerk.com/docs/ios/getting-started/quickstart"; +const NATIVE_APPLE_URL = + "https://clerk.com/docs/ios/guides/configure/auth-strategies/sign-in-with-apple"; + +function associatedDomainMatches(actual: string, expected: string): boolean { + return actual.toLowerCase() === expected.toLowerCase(); +} + +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, + ); +} + +function selectedEvidence(target: IOSAppTarget | undefined): IOSSourceEvidence[] { + return target ? [{ path: target.projectPath, objectId: target.id }] : []; +} + +function distinctResolved( + target: IOSAppTarget, + select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, +): string[] { + return [ + ...new Set( + target.configurations + .map(select) + .filter( + (value): value is Extract => + value.state === "resolved", + ) + .map((value) => value.value), + ), + ].sort(); +} + +function allEvidence( + target: IOSAppTarget, + select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, +): IOSSourceEvidence[] { + return target.configurations.flatMap((configuration) => select(configuration).evidence); +} + +function step( + id: IOSSetupStep["id"], + title: string, + status: IOSSetupStepStatus, + description: string, + evidence: IOSSourceEvidence[] = [], + links?: IOSSetupStep["links"], + automatable = false, +): IOSSetupStep { + return { id, title, status, automatable, description, links, evidence }; +} + +function publishableKeyRuntimeSource( + source: string | undefined, + target: IOSAppTarget, +): "inline-literal" | "run-scheme" | "local-secrets" | "available-only" | undefined { + if (!source) return undefined; + if ( + target.swift.configureCalls.some( + (call) => call.path === source && call.publishableKeyWiring === "inline-literal", + ) + ) { + return "inline-literal"; + } + if (source.endsWith(".xcscheme")) return "run-scheme"; + if (target.runtimeKeySinks.some((sink) => sink.path === source)) { + return "local-secrets"; + } + return "available-only"; +} + +export function hasIOSRuntimeKeyHandoffShape( + inspection: IOSProjectInspectionResult, + target: IOSAppTarget, +): boolean { + const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => + source.endsWith(".xcscheme"), + ); + return ( + inspection.generatedProject === null && + target.swift.evidenceComplete && + target.swift.entryPoints.length === 1 && + target.swift.configureCalls.length === 1 && + target.swift.configureCalls[0]?.publishableKeyWiring === "local-secrets-loader" && + target.swift.configureCalls[0]?.localSecretsRuntimeBinding === "proven" && + target.swift.configureCalls[0]?.startupBinding === "app-init" && + target.swift.configureCalls[0]?.path === target.swift.entryPoints[0]?.path && + target.swift.localSecretsRuntimeBindings.length === 1 && + target.runtimeKeySinks.length === 1 && + !hasEnabledSchemeKey + ); +} + +export interface BuildIOSSetupPlanOptions { + /** Strict SDK/package compatibility from the same planner used by apply. */ + sdkInstallPlan?: Pick; + /** Strict, redacted file/Git readiness from the same planner used by apply. */ + runtimeKeyPlan?: Pick; + /** Strict, publishable-key-redacted Swift source readiness from the apply planner. */ + directConfigPlan?: IOSDirectConfigPlan; + /** Strict existing-entitlements readiness from the same planner used by apply. */ + associatedDomainPlan?: Pick< + IOSAssociatedDomainPlan, + | "status" + | "expectedDomain" + | "requiresPublishableKey" + | "blockers" + | "files" + | "missingEntitlementsSettings" + >; + /** Optional native Apple capability requested or already present locally. */ + appleEntitlementPlan?: Pick; + /** Strict source readiness for the optional prebuilt AuthView scaffold. */ + prebuiltAuthPlan?: Pick; + /** Whether this invocation explicitly selected the optional AuthView scaffold. */ + prebuiltAuthSelected?: boolean; +} + +export function buildIOSSetupPlan( + inspection: IOSProjectInspectionResult, + options: BuildIOSSetupPlanOptions = {}, +): IOSSetupPlan { + const target = selectedTarget(inspection); + const targetEvidence = selectedEvidence(target); + const steps: IOSSetupStep[] = []; + + steps.push( + step( + "select-target", + "Select the iOS application target", + target ? "satisfied" : "blocked", + target + ? `Using ${target.name} in ${target.projectPath}.` + : inspection.selection.state === "ambiguous" + ? "More than one iOS app target is eligible. Rerun with --target ; the CLI will not guess." + : inspection.selection.state === "not-found" + ? `The requested target "${inspection.selection.requested}" was not found.${inspection.selection.candidates.length > 0 ? ` Available targets: ${inspection.selection.candidates.join(", ")}.` : ""}` + : "No usable iOS application target was found.", + targetEvidence, + ), + ); + + if (!target) { + const blockedSteps: Array<[IOSSetupStep["id"], string]> = [ + ["install-clerk-sdk", "Install Clerk's iOS SDK"], + ["configure-publishable-key", "Configure Clerk"], + ["inject-clerk-environment", "Inject Clerk into SwiftUI"], + ["wire-auth-callbacks", "Wire authentication callbacks"], + ["register-native-application", "Register the native application"], + ["add-associated-domain", "Add the associated domain"], + ["add-authentication-flow", "Add an authentication flow"], + ["verify-integration", "Verify the integration"], + ]; + for (const [id, title] of blockedSteps) { + steps.push( + step(id, title, "blocked", "Select an iOS application target before planning this step."), + ); + } + return finishPlan(inspection, steps); + } + + const usesClerkKitUI = target.swift.importsClerkKitUI.length > 0; + const productDecision = clerkKitUIInstallDecision(target); + const includeClerkKitUI = + productDecision === "prebuilt" || + options.prebuiltAuthSelected === true || + options.prebuiltAuthPlan?.status === "satisfied"; + const sourceEntryPointIsAmbiguous = target.swift.status === "ambiguous"; + const requiredProductsLinked = + target.packages.clerkKit === "linked" && + (!includeClerkKitUI || target.packages.clerkKitUI === "linked"); + const packageIsVerified = + target.packages.package === "remote" || target.packages.package === "local"; + const strictSDKBlocked = options.sdkInstallPlan?.status === "blocked"; + const strictSDKBlocker = strictSDKBlocked + ? options.sdkInstallPlan?.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const sdkStatus: IOSSetupStepStatus = strictSDKBlocked + ? "blocked" + : productDecision === "unknown" + ? "review" + : !requiredProductsLinked + ? "required" + : packageIsVerified + ? "satisfied" + : target.packages.package === "unattributed" + ? "review" + : "required"; + const sdkAutomatable = + sdkStatus === "required" && + inspection.generatedProject === null && + target.packages.package !== "unattributed"; + steps.push( + step( + "install-clerk-sdk", + "Install Clerk's iOS SDK for the selected target", + sdkStatus, + strictSDKBlocked + ? `The selected Clerk iOS SDK cannot support this approved setup safely: ${strictSDKBlocker ?? "Update the clerk-ios package and rerun the plan."}` + : productDecision === "unknown" + ? `Swift source membership for ${target.name} is incomplete, so the CLI cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the source-membership diagnostics or make the product choice manually.` + : sdkStatus === "satisfied" + ? `ClerkKit is linked to ${target.name}${target.packages.clerkKitUI === "linked" ? "; ClerkKitUI is linked too" : ""}.` + : sdkStatus === "review" + ? `ClerkKit${target.packages.clerkKitUI === "linked" ? " and ClerkKitUI are" : " is"} linked to ${target.name}, but the package reference could not be verified as clerk-ios. Confirm the linked products come from Clerk's remote or local package.` + : includeClerkKitUI && target.packages.clerkKitUI !== "linked" + ? usesClerkKitUI + ? `${target.name} imports ClerkKitUI, but that product is not linked to the target. Link both ClerkKit and ClerkKitUI from the clerk-ios Swift package.` + : target.packages.clerkKitUI === "declared" + ? `ClerkKitUI is declared for ${target.name} but not linked in its Frameworks phase. Link it alongside ClerkKit.` + : target.packages.clerkKit !== "absent" + ? `${target.name} already has ClerkKit but no source-proven custom flow. Link ClerkKitUI from the same clerk-ios package so the prebuilt AuthView path is ready by default.` + : `${target.name} has no existing Clerk integration. Link both ClerkKit and ClerkKitUI from the clerk-ios Swift package so the prebuilt AuthView is ready by default.` + : includeClerkKitUI + ? `Add https://github.com/clerk/clerk-ios with Swift Package Manager and link ClerkKit and ClerkKitUI to ${target.name} for the fastest prebuilt AuthView path.` + : `${target.name} already shows core-only or custom-flow intent. Add https://github.com/clerk/clerk-ios with Swift Package Manager and link ClerkKit; ClerkKitUI is not required for that path.`, + targetEvidence, + undefined, + sdkAutomatable, + ), + ); + + const configured = target.swift.configureCalls.length > 0; + const usablePublishableKey = + inspection.localPublishableKey.found && + !inspection.localPublishableKey.conflict && + inspection.localPublishableKey.frontendApiHost != null; + const runtimeKeySource = publishableKeyRuntimeSource( + inspection.localPublishableKey.source, + target, + ); + const publishableKeySourceIsRuntime = + runtimeKeySource === "inline-literal" || + runtimeKeySource === "run-scheme" || + runtimeKeySource === "local-secrets"; + const configureCallConnectedToRuntime = + usablePublishableKey && + runtimeKeySource != null && + runtimeKeySource !== "available-only" && + target.swift.configureCalls.some( + (call) => + call.startupBinding === "app-init" && + (runtimeKeySource === "inline-literal" + ? call.publishableKeyWiring === "inline-literal" && + call.inlinePublishableKey?.state === "valid" + : runtimeKeySource === "local-secrets" + ? call.publishableKeyWiring === "local-secrets-loader" && + call.localSecretsRuntimeBinding === "proven" + : call.publishableKeyWiring === "process-info-environment"), + ); + const publishableKeyBlocked = + publishableKeySourceIsRuntime && + (inspection.localPublishableKey.conflict || + (!inspection.localPublishableKey.found && + inspection.localPublishableKey.invalidSources.length > 0)); + const localSecretsHandoff = hasIOSRuntimeKeyHandoffShape(inspection, target); + const needsLocalSecretsHandoff = localSecretsHandoff && !configureCallConnectedToRuntime; + const hasDirectConfigCompatibility = hasIOSDirectConfigCompatibility(inspection, target); + const directConfigPlanApplies = options.directConfigPlan != null && !hasDirectConfigCompatibility; + const directConfigAutomationReady = + directConfigPlanApplies && + options.directConfigPlan?.status === "ready" && + options.directConfigPlan.changes?.configuration !== "verify-existing"; + const directConfigBlocked = + directConfigPlanApplies && options.directConfigPlan?.status === "blocked"; + const directConfigBlocker = directConfigBlocked + ? options.directConfigPlan?.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const runtimeKeyAutomationReady = + needsLocalSecretsHandoff && options.runtimeKeyPlan?.status === "ready"; + const runtimeKeyBlocker = + needsLocalSecretsHandoff && options.runtimeKeyPlan?.status === "blocked" + ? options.runtimeKeyPlan.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const configuredStatus: IOSSetupStepStatus = needsLocalSecretsHandoff + ? options.runtimeKeyPlan?.status === "blocked" + ? "blocked" + : "required" + : publishableKeyBlocked + ? "blocked" + : directConfigBlocked + ? "blocked" + : configured + ? sourceEntryPointIsAmbiguous || !configureCallConnectedToRuntime + ? "review" + : "satisfied" + : directConfigAutomationReady + ? "required" + : target.swift.evidenceComplete + ? "required" + : "review"; + steps.push( + step( + "configure-publishable-key", + "Configure Clerk with a publishable key", + configuredStatus, + needsLocalSecretsHandoff + ? runtimeKeyAutomationReady + ? `Clerk.configure(publishableKey:) is connected to the selected target's proven LocalSecrets.plist loader, but that runtime source does not contain a usable key. clerk init can fetch the linked development instance's publishable key directly into that plist without printing it or creating an env file.` + : runtimeKeyBlocker + ? `Clerk.configure(publishableKey:) is connected to the selected target's LocalSecrets.plist loader, but automatic key wiring is blocked: ${runtimeKeyBlocker}` + : "Clerk.configure(publishableKey:) is connected to the selected target's LocalSecrets.plist loader, but that source has no usable key. Add the development key manually or run the strict iOS setup preflight before applying it." + : publishableKeyBlocked + ? inspection.localPublishableKey.conflict + ? "Multiple effective publishable-key sources point at different Clerk instances. Resolve the conflict before configuring the app." + : "The effective publishable-key source is malformed. Replace it before relying on Clerk.configure(...)." + : directConfigBlocked + ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's @main App initializer and root Scene manually."}` + : configured + ? sourceEntryPointIsAmbiguous + ? "A Clerk.configure(...) call is present, but multiple @main entry points make startup ownership ambiguous. Confirm which entry point ships." + : configureCallConnectedToRuntime + ? runtimeKeySource === "inline-literal" + ? "Clerk is configured directly in the selected target's @main initializer with a valid publishable key. The value is intentionally redacted from this plan." + : "A Clerk.configure(...) call is connected to a recognized selected-target runtime key loader. The key expression and value are intentionally redacted from this plan." + : usablePublishableKey + ? runtimeKeySource === "available-only" + ? "A usable publishable key is available to copy, but the app is not proven to load it at runtime. Configure Clerk directly in the selected target's @main App initializer, or repair the app's existing runtime loader if it intentionally uses one." + : "A selected-target runtime publishable key is present, but the Clerk.configure(...) expression could not be connected to its loader. Confirm the wiring manually; the expression and value are intentionally redacted." + : "A Clerk.configure(...) call is present, but the inspector could not validate a usable selected-target runtime key source. Confirm the runtime value manually; the expression is intentionally redacted." + : !target.swift.evidenceComplete + ? "No Clerk.configure(...) call was found in the safely inspected source subset. Complete source membership inspection or confirm startup setup manually." + : inspection.localPublishableKey.conflict + ? "Available publishable-key candidates point to different Clerk instances. Choose the intended development instance and call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer." + : inspection.localPublishableKey.invalidSources.length > 0 + ? "The available publishable-key candidate is malformed. Replace it with the intended development key and call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer." + : inspection.localPublishableKey.found + ? "A local publishable key is available, but it is not proven to configure this target. New projects should call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer; the plan will never print the key." + : directConfigAutomationReady + ? `clerk init can add Clerk.configure(publishableKey:) directly to ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App initializer"} with the selected application's development key. The preview and result keep the value redacted.` + : "Select a Clerk application and call Clerk.configure(publishableKey:) with its development publishable key directly in the selected target's @main App initializer.", + target.swift.configureCalls, + undefined, + runtimeKeyAutomationReady || directConfigAutomationReady, + ), + ); + + const injected = target.swift.environmentInjections.length > 0; + const requiresSwiftUIEnvironment = + target.swift.environmentConsumers.length > 0 || includeClerkKitUI || directConfigPlanApplies; + const directEnvironmentAutomationReady = + directConfigPlanApplies && + options.directConfigPlan?.status === "ready" && + options.directConfigPlan.changes?.environment === "insert"; + const directEnvironmentBlocked = !injected && requiresSwiftUIEnvironment && directConfigBlocked; + const injectedStatus: IOSSetupStepStatus = injected + ? sourceEntryPointIsAmbiguous + ? "review" + : "satisfied" + : directEnvironmentBlocked + ? "blocked" + : target.swift.evidenceComplete && requiresSwiftUIEnvironment + ? "required" + : "review"; + steps.push( + step( + "inject-clerk-environment", + "Inject Clerk into the SwiftUI environment", + injectedStatus, + injected + ? sourceEntryPointIsAmbiguous + ? "Clerk.shared is injected, but multiple @main entry points make the shipping root ambiguous." + : "Clerk.shared is injected into SwiftUI." + : directEnvironmentBlocked + ? `Automatic SwiftUI environment injection stopped because the selected startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's WindowGroup root manually."}` + : target.swift.evidenceComplete && requiresSwiftUIEnvironment + ? directEnvironmentAutomationReady + ? `clerk init can add \`.environment(Clerk.shared)\` to the proven WindowGroup root in ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App source"}.` + : "At the app's root view, add `.environment(Clerk.shared)` so Clerk-aware views receive the configured client." + : requiresSwiftUIEnvironment + ? "Clerk.shared injection was not found in the safely inspected source subset. Confirm the shipping root manually." + : "No target source was found consuming Clerk from SwiftUI's environment. Add `.environment(Clerk.shared)` only if AuthView or an `@Environment(Clerk.self)` view needs it.", + target.swift.environmentInjections, + undefined, + directEnvironmentAutomationReady, + ), + ); + + const handlesURLs = target.swift.openURLHandlers.length > 0; + const selectedPrebuiltAuthReady = + options.prebuiltAuthSelected === true && + options.prebuiltAuthPlan?.status === "ready" && + !strictSDKBlocked; + const prebuiltAuthHandlesItsOwnCallbacks = + selectedPrebuiltAuthReady || options.prebuiltAuthPlan?.status === "satisfied"; + steps.push( + step( + "wire-auth-callbacks", + "Wire authentication callbacks", + handlesURLs && !sourceEntryPointIsAmbiguous + ? "satisfied" + : prebuiltAuthHandlesItsOwnCallbacks && !sourceEntryPointIsAmbiguous + ? "satisfied" + : "review", + handlesURLs + ? "An onOpenURL handler forwards redirect URLs to Clerk." + : prebuiltAuthHandlesItsOwnCallbacks + ? "ClerkKitUI's AuthView handles its callback lifecycle while presented, so this quickstart flow does not need generated app-level callback code." + : "For redirect-based authentication launched outside AuthView, verify that the app forwards incoming URLs to Clerk.", + target.swift.openURLHandlers, + undefined, + false, + ), + ); + + const bundleIdentifiers = distinctResolved( + target, + (configuration) => configuration.bundleIdentifier, + ); + const appPrefixes = [ + ...new Set( + target.configurations + .map((configuration) => configuration.entitlements?.literalAppIdentifierPrefix) + .filter((value): value is string => value != null), + ), + ].sort(); + const registrationBlocked = + target.configurations.length === 0 || + target.configurations.some( + (configuration) => configuration.bundleIdentifier.state !== "resolved", + ) || + bundleIdentifiers.length !== 1; + steps.push( + step( + "register-native-application", + "Register the iOS app in Clerk Dashboard", + registrationBlocked ? "blocked" : "review", + registrationBlocked + ? "A single Bundle ID could not be resolved across build configurations. Make it explicit or consistent before registering the app." + : appPrefixes.length === 1 + ? `The source entitlements contain the literal App ID Prefix candidate ${appPrefixes[0]} for ${bundleIdentifiers[0]}. Confirm it in Apple Developer, then verify the app is registered and Native API is enabled. Dashboard state is not changed or assumed by dry-run.` + : `Verify that ${bundleIdentifiers[0]} is registered and Native API is enabled. Supply the Apple App ID Prefix from the Developer portal; DEVELOPMENT_TEAM is not assumed to be the prefix.`, + allEvidence(target, (configuration) => configuration.bundleIdentifier), + [{ kind: "dashboard", url: NATIVE_APPLICATIONS_URL }], + ), + ); + + if (options.appleEntitlementPlan) { + const appleStatus: IOSSetupStepStatus = + options.appleEntitlementPlan.status === "ready" + ? "required" + : options.appleEntitlementPlan.status === "satisfied" + ? "satisfied" + : "blocked"; + const description = + options.appleEntitlementPlan.status === "ready" + ? "Add the native Sign in with Apple entitlement with the exact Default value. After authentication, clerk init will separately audit and enable the matching Clerk Apple connection without requesting hosted/web Apple credentials." + : options.appleEntitlementPlan.status === "satisfied" + ? "The selected target has the exact native Sign in with Apple entitlement. Regular clerk init will verify the matching Clerk Apple connection after authentication." + : `Native Sign in with Apple needs review: ${options.appleEntitlementPlan.blockers.map((item) => item.message).join(" ")}`; + steps.push( + step( + "enable-native-apple", + "Enable native Sign in with Apple", + appleStatus, + description, + target.configurations.flatMap((configuration) => configuration.entitlementsPath.evidence), + [{ kind: "documentation", url: NATIVE_APPLE_URL }], + options.appleEntitlementPlan.status === "ready", + ), + ); + } + + const expectedDomain = inspection.localPublishableKey.frontendApiHost + ? `webcredentials:${inspection.localPublishableKey.frontendApiHost}` + : undefined; + const expectedDomainIsSelectedTargetRuntime = + runtimeKeySource === "inline-literal" || + runtimeKeySource === "run-scheme" || + runtimeKeySource === "local-secrets"; + const entitlements = target.configurations + .map((configuration) => configuration.entitlements) + .filter((value) => value != null); + const allEntitlementsPresent = + entitlements.length === target.configurations.length && entitlements.length > 0; + const domainPresent = + expectedDomain != null && + allEntitlementsPresent && + entitlements.every((value) => + value.associatedDomains.some((domain) => associatedDomainMatches(domain, expectedDomain)), + ); + const hasUnresolvedAssociatedDomains = entitlements.some( + (value) => value.unresolvedAssociatedDomains.length > 0, + ); + const associatedDomainPlan = options.associatedDomainPlan; + const associatedDomainStatus: IOSSetupStepStatus = + associatedDomainPlan?.status === "ready" + ? "required" + : associatedDomainPlan?.status === "satisfied" + ? "satisfied" + : associatedDomainPlan?.status === "blocked" + ? "review" + : expectedDomain && !expectedDomainIsSelectedTargetRuntime + ? "review" + : domainPresent + ? "satisfied" + : expectedDomain && allEntitlementsPresent && hasUnresolvedAssociatedDomains + ? "review" + : expectedDomain + ? "required" + : "blocked"; + const associatedDomainDescription = + associatedDomainPlan?.status === "ready" + ? associatedDomainPlan.expectedDomain + ? associatedDomainPlan.missingEntitlementsSettings + ? `Create and attach ${associatedDomainPlan.files[0]?.path ?? "an entitlements file"} only to iPhone and iPad builds, then add ${associatedDomainPlan.expectedDomain}. clerk init can apply this safely.` + : `Add ${associatedDomainPlan.expectedDomain} to every selected-target entitlements configuration. clerk init can apply the exact existing-file edits safely.` + : associatedDomainPlan.missingEntitlementsSettings + ? `The selected target has one safe synchronized destination for a new entitlements file. clerk init will create and attach it only to iPhone and iPad builds, then add the linked development application's exact webcredentials host without exposing the publishable key.` + : "The existing selected-target entitlements files are safe to edit. clerk init will derive the exact webcredentials host from the linked development application after authentication and add it without exposing the publishable key." + : associatedDomainPlan?.status === "blocked" + ? `Automatic Associated Domains setup needs review: ${associatedDomainPlan.blockers.map((blocker) => blocker.message).join(" ")}` + : expectedDomain && !expectedDomainIsSelectedTargetRuntime + ? domainPresent + ? `${expectedDomain} matches every inspected entitlements configuration, but the key is only available to copy and is not proven to be the selected target's runtime key. Confirm the runtime key before treating this domain as final.` + : `The available key candidate maps to ${expectedDomain}, but it is not proven to be the selected target's runtime key. Wire or confirm the runtime key before adding its Associated Domain.` + : domainPresent + ? `${expectedDomain} is present in every inspected entitlements configuration.` + : expectedDomain + ? allEntitlementsPresent && hasUnresolvedAssociatedDomains + ? `Some associated-domain values use unresolved build settings. Confirm they expand to ${expectedDomain} in every selected-target configuration.` + : `Enable Associated Domains for ${target.name} and add ${expectedDomain} to every selected-target entitlements configuration.` + : inspection.localPublishableKey.conflict + ? "Local publishable-key sources point at different Clerk instances, so the associated domain cannot be chosen safely. Resolve the key conflict and rerun this plan." + : "A valid local publishable key is needed to derive the exact `webcredentials:` Frontend API host. Add the key, then rerun this plan."; + steps.push( + step( + "add-associated-domain", + "Add Clerk's associated domain", + associatedDomainStatus, + associatedDomainDescription, + target.configurations.flatMap((configuration) => configuration.entitlementsPath.evidence), + undefined, + associatedDomainPlan?.status === "ready", + ), + ); + + const hasAuthFlow = target.swift.authFlowReferences.length > 0; + const prebuiltAuthReady = options.prebuiltAuthPlan?.status === "ready" && !strictSDKBlocked; + const prebuiltAuthSatisfied = options.prebuiltAuthPlan?.status === "satisfied"; + const selectedPrebuiltAuthBlocked = + options.prebuiltAuthSelected === true && + (options.prebuiltAuthPlan?.status === "blocked" || strictSDKBlocked); + const authFlowStatus: IOSSetupStepStatus = selectedPrebuiltAuthBlocked + ? "blocked" + : hasAuthFlow || prebuiltAuthSatisfied + ? sourceEntryPointIsAmbiguous + ? "review" + : "satisfied" + : prebuiltAuthReady + ? "required" + : target.swift.evidenceComplete + ? "required" + : "review"; + steps.push( + step( + "add-authentication-flow", + "Add an authentication flow", + authFlowStatus, + selectedPrebuiltAuthBlocked + ? `The prebuilt AuthView scaffold was requested, but this app is not safe to rewrite automatically: ${strictSDKBlocker ?? options.prebuiltAuthPlan?.blockers.map((blocker) => blocker.message).join(" ") ?? "Review the existing signed-out route and integrate AuthView manually."} Linked AuthView providers are not inspected by this network-free local plan.` + : hasAuthFlow || prebuiltAuthSatisfied + ? sourceEntryPointIsAmbiguous + ? "A Clerk authentication flow is referenced, but multiple @main entry points make the shipping route ambiguous." + : prebuiltAuthSatisfied + ? "ClerkKitUI's documented UserButton entry and AuthView sheet are already configured in target source." + : "A Clerk authentication UI or sign-in/sign-up flow is referenced in target source." + : prebuiltAuthReady + ? options.prebuiltAuthSelected + ? `Add ClerkKitUI's documented UserButton entry, AuthView sheet, and image prefetching to ${options.prebuiltAuthPlan?.sourcePath ?? "the proven placeholder SwiftUI view"}. Linked AuthView providers are not inspected by this network-free local plan; regular clerk init will add or verify the local Sign in with Apple entitlement only if Apple is enabled for the linked instance.` + : `This target's pristine placeholder is eligible for the optional prebuilt AuthView scaffold. Run clerk init with --prebuilt-auth-ui or select it when prompted; existing application UI is never replaced automatically.` + : target.swift.evidenceComplete + ? productDecision === "core-only" + ? "Complete the custom ClerkKit sign-in/sign-up flow and route signed-out users to it." + : "Present ClerkKitUI's AuthView or build a custom ClerkKit sign-in/sign-up flow, then route signed-out users to it." + : "No Clerk authentication flow was found in the safely inspected source subset. Confirm the signed-out route manually.", + target.swift.authFlowReferences, + undefined, + prebuiltAuthReady, + ), + ); + + const actionable = steps.some((item) => item.status === "required" || item.status === "blocked"); + steps.push( + step( + "verify-integration", + "Build and verify sign-in", + "review", + actionable + ? "After completing the required steps, build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled." + : "The local evidence looks complete. Build the selected target and verify sign-in, sign-out, app relaunch, and any redirect-based method you enabled.", + targetEvidence, + [{ kind: "documentation", url: QUICKSTART_URL }], + ), + ); + + return finishPlan(inspection, steps); +} + +function finishPlan(inspection: IOSProjectInspectionResult, steps: IOSSetupStep[]): IOSSetupPlan { + const summary: IOSSetupPlan["summary"] = { + satisfied: 0, + required: 0, + review: 0, + blocked: 0, + }; + for (const item of steps) summary[item.status]++; + const status: IOSSetupPlan["status"] = + summary.blocked > 0 ? "blocked" : summary.required > 0 ? "action-required" : "ready"; + + return { + schemaVersion: 1, + kind: "clerk-ios-setup", + root: inspection.root, + status, + selection: inspection.selection, + summary, + steps, + diagnostics: inspection.diagnostics, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts new file mode 100644 index 000000000..ca64ba4d2 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { auditIOSPrebuiltAuthEnvironment } from "./prebuilt-auth-environment.ts"; + +describe("auditIOSPrebuiltAuthEnvironment", () => { + test("requires the native Apple entitlement when Apple is enabled and authenticatable", () => { + expect( + auditIOSPrebuiltAuthEnvironment({ + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + }), + ).toEqual({ apple: "required" }); + }); + + test("does not require the entitlement when enabled Apple is not authenticatable", () => { + expect( + auditIOSPrebuiltAuthEnvironment({ + social: { + oauth_apple: { + enabled: true, + authenticatable: false, + strategy: "oauth_apple", + }, + }, + }), + ).toEqual({ apple: "not-required" }); + }); + + test("does not require the entitlement when Apple is disabled", () => { + expect( + auditIOSPrebuiltAuthEnvironment({ + social: { + oauth_apple: { + enabled: false, + authenticatable: true, + strategy: "oauth_apple", + }, + }, + }), + ).toEqual({ apple: "not-required" }); + }); + + test("does not require the entitlement when Apple is absent", () => { + expect(auditIOSPrebuiltAuthEnvironment({ social: {} })).toEqual({ + apple: "not-required", + }); + }); + + test.each([ + undefined, + null, + {}, + { social: null }, + { social: [] }, + { social: { oauth_apple: null } }, + { social: { oauth_apple: [] } }, + { social: { oauth_apple: { enabled: "true", authenticatable: true } } }, + { social: { oauth_apple: { enabled: true, authenticatable: "true" } } }, + { social: { oauth_apple: { enabled: true } } }, + { + social: { + alias: { enabled: true, authenticatable: true, strategy: "oauth_apple" }, + }, + }, + { + social: { + oauth_apple: { enabled: true, authenticatable: true, strategy: "oauth_google" }, + }, + }, + ])("blocks malformed or ambiguous provider data", (settings) => { + expect(auditIOSPrebuiltAuthEnvironment(settings)).toEqual({ + apple: "blocked", + message: + "Clerk's Apple sign-in settings could not be safely determined. Review the Apple social connection before applying the prebuilt iOS authentication UI.", + }); + }); + + test("returns only redacted status data and never retains provider details", () => { + const secret = "client-secret-must-not-escape"; + const callbackUrl = "https://example.test/private-callback"; + const settings = { + social: { + oauth_apple: { + enabled: true, + authenticatable: true, + strategy: "oauth_apple", + client_secret: secret, + redirect_url: callbackUrl, + nested: { credential: secret }, + }, + }, + }; + + const audit = auditIOSPrebuiltAuthEnvironment(settings); + const serialized = JSON.stringify(audit); + + expect(audit).toEqual({ apple: "required" }); + expect(serialized).toBe('{"apple":"required"}'); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain(callbackUrl); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.ts new file mode 100644 index 000000000..d34d387e3 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth-environment.ts @@ -0,0 +1,52 @@ +export type IOSPrebuiltAuthEnvironmentAudit = + | { apple: "required" } + | { apple: "not-required" } + | { apple: "blocked"; message: string }; + +const APPLE_PROVIDER_STRATEGY = "oauth_apple"; +const BLOCKED_MESSAGE = + "Clerk's Apple sign-in settings could not be safely determined. Review the Apple social connection before applying the prebuilt iOS authentication UI."; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function blocked(): IOSPrebuiltAuthEnvironmentAudit { + return { apple: "blocked", message: BLOCKED_MESSAGE }; +} + +/** + * Determines whether AuthView will offer native Sign in with Apple without + * retaining or returning any Frontend API environment data. + */ +export function auditIOSPrebuiltAuthEnvironment( + settings: unknown, +): IOSPrebuiltAuthEnvironmentAudit { + if (!isRecord(settings) || !isRecord(settings.social)) { + return blocked(); + } + + let appleEnabled = false; + for (const [key, provider] of Object.entries(settings.social)) { + if ( + !isRecord(provider) || + typeof provider.enabled !== "boolean" || + typeof provider.authenticatable !== "boolean" || + typeof provider.strategy !== "string" || + provider.strategy.trim().length === 0 + ) { + return blocked(); + } + + const keyIdentifiesApple = key === APPLE_PROVIDER_STRATEGY; + const strategyIdentifiesApple = provider.strategy === APPLE_PROVIDER_STRATEGY; + if (keyIdentifiesApple !== strategyIdentifiesApple) { + return blocked(); + } + if (strategyIdentifiesApple && provider.enabled && provider.authenticatable) { + appleEnabled = true; + } + } + + return appleEnabled ? { apple: "required" } : { apple: "not-required" }; +} diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts new file mode 100644 index 000000000..0bb4da674 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts @@ -0,0 +1,403 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; +import { afterEach, describe, expect, test } from "bun:test"; +import type { PbxObjects } from "./pbx.ts"; +import { + applyIOSPrebuiltAuth, + planIOSPrebuiltAuth, + prepareIOSPrebuiltAuthMutation, +} from "./prebuilt-auth.ts"; +import { createIOSFixture, IOS_FIXTURE_IDS } from "./test-helpers.ts"; + +const CONTENT_FILE_ID = "616161616161616161616161"; +const CONTENT_BUILD_FILE_ID = "626262626262626262626262"; +const SHARED_CONTENT_BUILD_FILE_ID = "636363636363636363636363"; + +const APP_SOURCE = `import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`; + +const CONTENT_SOURCE = `// +// ContentView.swift +// MyApp +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} +`; + +const GENERATED_CONTENT_SOURCE = `// +// ContentView.swift +// MyApp +// + +import SwiftUI +import ClerkKit +import ClerkKitUI + +struct ContentView: View { + @State private var authIsPresented = false + + var body: some View { + VStack { + UserButton(signedOutContent: { + Button("Sign up") { + authIsPresented = true + } + }) + } + .prefetchClerkImages() + .sheet(isPresented: $authIsPresented) { + AuthView() + } + } +} +`; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +async function createFixture(options: { shared?: boolean; crlf?: boolean } = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "clerk-prebuilt-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + clerkSDK: true, + includeKey: false, + secondTarget: options.shared === true, + }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + (objects[IOS_FIXTURE_IDS.appGroup]!.children as string[]).push(CONTENT_FILE_ID); + (objects[IOS_FIXTURE_IDS.sourcesPhase]!.files as string[]).push(CONTENT_BUILD_FILE_ID); + objects[CONTENT_FILE_ID] = { + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + path: "ContentView.swift", + sourceTree: "", + }; + objects[CONTENT_BUILD_FILE_ID] = { isa: "PBXBuildFile", fileRef: CONTENT_FILE_ID }; + if (options.shared) { + (objects[IOS_FIXTURE_IDS.secondSourcesPhase]!.files as string[]).push( + SHARED_CONTENT_BUILD_FILE_ID, + ); + objects[SHARED_CONTENT_BUILD_FILE_ID] = { + isa: "PBXBuildFile", + fileRef: CONTENT_FILE_ID, + }; + } + await writeFile(projectPath, buildPbxProject(project)); + await writeFile(join(root, "MyApp", "MyAppApp.swift"), APP_SOURCE); + const content = options.crlf ? CONTENT_SOURCE.replace(/\n/g, "\r\n") : CONTENT_SOURCE; + await writeFile(join(root, "MyApp", "ContentView.swift"), content); + return root; +} + +function options(root: string) { + return { + root, + projectPath: "MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + allowDirty: true, + } as const; +} + +async function updateDeploymentTargets( + root: string, + update: (settings: Record, configurationId: string) => void, +): Promise { + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + for (const configurationId of [IOS_FIXTURE_IDS.targetDebug, IOS_FIXTURE_IDS.targetRelease]) { + const settings = objects[configurationId]?.buildSettings; + if (!settings || typeof settings !== "object" || Array.isArray(settings)) { + throw new Error(`Missing fixture build settings for ${configurationId}.`); + } + update(settings as Record, configurationId); + } + await writeFile(projectPath, buildPbxProject(project)); +} + +describe("prebuilt AuthView source setup", () => { + test("plans only an exact target-owned untouched SwiftUI placeholder", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan).toMatchObject({ + schemaVersion: 1, + kind: "clerk-ios-prebuilt-auth", + status: "ready", + appSourcePath: "MyApp/MyAppApp.swift", + sourcePath: "MyApp/ContentView.swift", + blockers: [], + }); + expect(JSON.stringify(plan)).not.toContain("AuthView()"); + expect(JSON.stringify(plan)).not.toContain("Hello, world!"); + }); + + test.each([ + { + name: "one selected configuration below iOS 17", + update(settings: Record, configurationId: string) { + settings.IPHONEOS_DEPLOYMENT_TARGET = + configurationId === IOS_FIXTURE_IDS.targetDebug ? "17.0" : "16.4"; + }, + }, + { + name: "an unresolved deployment target", + update(settings: Record) { + settings.IPHONEOS_DEPLOYMENT_TARGET = "$(PRIVATE_IOS_MINIMUM)"; + }, + }, + { + name: "conflicting device and simulator deployment targets", + update(settings: Record) { + delete settings.IPHONEOS_DEPLOYMENT_TARGET; + settings["IPHONEOS_DEPLOYMENT_TARGET[sdk=iphoneos*]"] = "17.0"; + settings["IPHONEOS_DEPLOYMENT_TARGET[sdk=iphonesimulator*]"] = "16.0"; + }, + }, + { + name: "a missing deployment target", + update(settings: Record) { + delete settings.IPHONEOS_DEPLOYMENT_TARGET; + }, + }, + ])("blocks $name with fixed guidance and no source write", async ({ update }) => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + const sourceBefore = await readFile(sourcePath); + await updateDeploymentTargets(root, update); + + const plan = await planIOSPrebuiltAuth(options(root)); + const result = await applyIOSPrebuiltAuth(plan); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers).toEqual([ + { + code: "incompatible-deployment-target", + message: + "ClerkKitUI's native components require iOS 17.0 or newer. Set IPHONEOS_DEPLOYMENT_TARGET to 17.0 or newer for every selected-target iPhone and iPad build configuration, make device and simulator values consistent, then rerun clerk init.", + }, + ]); + expect(JSON.stringify(plan)).not.toContain("PRIVATE_IOS_MINIMUM"); + expect(result.status).toBe("blocked"); + expect(await readFile(sourcePath)).toEqual(sourceBefore); + }); + + test("writes the documented AuthView presentation and is byte-idempotent", async () => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + await chmod(sourcePath, 0o640); + const plan = await planIOSPrebuiltAuth(options(root)); + const result = await applyIOSPrebuiltAuth(plan); + const source = await readFile(sourcePath, "utf8"); + + expect(result.status).toBe("applied"); + expect(source).toBe(GENERATED_CONTENT_SOURCE); + expect(source).not.toContain("@Environment"); + expect(source).not.toContain(".onOpenURL"); + expect(source).not.toContain("clerk.auth.events"); + expect(source).not.toContain("clerk.session?.tasks"); + expect(source).not.toContain(".alert("); + expect(source).not.toContain("#Preview"); + expect((await Bun.file(sourcePath).stat()).mode & 0o777).toBe(0o640); + + const rerun = await planIOSPrebuiltAuth(options(root)); + expect(rerun.status).toBe("satisfied"); + expect((await applyIOSPrebuiltAuth(rerun)).status).toBe("satisfied"); + expect(await readFile(sourcePath, "utf8")).toBe(source); + }); + + test("preserves CRLF and the existing Xcode header", async () => { + const root = await createFixture({ crlf: true }); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + const plan = await planIOSPrebuiltAuth(options(root)); + expect((await applyIOSPrebuiltAuth(plan)).status).toBe("applied"); + const source = await readFile(sourcePath, "utf8"); + + expect(source.startsWith("//\r\n// ContentView.swift\r\n// MyApp\r\n//\r\n\r\n")).toBe(true); + expect(source.includes("\r\n")).toBe(true); + expect(/(^|[^\r])\n/.test(source)).toBe(false); + }); + + test("refuses customized UI instead of replacing it", async () => { + const root = await createFixture(); + const sourcePath = join(root, "MyApp", "ContentView.swift"); + await writeFile( + sourcePath, + CONTENT_SOURCE.replace('Text("Hello, world!")', 'Text("Customer dashboard")'), + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("missing-placeholder"); + expect(await readFile(sourcePath, "utf8")).toContain("Customer dashboard"); + }); + + test("refuses source shared with another target", async () => { + const root = await createFixture({ shared: true }); + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-source"); + }); + + test("returns the replanned source blocker without exposing a concurrent edit", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + await writeFile( + join(root, "MyApp", "ContentView.swift"), + CONTENT_SOURCE.replace('Text("Hello, world!")', 'Text("Concurrent edit")'), + ); + + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + expect(prepared.status).toBe("blocked"); + expect(prepared.plan.blockers).toContainEqual( + expect.objectContaining({ code: "missing-placeholder" }), + ); + expect(JSON.stringify(prepared)).not.toContain("Concurrent edit"); + }); + + test("returns the replanned blocker before comparing stale source identity", async () => { + const root = await createFixture(); + const plan = await planIOSPrebuiltAuth(options(root)); + await writeFile(join(root, "Project.swift"), "import ProjectDescription\n"); + + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + + expect(prepared.status).toBe("blocked"); + expect(prepared.plan.blockers).toContainEqual( + expect.objectContaining({ code: "generated-project" }), + ); + }); + + test("accepts the direct-configured app root without touching it", async () => { + const root = await createFixture(); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + const encodedHost = Buffer.from("example.clerk.accounts.dev$").toString("base64"); + await writeFile( + appPath, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: "pk_test_${encodedHost}") + } + + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + } +} +`, + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + expect(plan.status).toBe("ready"); + expect(plan.appSourcePath).toBe("MyApp/MyAppApp.swift"); + }); + + test("requires the exact ContentView root to belong to the shipping SwiftUI App", async () => { + const root = await createFixture(); + await writeFile( + join(root, "MyApp", "MyAppApp.swift"), + `import SwiftUI + +@main +struct MyApp { + static func main() {} +} + +struct DecoyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} +`, + ); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsupported-app-structure"); + }); + + test("refuses a source shared with a project below the normal discovery depth", async () => { + const root = await createFixture(); + const deepRoot = join(root, "a", "b", "c", "d"); + await mkdir(deepRoot, { recursive: true }); + await createIOSFixture(deepRoot, { + clerkSDK: false, + includeKey: false, + }); + + const projectPath = join(deepRoot, "MyApp.xcodeproj", "project.pbxproj"); + const project = parsePbxProject(await readFile(projectPath, "utf8")); + const objects = (project as unknown as { objects: PbxObjects }).objects; + (objects[IOS_FIXTURE_IDS.appGroup]!.children as string[]).push(CONTENT_FILE_ID); + (objects[IOS_FIXTURE_IDS.sourcesPhase]!.files as string[]).push(CONTENT_BUILD_FILE_ID); + objects[CONTENT_FILE_ID] = { + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + path: "../../../../../MyApp/ContentView.swift", + sourceTree: "", + }; + objects[CONTENT_BUILD_FILE_ID] = { isa: "PBXBuildFile", fileRef: CONTENT_FILE_ID }; + await writeFile(projectPath, buildPbxProject(project)); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("shared-source"); + }); + + test("fails closed when exhaustive container discovery reaches its safety bound", async () => { + const root = await createFixture(); + const beyondBound = Array.from({ length: 26 }, (_, index) => `level-${index}`).reduce( + (directory, component) => join(directory, component), + root, + ); + await mkdir(beyondBound, { recursive: true }); + + const plan = await planIOSPrebuiltAuth(options(root)); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("incomplete-source-membership"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts new file mode 100644 index 000000000..453b22119 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts @@ -0,0 +1,804 @@ +import { lstat, readFile } from "node:fs/promises"; +import { basename, dirname, relative, resolve } from "node:path"; +import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; +import { + applyIOSFileTransaction, + hashIOSFileBytes, + type IOSExistingFileMutation, +} from "./file-transaction.ts"; +import { hasExactIOSSwiftUIAppContentRoot } from "./direct-config.ts"; +import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; +import type { IOSBuildConfiguration } from "./types.ts"; + +const MAX_SWIFT_FILE_BYTES = 1_000_000; + +export interface IOSPrebuiltAuthPlanOptions { + root: string; + projectPath: string; + targetId: string; + allowDirty?: boolean; +} + +export type IOSPrebuiltAuthBlockerCode = + | "invalid-selection" + | "target-not-found" + | "generated-project" + | "incompatible-deployment-target" + | "incomplete-source-membership" + | "ambiguous-entry-point" + | "unsupported-app-structure" + | "missing-placeholder" + | "shared-source" + | "unreadable-source" + | "unsupported-encoding" + | "unsupported-line-endings" + | "existing-auth-integration" + | "existing-authentication-flow" + | "runtime-prerequisites" + | "dirty-source" + | "git-state-unknown"; + +export interface IOSPrebuiltAuthBlocker { + code: IOSPrebuiltAuthBlockerCode; + message: string; +} + +/** A redacted, serializable semantic source plan. */ +export interface IOSPrebuiltAuthPlan { + schemaVersion: 1; + kind: "clerk-ios-prebuilt-auth"; + status: "ready" | "satisfied" | "blocked"; + root: string; + projectPath: string; + targetId: string; + allowDirty: boolean; + appSourcePath?: string; + expectedAppSourceHash?: string; + sourcePath?: string; + expectedSourceHash?: string; + actions: string[]; + blockers: IOSPrebuiltAuthBlocker[]; +} + +/** @internal Candidate bytes are hidden from ordinary serialization. */ +export interface IOSPrebuiltAuthFileMutation { + absolutePath: string; + expectedHash: string; + candidateHash: string; + mode: number; + originalBytes: Uint8Array; + candidateBytes: Uint8Array; +} + +export type PreparedIOSPrebuiltAuthMutation = + | { + status: "ready"; + plan: IOSPrebuiltAuthPlan; + mutation: IOSPrebuiltAuthFileMutation; + } + | { + status: "satisfied" | "blocked" | "stale"; + plan: IOSPrebuiltAuthPlan; + message?: string; + mutation?: undefined; + }; + +export interface IOSPrebuiltAuthApplyResult { + status: "applied" | "satisfied" | "blocked" | "stale" | "rolled-back"; + plan: IOSPrebuiltAuthPlan; + message?: string; +} + +interface SourceSnapshot { + absolutePath: string; + relativePath: string; + bytes: Uint8Array; + source: string; + hash: string; + mode: number; + device: number; + inode: number; + newline: "\n" | "\r\n"; +} + +interface PreparedPlan { + plan: IOSPrebuiltAuthPlan; + appSnapshot?: SourceSnapshot; + sourceSnapshot?: SourceSnapshot; + sourceHeader?: string; +} + +const preparedValidators = new WeakMap Promise>(); + +function makePlan( + options: IOSPrebuiltAuthPlanOptions, + root: string, + projectPath: string, + status: IOSPrebuiltAuthPlan["status"], + details: Partial< + Pick< + IOSPrebuiltAuthPlan, + | "appSourcePath" + | "expectedAppSourceHash" + | "sourcePath" + | "expectedSourceHash" + | "actions" + | "blockers" + > + > = {}, +): IOSPrebuiltAuthPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-prebuilt-auth", + status, + root, + projectPath, + targetId: options.targetId, + allowDirty: options.allowDirty === true, + appSourcePath: details.appSourcePath, + expectedAppSourceHash: details.expectedAppSourceHash, + sourcePath: details.sourcePath, + expectedSourceHash: details.expectedSourceHash, + actions: details.actions ?? [], + blockers: details.blockers ?? [], + }; +} + +function blocked( + options: IOSPrebuiltAuthPlanOptions, + root: string, + projectPath: string, + code: IOSPrebuiltAuthBlockerCode, + message: string, + details: Partial< + Pick< + IOSPrebuiltAuthPlan, + "appSourcePath" | "expectedAppSourceHash" | "sourcePath" | "expectedSourceHash" + > + > = {}, +): PreparedPlan { + return { + plan: makePlan(options, root, projectPath, "blocked", { + ...details, + blockers: [{ code, message }], + }), + }; +} + +function newlineStyle(source: string): "\n" | "\r\n" | undefined { + if (/\r(?!\n)/.test(source)) return undefined; + const hasCRLF = source.includes("\r\n"); + const hasBareLF = /(^|[^\r])\n/.test(source); + if (hasCRLF && hasBareLF) return undefined; + return hasCRLF ? "\r\n" : "\n"; +} + +function decodeUTF8(bytes: Uint8Array): string | undefined { + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); + } catch { + return undefined; + } +} + +async function sourceSnapshot( + root: string, + relativePath: string, +): Promise { + const absolutePath = resolve(root, relativePath); + if (!(await pathIsSafelyWithinIOSRoot(root, absolutePath))) return undefined; + try { + const info = await lstat(absolutePath); + if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_SWIFT_FILE_BYTES) { + return undefined; + } + const bytes = new Uint8Array(await readFile(absolutePath)); + const source = decodeUTF8(bytes); + if (source == null || source.includes("\0")) return undefined; + const newline = newlineStyle(source); + if (!newline) return undefined; + return { + absolutePath, + relativePath, + bytes, + source, + hash: hashIOSFileBytes(bytes), + mode: info.mode & 0o7777, + device: info.dev, + inode: info.ino, + newline, + }; + } catch { + return undefined; + } +} + +async function generatedProjectKind( + root: string, + absoluteProjectPath: string, +): Promise<"xcodegen" | "tuist" | null> { + let directory = dirname(absoluteProjectPath); + while (await pathIsSafelyWithinIOSRoot(root, directory)) { + for (const [markerPath, kind] of [ + ["project.yml", "xcodegen"], + ["Project.swift", "tuist"], + ["Workspace.swift", "tuist"], + ["Tuist/ProjectDescriptionHelpers", "tuist"], + ] as const) { + const marker = resolve(directory, markerPath); + if ((await pathIsSafelyWithinIOSRoot(root, marker)) && (await Bun.file(marker).exists())) { + return kind; + } + } + if (directory === root) break; + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + return null; +} + +function splitHeader(source: string): { header: string; body: string } | undefined { + const importMatch = /^[\t ]*import[\t ]+(?:ClerkKit|ClerkKitUI|SwiftUI)[\t ]*$/m.exec(source); + if (importMatch?.index == null) return undefined; + const header = source.slice(0, importMatch.index); + const validHeader = header + .split(/\r?\n/) + .every((line) => line.trim() === "" || line.trimStart().startsWith("//")); + if (!validHeader || header.includes("/*")) return undefined; + return { header, body: source.slice(importMatch.index) }; +} + +function compactSwift(source: string): string | undefined { + let result = ""; + let inString = false; + let escaped = false; + for (let cursor = 0; cursor < source.length; cursor += 1) { + const character = source[cursor] ?? ""; + if (inString) { + result += character; + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { + inString = true; + result += character; + } else if (!/\s/.test(character)) { + result += character; + } + } + return inString ? undefined : result; +} + +function supportsPrebuiltAuthDeploymentTarget(value: string): boolean { + const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/.exec(value.trim()); + if (!match) return false; + const components = match.slice(1).map((component) => Number(component ?? "0")); + if (components.some((component) => !Number.isSafeInteger(component))) return false; + return (components[0] ?? 0) >= 17; +} + +function targetSupportsPrebuiltAuth(configurations: IOSBuildConfiguration[]): boolean { + return ( + configurations.length > 0 && + configurations.every( + (configuration) => + configuration.deploymentTarget.state === "resolved" && + supportsPrebuiltAuthDeploymentTarget(configuration.deploymentTarget.value), + ) + ); +} + +const PRISTINE_CONTENT_VIEW = `import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} +`; + +const SIMPLE_CONTENT_VIEW = `import SwiftUI + +struct ContentView: View { + var body: some View { + Text("Hello, world!") + } +} + +#Preview { + ContentView() +} +`; + +const GENERATED_CONTENT_VIEW = `import SwiftUI +import ClerkKit +import ClerkKitUI + +struct ContentView: View { + @State private var authIsPresented = false + + var body: some View { + VStack { + UserButton(signedOutContent: { + Button("Sign up") { + authIsPresented = true + } + }) + } + .prefetchClerkImages() + .sheet(isPresented: $authIsPresented) { + AuthView() + } + } +} +`; + +const pristineForms = new Set( + [PRISTINE_CONTENT_VIEW, SIMPLE_CONTENT_VIEW].map((source) => compactSwift(source)), +); +const generatedForm = compactSwift(GENERATED_CONTENT_VIEW); + +function classifyContentView(source: string): { + kind: "pristine" | "generated" | "other"; + header?: string; +} { + const split = splitHeader(source); + if (!split || split.body.includes("//") || split.body.includes("/*")) return { kind: "other" }; + const compact = compactSwift(split.body); + if (compact != null && compact === generatedForm) + return { kind: "generated", header: split.header }; + if (compact != null && pristineForms.has(compact)) + return { kind: "pristine", header: split.header }; + return { kind: "other" }; +} + +async function gitDirtyState( + root: string, + absolutePath: string, +): Promise<"clean" | "dirty" | "not-repository" | "unknown"> { + try { + const child = Bun.spawn( + ["git", "status", "--porcelain=v1", "--untracked-files=all", "--", absolutePath], + { cwd: root, stdout: "pipe", stderr: "ignore" }, + ); + const output = await new Response(child.stdout).text(); + const exitCode = await child.exited; + if (exitCode === 0) return output.trim() === "" ? "clean" : "dirty"; + const probe = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], { + cwd: root, + stdout: "ignore", + stderr: "ignore", + }); + return (await probe.exited) === 0 ? "unknown" : "not-repository"; + } catch { + return "unknown"; + } +} + +async function sourceIdentityOccurrences( + memberships: Awaited>, + snapshot: SourceSnapshot, +): Promise { + let occurrences = 0; + try { + for (const membership of memberships) { + if (!membership.complete) return undefined; + for (const file of membership.files) { + const info = await lstat(file.absolutePath); + if (!info.isFile() || info.isSymbolicLink()) return undefined; + if (info.dev === snapshot.device && info.ino === snapshot.inode) occurrences += 1; + } + } + return occurrences; + } catch { + return undefined; + } +} + +async function preparePlan(options: IOSPrebuiltAuthPlanOptions): Promise { + const root = resolve(options.root); + const absoluteProjectPath = resolve(root, options.projectPath); + if ( + !options.targetId || + !options.projectPath || + resolve(root, relative(root, absoluteProjectPath)) !== absoluteProjectPath || + !(await pathIsSafelyWithinIOSRoot(root, absoluteProjectPath)) + ) { + return blocked( + options, + root, + options.projectPath, + "invalid-selection", + "The selected Xcode project or target is invalid.", + ); + } + const projectPath = relativeIOSPath(root, absoluteProjectPath); + const inspection = await inspectIOSProject(root, { target: options.targetId }); + if ( + inspection.selection.state !== "selected" || + inspection.selection.targetId !== options.targetId || + inspection.selection.projectPath !== projectPath + ) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected native iOS application target could not be proven.", + ); + } + const generator = + inspection.generatedProject ?? (await generatedProjectKind(root, absoluteProjectPath)); + if (generator != null) { + return blocked( + options, + root, + projectPath, + "generated-project", + `This is a ${generator === "xcodegen" ? "XcodeGen" : "Tuist"} project; update its source manifest instead of generated Swift sources.`, + ); + } + const target = inspection.appTargets.find( + (candidate) => candidate.id === options.targetId && candidate.projectPath === projectPath, + ); + if (!target) { + return blocked( + options, + root, + projectPath, + "target-not-found", + "The selected native iOS application target disappeared during inspection.", + ); + } + if (!targetSupportsPrebuiltAuth(target.configurations)) { + return blocked( + options, + root, + projectPath, + "incompatible-deployment-target", + "ClerkKitUI's native components require iOS 17.0 or newer. Set IPHONEOS_DEPLOYMENT_TARGET to 17.0 or newer for every selected-target iPhone and iPad build configuration, make device and simulator values consistent, then rerun clerk init.", + ); + } + if (!target.swift.evidenceComplete) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "The selected target's complete Swift source membership could not be proven.", + ); + } + if (target.swift.entryPoints.length !== 1 || !target.swift.entryPoints[0]?.path) { + return blocked( + options, + root, + projectPath, + "ambiguous-entry-point", + "The selected target must contain exactly one shipping @main Swift entry point.", + ); + } + const appSourcePath = target.swift.entryPoints[0].path; + const appSnapshot = await sourceSnapshot(root, appSourcePath); + if (!appSnapshot) { + return blocked( + options, + root, + projectPath, + "unreadable-source", + "The selected @main Swift source is not a safe, readable in-root regular file.", + { appSourcePath }, + ); + } + const appDetails = { + appSourcePath, + expectedAppSourceHash: appSnapshot.hash, + }; + if (!hasExactIOSSwiftUIAppContentRoot(appSnapshot.source)) { + return blocked( + options, + root, + projectPath, + "unsupported-app-structure", + "The shipping WindowGroup must have one direct ContentView root before the optional prebuilt UI can be added.", + appDetails, + ); + } + + const memberships = await inspectIOSSourceMembership(root); + const selectedMembership = memberships.find( + (membership) => + membership.targetId === options.targetId && membership.projectPath === projectPath, + ); + if (!selectedMembership?.complete || memberships.some((membership) => !membership.complete)) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "Complete source ownership across every local native target could not be proven.", + appDetails, + ); + } + const contentCandidates = selectedMembership.files.filter( + (file) => + basename(file.absolutePath) === "ContentView.swift" && + dirname(file.absolutePath) === dirname(appSnapshot.absolutePath), + ); + if (contentCandidates.length !== 1 || !contentCandidates[0]) { + return blocked( + options, + root, + projectPath, + "missing-placeholder", + "The selected target does not have one separate target-owned ContentView.swift beside its @main source.", + appDetails, + ); + } + const sourcePath = contentCandidates[0].relativePath; + const sourceSnapshotValue = await sourceSnapshot(root, sourcePath); + if (!sourceSnapshotValue) { + return blocked( + options, + root, + projectPath, + "unreadable-source", + "ContentView.swift is not a safe, readable in-root regular UTF-8 source file.", + { ...appDetails, sourcePath }, + ); + } + const sourceDetails = { + ...appDetails, + sourcePath, + expectedSourceHash: sourceSnapshotValue.hash, + }; + const identityOccurrences = await sourceIdentityOccurrences(memberships, sourceSnapshotValue); + if (identityOccurrences !== 1) { + return blocked( + options, + root, + projectPath, + "shared-source", + "ContentView.swift is shared, aliased, or not exclusively owned by the selected target.", + sourceDetails, + ); + } + + const classification = classifyContentView(sourceSnapshotValue.source); + if (classification.kind === "generated") { + return { + appSnapshot, + sourceSnapshot: sourceSnapshotValue, + sourceHeader: classification.header, + plan: makePlan(options, root, projectPath, "satisfied", { + ...sourceDetails, + actions: [ + `Verify ClerkKitUI's prebuilt UserButton and AuthView presentation in ${sourcePath}.`, + "Verify Clerk images are prefetched for the prebuilt authentication UI.", + ], + }), + }; + } + if (classification.kind !== "pristine") { + return blocked( + options, + root, + projectPath, + target.swift.authFlowReferences.length > 0 || + target.swift.openURLHandlers.length > 0 || + target.swift.importsClerkKitUI.length > 0 + ? "existing-authentication-flow" + : "missing-placeholder", + "Existing or customized application UI was preserved. Integrate AuthView manually in the app's signed-out route.", + sourceDetails, + ); + } + if ( + target.swift.authFlowReferences.length > 0 || + target.swift.openURLHandlers.length > 0 || + target.swift.importsClerkKitUI.length > 0 || + target.swift.environmentConsumers.length > 0 + ) { + return blocked( + options, + root, + projectPath, + "existing-authentication-flow", + "Existing Clerk authentication source was preserved instead of layering a second prebuilt flow over it.", + sourceDetails, + ); + } + if (!options.allowDirty) { + const dirty = await gitDirtyState(root, sourceSnapshotValue.absolutePath); + if (dirty === "dirty") { + return blocked( + options, + root, + projectPath, + "dirty-source", + `The planned Swift source ${sourcePath} has existing Git changes; pass the explicit dirty-file override to include it.`, + sourceDetails, + ); + } + if (dirty === "unknown") { + return blocked( + options, + root, + projectPath, + "git-state-unknown", + `Git state for the planned Swift source ${sourcePath} could not be verified.`, + sourceDetails, + ); + } + } + return { + appSnapshot, + sourceSnapshot: sourceSnapshotValue, + sourceHeader: classification.header, + plan: makePlan(options, root, projectPath, "ready", { + ...sourceDetails, + actions: [ + `Replace only the untouched SwiftUI placeholder in ${sourcePath} with ClerkKitUI's documented UserButton and AuthView presentation.`, + "Present AuthView from UserButton's signed-out content.", + "Prefetch Clerk images for the prebuilt authentication UI.", + ], + }), + }; +} + +export async function planIOSPrebuiltAuth( + options: IOSPrebuiltAuthPlanOptions, +): Promise { + return (await preparePlan(options)).plan; +} + +function mutationWithHiddenBytes( + snapshot: SourceSnapshot, + candidateBytes: Uint8Array, +): IOSPrebuiltAuthFileMutation { + const mutation = { + absolutePath: snapshot.absolutePath, + expectedHash: snapshot.hash, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: snapshot.mode, + } as IOSPrebuiltAuthFileMutation; + Object.defineProperties(mutation, { + originalBytes: { value: snapshot.bytes, enumerable: false }, + candidateBytes: { value: candidateBytes, enumerable: false }, + }); + return mutation; +} + +function readyPrepared( + plan: IOSPrebuiltAuthPlan, + mutation: IOSPrebuiltAuthFileMutation, + validator: () => Promise, +): PreparedIOSPrebuiltAuthMutation { + const prepared = { status: "ready", plan } as PreparedIOSPrebuiltAuthMutation; + Object.defineProperty(prepared, "mutation", { value: mutation, enumerable: false }); + preparedValidators.set(prepared, validator); + return prepared; +} + +export async function prepareIOSPrebuiltAuthMutation( + plan: IOSPrebuiltAuthPlan, +): Promise { + if (plan.status === "blocked") return { status: "blocked", plan }; + if ( + plan.schemaVersion !== 1 || + plan.kind !== "clerk-ios-prebuilt-auth" || + !plan.appSourcePath || + !plan.expectedAppSourceHash || + !plan.sourcePath || + !plan.expectedSourceHash + ) { + return { + status: "blocked", + plan: { + ...plan, + status: "blocked", + actions: [], + blockers: [ + { + code: "invalid-selection", + message: "The prebuilt AuthView source plan is incomplete or unsupported.", + }, + ], + }, + }; + } + const current = await preparePlan({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: plan.allowDirty, + }); + if (current.plan.status === "blocked" || !current.sourceSnapshot) { + return { status: "blocked", plan: current.plan }; + } + if ( + current.plan.appSourcePath !== plan.appSourcePath || + current.plan.sourcePath !== plan.sourcePath || + current.plan.expectedAppSourceHash !== plan.expectedAppSourceHash || + current.plan.expectedSourceHash !== plan.expectedSourceHash + ) { + return { + status: "stale", + plan, + message: "The selected Swift sources changed after the preview.", + }; + } + if (current.plan.status === "satisfied") return { status: "satisfied", plan: current.plan }; + + const newline = current.sourceSnapshot.newline; + const generated = `${current.sourceHeader ?? ""}${GENERATED_CONTENT_VIEW.replace(/\n/g, newline)}`; + const candidateBytes = new TextEncoder().encode(generated); + const mutation = mutationWithHiddenBytes(current.sourceSnapshot, candidateBytes); + const candidateHash = mutation.candidateHash; + return readyPrepared(plan, mutation, async () => { + const verified = await preparePlan({ + root: plan.root, + projectPath: plan.projectPath, + targetId: plan.targetId, + allowDirty: true, + }); + return ( + verified.plan.status === "satisfied" && + verified.plan.sourcePath === plan.sourcePath && + verified.plan.expectedSourceHash === candidateHash + ); + }); +} + +export async function validatePreparedIOSPrebuiltAuth( + prepared: PreparedIOSPrebuiltAuthMutation, +): Promise { + return (await preparedValidators.get(prepared)?.()) ?? false; +} + +function asExistingMutation(mutation: IOSPrebuiltAuthFileMutation): IOSExistingFileMutation { + return { + path: mutation.absolutePath, + originalBytes: mutation.originalBytes, + originalHash: mutation.expectedHash, + candidateBytes: mutation.candidateBytes, + candidateHash: mutation.candidateHash, + mode: mutation.mode, + }; +} + +export async function applyIOSPrebuiltAuth( + plan: IOSPrebuiltAuthPlan, +): Promise { + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + if (prepared.status !== "ready") return prepared; + const result = await applyIOSFileTransaction( + [asExistingMutation(prepared.mutation)], + [async () => validatePreparedIOSPrebuiltAuth(prepared)], + ); + if (result.status === "applied") return { status: "applied", plan }; + if (result.status === "stale") { + return { + status: "stale", + plan, + message: "The selected Swift source changed while the approved update was being committed.", + }; + } + return { + status: "rolled-back", + plan, + message: "The AuthView source update failed validation and the original file was restored.", + }; +} diff --git a/packages/cli-core/src/commands/init/strategy.test.ts b/packages/cli-core/src/commands/init/strategy.test.ts index 472c9f9ba..f5c564d28 100644 --- a/packages/cli-core/src/commands/init/strategy.test.ts +++ b/packages/cli-core/src/commands/init/strategy.test.ts @@ -21,6 +21,7 @@ import { bootstrapMod, keylessMod, keylessTargetMod, + plapiMod, } from "../../test/lib/init-harness.ts"; import * as promptsMod from "../../lib/prompts.ts"; import { init } from "./index.ts"; @@ -243,7 +244,9 @@ describe("init strategy", () => { test("agent mode with --login while unauthenticated throws a usage error", async () => { setup({ isAgent: true, email: null }); - await expect(init({ login: true })).rejects.toThrow(/--login requires an interactive terminal/); + await expect(init({ login: true })).rejects.toThrow( + /--login requires authentication.*interactively/, + ); expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); expect(loginMod.login).not.toHaveBeenCalled(); @@ -313,6 +316,9 @@ describe("init strategy", () => { test("agent mode with keyless framework and --app uses real app flow", async () => { setup({ isAgent: true, email: "user@example.com" }); mockExistingProject(KEYLESS_CTX); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ profile: { appId: "app_abc" } } as never); mockMiddlewareScaffold(); await init({ app: "app_abc" }); @@ -356,19 +362,48 @@ describe("init strategy", () => { expect(captured.err).toContain("clerk init --app "); }); - test("agent mode with real app target and no auth launches login", async () => { + test("authenticated agent iOS setup without an app target prints native runtime guidance", async () => { + const { captured } = setup({ isAgent: true, email: "user@example.com" }); + const iosCtx = { + ...FAKE_CTX, + existingClerk: false, + deps: {}, + framework: { + dep: "ios", + name: "iOS (Swift)", + sdk: "ClerkKit", + envVar: "CLERK_PUBLISHABLE_KEY", + envFile: ".env" as const, + ecosystem: "swift" as const, + }, + envFile: ".env", + }; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [{ type: "create", path: "MyApp/MyAppApp.swift", content: "", description: "" }], + postInstructions: [], + }); + + await init({ yes: true }); + + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(captured.err).toContain("clerk init --app "); + expect(captured.err).toContain("Clerk.configure(publishableKey:"); + expect(captured.err).toContain(".environment(Clerk.shared)"); + expect(captured.err).toContain("LocalSecrets loaders remain supported compatibility paths"); + expect(captured.err).not.toContain("clerk env pull"); + }); + + test("agent mode with real app target and no auth fails before interactive login", async () => { setup({ isAgent: true }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); - await init({ app: "app_abc" }); + await expect(init({ app: "app_abc" })).rejects.toThrow("--app requires authentication"); - expect(loginMod.login).toHaveBeenCalledWith({ showNextSteps: false }); - expect(linkMod.link).toHaveBeenCalledWith({ - skipIfLinked: true, - app: "app_abc", - cwd: FAKE_CTX.cwd, - createIfMissing: expect.any(String), - }); + expect(loginMod.login).not.toHaveBeenCalled(); + expect(linkMod.link).not.toHaveBeenCalled(); }); test("-y flag triggers login when unauthenticated", async () => { @@ -727,14 +762,14 @@ describe("init strategy", () => { spyOn(heuristics, "isAuthenticated").mockResolvedValue(true); await expect(init({ login: true })).rejects.toThrow( - /--login requires an interactive terminal/, + /--login requires authentication.*interactively/, ); expect(loginMod.login).not.toHaveBeenCalled(); expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); }); - test("a real CLERK_PLATFORM_API_KEY is trusted outright, without needing to validate a stored session", async () => { - process.env.CLERK_PLATFORM_API_KEY = "test_key"; + test("a Platform API key is trusted only after read-only PLAPI validation", async () => { + process.env.CLERK_PLATFORM_API_KEY = "ak_test_agent_validation"; try { setup({ isAgent: true, email: null }); mockExistingProject(KEYLESS_CTX); @@ -743,6 +778,7 @@ describe("init strategy", () => { await init({}); + expect(plapiMod.listApplications).toHaveBeenCalled(); expect(linkMod.link).toHaveBeenCalled(); expect(keylessMod.createAccountlessApp).not.toHaveBeenCalled(); } finally { diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 044cb81df..8a0d0fb9e 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -166,7 +166,9 @@ describe("link", () => { await runLink({ app: "app_123" }); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); expect(mockSetProfile).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -220,7 +222,9 @@ describe("link", () => { await runLink({ app: "app_123" }); expect(mockConfirm).not.toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); expect(mockSetProfile).toHaveBeenCalled(); }); @@ -264,6 +268,25 @@ describe("link", () => { expect(mockAutolink).toHaveBeenCalled(); expect(mockCreateApplication).not.toHaveBeenCalled(); }); + + test("can skip ambient-key autolink for a native runtime plan", async () => { + mockIsAgent.mockReturnValue(true); + mockAutolink.mockResolvedValue({ + path: "github.com/org/repo", + profile: { workspaceId: "", appId: "app_web", instances: { development: "ins_web" } }, + }); + mockCreateApplication.mockResolvedValue({ ...mockApp, application_id: "app_native" }); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runLink({ createIfMissing: "native-project", skipAutolink: true }); + + expect(mockAutolink).not.toHaveBeenCalled(); + expect(mockCreateApplication).toHaveBeenCalledWith("native-project"); + expect(mockSetProfile).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ appId: "app_native" }), + ); + }); }); describe("already linked", () => { @@ -351,7 +374,9 @@ describe("link", () => { await runLink({ skipIfLinked: true, app: "app_123" }); expect(mockConfirm).toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); expect(mockSetProfile).toHaveBeenCalled(); }); }); @@ -404,7 +429,9 @@ describe("link", () => { expect(mockListApplications).not.toHaveBeenCalled(); expect(mockSearch).not.toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); }); test("shows interactive picker when no --app flag", async () => { @@ -434,6 +461,33 @@ describe("link", () => { expect(mockFetchApplication).not.toHaveBeenCalled(); }); + test("skipAutolink bypasses ambient key detection and uses the interactive picker", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockListApplications.mockResolvedValue([mockApp]); + mockFindClerkKeys.mockResolvedValue([ + { key: "pk_test", source: "CLERK_PUBLISHABLE_KEY env var" }, + ]); + mockMatchKeyToApp.mockReturnValue({ + app: mockApp, + instance: mockApp.instances[0], + source: "CLERK_PUBLISHABLE_KEY env var", + }); + mockSearch.mockResolvedValue("app_123"); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runLink({ skipAutolink: true }); + + expect(mockAutolink).not.toHaveBeenCalled(); + expect(mockFindClerkKeys).not.toHaveBeenCalled(); + expect(mockMatchKeyToApp).not.toHaveBeenCalled(); + expect(mockSearch).toHaveBeenCalled(); + expect(mockSetProfile).toHaveBeenCalledWith( + "github.com/org/repo", + expect.objectContaining({ appId: "app_123" }), + ); + }); + test("source returns create option first, then all choices, when term is empty", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); @@ -1047,7 +1101,9 @@ describe("link", () => { expect(mockFindClerkKeys).not.toHaveBeenCalled(); expect(mockSearch).not.toHaveBeenCalled(); - expect(mockFetchApplication).toHaveBeenCalledWith("app_123"); + expect(mockFetchApplication).toHaveBeenCalledWith("app_123", { + includeSecretKeys: false, + }); }); test("shows target app name in re-link prompt when --app is provided", async () => { diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index e9872bf98..6db3f9934 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -26,6 +26,12 @@ interface LinkOptions { * interactive end-to-end. */ createIfMissing?: string; + /** + * Skip generic process-env/dotenv key discovery when those sources are not + * runtime inputs for the calling framework. Native iOS direct setup uses + * this so a web app's ambient key cannot silently choose the embedded app. + */ + skipAutolink?: boolean; } export async function link(options: LinkOptions = {}): Promise { @@ -45,7 +51,7 @@ export async function link(options: LinkOptions = {}): Promise { return; } - if (!existing && !options.app && (options.skipIfLinked || agent)) { + if (!existing && !options.app && !options.skipAutolink && (options.skipIfLinked || agent)) { const autolinked = await autolink(cwd); if (autolinked) return; } @@ -75,13 +81,16 @@ export async function link(options: LinkOptions = {}): Promise { await ensureAuth(); const app = options.app - ? await withApiContext(fetchApplication(options.app), "Failed to fetch application") + ? await withApiContext( + fetchApplication(options.app, { includeSecretKeys: false }), + "Failed to fetch application", + ) : agent && options.createIfMissing ? await withApiContext( createApplication(options.createIfMissing), "Failed to create application", ) - : await resolveApp(cwd, displayPath, !existing); + : await resolveApp(cwd, displayPath, !existing && !options.skipAutolink); const devInstance = app.instances.find((i) => i.environment_type === "development"); const prodInstance = app.instances.find((i) => i.environment_type === "production"); @@ -159,7 +168,7 @@ async function handleExistingProfile( if (options.app) { await ensureAuth(); const targetApp = await withApiContext( - fetchApplication(options.app), + fetchApplication(options.app, { includeSecretKeys: false }), "Failed to fetch application", ); return confirm({ message: `Re-link to ${cyan(appLabel(targetApp))}?`, default: false }); diff --git a/packages/cli-core/src/lib/config-instance.ts b/packages/cli-core/src/lib/config-instance.ts new file mode 100644 index 000000000..478682a00 --- /dev/null +++ b/packages/cli-core/src/lib/config-instance.ts @@ -0,0 +1,61 @@ +import { CliError, ERROR_CODE } from "./errors.ts"; +import type { Application, ApplicationInstance } from "./plapi.ts"; + +export const INSTANCE_ALIASES: Record = { + dev: "development", + development: "development", + prod: "production", + production: "production", +}; + +export function resolveFetchedApplicationInstance( + appId: string, + app: Application, + instance?: string, +): + | { found: true; instance: ApplicationInstance; instanceId: string; instanceLabel: string } + | { found: false; instanceId: string; instanceLabel: string } { + if (instance) { + const environment = INSTANCE_ALIASES[instance]; + if (environment) { + const matched = app.instances.find((entry) => entry.environment_type === environment); + if (!matched) { + throw new CliError(`No ${environment} instance found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + instanceLabel: environment, + }; + } + + const matched = app.instances.find((entry) => entry.instance_id === instance); + if (matched) { + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + // Downstream guardrails key off the environment label when it is available. + instanceLabel: matched.environment_type || instance, + }; + } + + return { found: false, instanceId: instance, instanceLabel: instance }; + } + + const development = app.instances.find((entry) => entry.environment_type === "development"); + if (!development) { + throw new CliError(`No development instance found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + return { + found: true, + instance: development, + instanceId: development.instance_id, + instanceLabel: "development", + }; +} diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 41943b990..022805f3d 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -11,7 +11,8 @@ import { getGitRepoIdentifier, getGitNormalizedRemote } from "./git.ts"; import { CliError, ERROR_CODE } from "./errors.ts"; import { withHomeFsAccess } from "./host-execution.ts"; import { log } from "./log.ts"; -import type { Application, ApplicationInstance } from "./plapi.ts"; +import { INSTANCE_ALIASES, resolveFetchedApplicationInstance } from "./config-instance.ts"; +export { resolveFetchedApplicationInstance } from "./config-instance.ts"; let overrideConfigFile: string | undefined; @@ -308,13 +309,6 @@ export async function resolveProfile(cwd: string): Promise< return undefined; } -const INSTANCE_ALIASES: Record = { - dev: "development", - development: "development", - prod: "production", - production: "production", -}; - export function resolveInstanceId(profile: Profile, flag?: string): { id: string; label: string } { if (!flag) { return { id: profile.instances.development, label: "development" }; @@ -339,64 +333,6 @@ interface AppContextOptions { cwd?: string; } -export function resolveFetchedApplicationInstance( - appId: string, - app: Application, - instance?: string, -): - | { found: true; instance: ApplicationInstance; instanceId: string; instanceLabel: string } - | { found: false; instanceId: string; instanceLabel: string } { - if (instance) { - const env = INSTANCE_ALIASES[instance]; - if (env) { - const matched = app.instances.find((entry) => entry.environment_type === env); - if (!matched) { - throw new CliError(`No ${env} instance found for application ${appId}.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - }); - } - return { - found: true, - instance: matched, - instanceId: matched.instance_id, - instanceLabel: env, - }; - } - - const matched = app.instances.find((entry) => entry.instance_id === instance); - if (matched) { - return { - found: true, - instance: matched, - instanceId: matched.instance_id, - // Label by environment type, not the raw id — downstream guardrails - // (e.g. the production impersonation warning) key off this label. - instanceLabel: matched.environment_type || instance, - }; - } - - return { - found: false, - instanceId: instance, - instanceLabel: instance, - }; - } - - const development = app.instances.find((entry) => entry.environment_type === "development"); - if (!development) { - throw new CliError(`No development instance found for application ${appId}.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - }); - } - - return { - found: true, - instance: development, - instanceId: development.instance_id, - instanceLabel: "development", - }; -} - /** * Resolve app context from explicit flags or linked profile. * This is the isomorphic resolution chain used by profile-dependent commands: diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 0c7dcd5c0..46b133374 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -115,6 +115,31 @@ export const ERROR_CODE = { OAUTH_NO_CODE: "oauth_no_code", /** The loopback callback server could not bind a local port. */ CALLBACK_BIND_FAILED: "callback_bind_failed", + + /** No single native iOS application target or Bundle ID could be resolved safely. */ + IOS_TARGET_UNRESOLVED: "ios_target_unresolved", + /** The inspected iOS project has a known condition that prevents safe automatic setup. */ + IOS_SETUP_BLOCKED: "ios_setup_blocked", + /** The iOS worktree cannot be proven safe to modify. */ + IOS_WORKTREE_UNSAFE: "ios_worktree_unsafe", + /** The iOS project or Clerk application changed after the approved setup was planned. */ + IOS_SETUP_STALE: "ios_setup_stale", + /** An internally inconsistent or incomplete iOS setup plan reached the apply boundary. */ + IOS_SETUP_PLAN_INVALID: "ios_setup_plan_invalid", + /** An approved local iOS transaction could not be applied or verified. */ + IOS_LOCAL_APPLY_FAILED: "ios_local_apply_failed", + /** A failed iOS transaction could not restore every original file safely. */ + IOS_LOCAL_ROLLBACK_FAILED: "ios_local_rollback_failed", + /** The development publishable key required by the approved iOS setup is unavailable. */ + IOS_PUBLISHABLE_KEY_UNAVAILABLE: "ios_publishable_key_unavailable", + /** The iOS runtime publishable key does not belong to the linked Clerk application. */ + IOS_PUBLISHABLE_KEY_MISMATCH: "ios_publishable_key_mismatch", + /** An approved Clerk native configuration change could not be applied or confirmed. */ + IOS_REMOTE_APPLY_FAILED: "ios_remote_apply_failed", + /** Clerk native configuration was readable after apply but did not match the approved state. */ + IOS_REMOTE_VERIFY_FAILED: "ios_remote_verify_failed", + /** Platform API returned a successful response with missing or contradictory data. */ + PLAPI_UNEXPECTED_RESPONSE: "plapi_unexpected_response", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; diff --git a/packages/cli-core/src/lib/framework.ts b/packages/cli-core/src/lib/framework.ts index 61f22481b..ce2b98e2c 100644 --- a/packages/cli-core/src/lib/framework.ts +++ b/packages/cli-core/src/lib/framework.ts @@ -8,8 +8,9 @@ import { readdir } from "node:fs/promises"; import { log } from "./log.ts"; /** Where the framework's Clerk SDK is published. Drives how `clerk init` - * installs the SDK: npm frameworks run the package manager, native - * ecosystems (Swift Package Manager, Gradle) print manual install steps. */ + * installs the SDK: npm frameworks run the package manager, iOS has a + * dedicated Xcode graph installer, and other native ecosystems print manual + * install steps. */ export type FrameworkEcosystem = "npm" | "swift" | "gradle"; export interface FrameworkInfo { diff --git a/packages/cli-core/src/lib/plapi-native.test.ts b/packages/cli-core/src/lib/plapi-native.test.ts new file mode 100644 index 000000000..730150ee8 --- /dev/null +++ b/packages/cli-core/src/lib/plapi-native.test.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { credentialStoreStubs, stubFetch } from "../test/lib/stubs.ts"; + +const mockGetValidToken = mock(); +mock.module("./credential-store.ts", () => ({ + ...credentialStoreStubs, + getValidToken: (...args: unknown[]) => mockGetValidToken(...args), +})); + +const { createIOSApplication, enableNativeApi, getNativeSettings, listIOSApplications } = + await import("./plapi.ts"); +const { PlapiError } = await import("./errors.ts"); + +describe("PLAPI native application client", () => { + const originalEnv = { ...process.env }; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + mockGetValidToken.mockResolvedValue(null); + process.env.CLERK_PLATFORM_API_KEY = "ak_test_client_token"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + globalThis.fetch = originalFetch; + mockGetValidToken.mockReset(); + }); + + test("gets native settings for an environment alias", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { object: "native_settings" as const, api_enabled: false }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await getNativeSettings("app_abc", "development"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_settings", + ); + expect(capturedHeaders?.get("Authorization")).toBe("Bearer ak_test_client_token"); + expect(capturedHeaders?.get("Accept")).toBe("application/json"); + expect(capturedHeaders?.has("Idempotency-Key")).toBe(false); + expect(result).toEqual(responseBody); + }); + + test("enables Native API with an idempotency key", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { object: "native_settings" as const, api_enabled: true }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedBody = init?.body as string; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await enableNativeApi("app_abc", "ins_dev_123", { + idempotencyKey: "enable-native-api-123", + }); + + expect(capturedMethod).toBe("PATCH"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_dev_123/native_settings", + ); + expect(JSON.parse(capturedBody)).toEqual({ api_enabled: true }); + expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); + expect(capturedHeaders?.get("Idempotency-Key")).toBe("enable-native-api-123"); + expect(result).toEqual(responseBody); + }); + + test("lists the public iOS application DTOs", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + const responseBody = [ + { + object: "ios_application" as const, + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }, + ]; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + return new Response(JSON.stringify(responseBody), { status: 200 }); + }); + + const result = await listIOSApplications("app_abc", "ins_dev_123"); + + expect(capturedMethod).toBe("GET"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/ins_dev_123/native_applications/ios", + ); + expect(result).toEqual(responseBody); + expect(result[0]).not.toHaveProperty("team_id"); + }); + + test("creates an iOS application with the public field names and idempotency key", async () => { + let capturedUrl = ""; + let capturedMethod = ""; + let capturedBody = ""; + let capturedHeaders: Headers | undefined; + const responseBody = { + object: "ios_application" as const, + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }; + stubFetch(async (input, init) => { + capturedUrl = input.toString(); + capturedMethod = init?.method ?? "GET"; + capturedBody = init?.body as string; + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify(responseBody), { status: 201 }); + }); + + const result = await createIOSApplication( + "app_abc", + "development", + { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, + { idempotencyKey: "create-ios-app-123" }, + ); + + expect(capturedMethod).toBe("POST"); + expect(capturedUrl).toBe( + "https://api.clerk.com/v1/platform/applications/app_abc/instances/development/native_applications/ios", + ); + expect(JSON.parse(capturedBody)).toEqual({ + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + }); + expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); + expect(capturedHeaders?.get("Idempotency-Key")).toBe("create-ios-app-123"); + expect(result).toEqual(responseBody); + }); + + test("preserves typed PLAPI errors from native endpoints without credential data", async () => { + stubFetch( + async () => + new Response(JSON.stringify({ errors: [{ code: "resource_not_found" }] }), { status: 404 }), + ); + + try { + await getNativeSettings("app_missing", "development"); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(PlapiError); + expect((error as InstanceType).status).toBe(404); + expect(JSON.stringify(error)).not.toContain("ak_test_client_token"); + } + }); +}); diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index 4c3c87b8d..c7a511546 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -252,6 +252,22 @@ describe("plapi", () => { expect(capturedHeaders?.get("Content-Type")).toBe("application/json"); }); + test("sends If-Match when a config version is supplied", async () => { + let capturedHeaders: Headers | undefined; + stubFetch(async (_input, init) => { + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({}), { status: 200 }); + }); + + await patchInstanceConfig( + "app_1", + "ins_1", + { connection_oauth_apple: { enabled: true } }, + { ifMatch: "v1_12345678" }, + ); + expect(capturedHeaders?.get("If-Match")).toBe("v1_12345678"); + }); + test("sends JSON body", async () => { let capturedBody = ""; stubFetch(async (_input, init) => { @@ -293,7 +309,7 @@ describe("plapi", () => { ], }; - test("always sends include_secret_keys=true", async () => { + test("sends include_secret_keys=true by default", async () => { let requestedUrl = ""; stubFetch(async (input) => { requestedUrl = input.toString(); @@ -306,6 +322,19 @@ describe("plapi", () => { expect(url.searchParams.get("include_secret_keys")).toBe("true"); }); + test("omits include_secret_keys when the caller only needs public metadata", async () => { + let requestedUrl = ""; + stubFetch(async (input) => { + requestedUrl = input.toString(); + return new Response(JSON.stringify(mockApp), { status: 200 }); + }); + + await fetchApplication("app_abc", { includeSecretKeys: false }); + const url = new URL(requestedUrl); + expect(url.pathname).toBe("/v1/platform/applications/app_abc"); + expect(url.searchParams.has("include_secret_keys")).toBe(false); + }); + test("returns parsed application JSON", async () => { stubFetch(async () => new Response(JSON.stringify(mockApp), { status: 200 })); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index ba8f80546..7ce1afb23 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -69,13 +69,22 @@ export async function getAuthToken(): Promise { * throws PlapiError on non-ok responses. Debug logging is centralized in * `loggedFetch`; don't add inline `log.debug` calls here or in callers. */ -async function plapiFetch(method: string, url: URL, init?: { body?: string }): Promise { +type PlapiFetchInit = { + body?: string; + idempotencyKey?: string; + /** Config version used for optimistic concurrency control. */ + ifMatch?: string; +}; + +async function plapiFetch(method: string, url: URL, init?: PlapiFetchInit): Promise { const token = await getAuthToken(); const headers: Record = { Authorization: `Bearer ${token}`, Accept: "application/json", }; if (init?.body) headers["Content-Type"] = "application/json"; + if (init?.idempotencyKey) headers["Idempotency-Key"] = init.idempotencyKey; + if (init?.ifMatch) headers["If-Match"] = init.ifMatch; const response = await loggedFetch(url, { tag: "plapi", method, @@ -229,9 +238,107 @@ export type TriggerDNSCheckResponse = DomainStatusResponse & { last_run_at: number | null; }; -export async function fetchApplication(applicationId: string): Promise { +export type NativeSettings = { + object: "native_settings"; + api_enabled: boolean; +}; + +export type IOSApplication = { + object: "ios_application"; + id: string; + app_id_prefix: string; + bundle_id: string; + created_at: number; + updated_at: number; +}; + +export type CreateIOSApplicationParams = { + appIdPrefix: string; + bundleId: string; +}; + +export type IdempotentMutationOptions = { + /** Reuse this value when retrying the same mutation. */ + idempotencyKey: string; +}; + +export async function getNativeSettings( + applicationId: string, + envOrInstanceId: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_settings`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function enableNativeApi( + applicationId: string, + envOrInstanceId: string, + options?: IdempotentMutationOptions, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_settings`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("PATCH", url, { + body: JSON.stringify({ api_enabled: true }), + idempotencyKey: options?.idempotencyKey, + }); + return response.json() as Promise; +} + +export async function listIOSApplications( + applicationId: string, + envOrInstanceId: string, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_applications/ios`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("GET", url); + return response.json() as Promise; +} + +export async function createIOSApplication( + applicationId: string, + envOrInstanceId: string, + params: CreateIOSApplicationParams, + options: IdempotentMutationOptions, +): Promise { + const url = new URL( + `/v1/platform/applications/${applicationId}/instances/${envOrInstanceId}/native_applications/ios`, + getPlapiBaseUrl(), + ); + const response = await plapiFetch("POST", url, { + body: JSON.stringify({ + app_id_prefix: params.appIdPrefix, + bundle_id: params.bundleId, + }), + idempotencyKey: options.idempotencyKey, + }); + return response.json() as Promise; +} + +export interface FetchApplicationOptions { + /** + * Include instance secret keys in the response. This defaults to true for + * backwards compatibility; callers that only need publishable metadata + * should opt out so secret keys never enter their process. + */ + includeSecretKeys?: boolean; +} + +export async function fetchApplication( + applicationId: string, + options: FetchApplicationOptions = {}, +): Promise { const url = new URL(`/v1/platform/applications/${applicationId}`, getPlapiBaseUrl()); - url.searchParams.set("include_secret_keys", "true"); + if (options.includeSecretKeys !== false) { + url.searchParams.set("include_secret_keys", "true"); + } const response = await plapiFetch("GET", url); return response.json() as Promise; } @@ -277,12 +384,18 @@ export async function triggerApplicationDomainDNSCheck( return response.json() as Promise; } +export type InstanceConfigMutationOptions = { + destructive?: boolean; + dryRun?: boolean; + ifMatch?: string; +}; + async function sendInstanceConfig( method: "PUT" | "PATCH", applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ): Promise> { const url = new URL( `/v1/platform/applications/${applicationId}/instances/${instanceId}/config`, @@ -294,7 +407,10 @@ async function sendInstanceConfig( if (options?.dryRun) { url.searchParams.set("dry_run", "true"); } - const response = await plapiFetch(method, url, { body: JSON.stringify(config) }); + const response = await plapiFetch(method, url, { + body: JSON.stringify(config), + ifMatch: options?.ifMatch, + }); return response.json() as Promise>; } @@ -302,14 +418,14 @@ export const putInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ) => sendInstanceConfig("PUT", applicationId, instanceId, config, options); export const patchInstanceConfig = async ( applicationId: string, instanceId: string, config: Record, - options?: { destructive?: boolean; dryRun?: boolean }, + options?: InstanceConfigMutationOptions, ) => sendInstanceConfig("PATCH", applicationId, instanceId, config, options); export async function createApplication(name: string): Promise { diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts index 5880a435d..fb83c6f0d 100644 --- a/packages/cli-core/src/lib/telemetry.ts +++ b/packages/cli-core/src/lib/telemetry.ts @@ -64,6 +64,12 @@ export type TelemetryStage = | "already_set_up" | "keys" | "skills" + | "ios_inspect" + | "ios_native_plan" + | "ios_apple_plan" + | "ios_local_setup" + | "ios_native_setup" + | "ios_apple_setup" // `clerk auth login` | "session_check" | "awaiting_callback" @@ -208,6 +214,11 @@ export function currentTelemetryStage(): TelemetryStage | null { return context?.stage ?? null; } +/** Clears any prior in-memory invocation context without reading or writing state. */ +export function discardCommandTelemetry(): void { + context = null; +} + export function telemetryResultForError(error: unknown): TelemetryResult { if (error instanceof UserAbortError) { return { outcome: "abort", exitCode: EXIT_CODE.SUCCESS }; diff --git a/packages/cli-core/src/test/integration/agent-mode.test.ts b/packages/cli-core/src/test/integration/agent-mode.test.ts index 50ff159d0..8833227f2 100644 --- a/packages/cli-core/src/test/integration/agent-mode.test.ts +++ b/packages/cli-core/src/test/integration/agent-mode.test.ts @@ -125,18 +125,22 @@ test("init creates and links a real app for keyless framework when authed in age test("init prints manual setup for non-keyless framework without an app target in agent mode", async () => { await writeReactProject(); + http.mock({ + "/v1/platform/applications": [], + }); const { stderr } = await clerk("--mode", "agent", "init", "--no-skills"); expect(stderr).toContain("clerk init --app "); - expect(http.requests).toHaveLength(0); + expect(http.requests).toHaveLength(1); + expect(http.requests[0]?.url).toContain("/v1/platform/applications"); }); test("init with --app uses real app flow in agent mode", async () => { await writeReactProject(); const devInstance = getInstance(MOCK_APP, "development"); http.mock({ - [`/applications/${MOCK_APP.application_id}`]: MOCK_APP, + "/v1/platform/applications": MOCK_APP, }); await clerk("--mode", "agent", "init", "--app", MOCK_APP.application_id, "--no-skills"); diff --git a/packages/cli-core/src/test/lib/init-harness.ts b/packages/cli-core/src/test/lib/init-harness.ts index 5db6e2d9f..73deb5427 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -29,6 +29,11 @@ export * as bootstrapMod from "../../commands/init/bootstrap.ts"; export * as nextStepsMod from "../../lib/next-steps.ts"; export * as keylessMod from "../../lib/keyless.ts"; export * as keylessTargetMod from "../../lib/keyless-target.ts"; +export * as iosApplyMod from "../../commands/init/ios/apply.ts"; +export * as nativeRemoteMod from "../../commands/init/ios/native-remote.ts"; +export * as nativeAppleMod from "../../commands/init/ios/native-apple.ts"; +export * as plapiMod from "../../lib/plapi.ts"; +export * as fapiMod from "../../lib/fapi.ts"; import * as loginModule from "../../commands/auth/login.ts"; import * as linkModule from "../../commands/link/index.ts"; @@ -45,6 +50,15 @@ import * as heuristicsModule from "../../commands/init/heuristics.ts"; import * as skillsModule from "../../commands/init/skills.ts"; import * as bootstrapModule from "../../commands/init/bootstrap.ts"; import * as keylessModule from "../../lib/keyless.ts"; +import * as iosApplyModule from "../../commands/init/ios/apply.ts"; +import * as nativeRemoteModule from "../../commands/init/ios/native-remote.ts"; +import * as nativeAppleModule from "../../commands/init/ios/native-apple.ts"; +import * as plapiModule from "../../lib/plapi.ts"; +import * as fapiModule from "../../lib/fapi.ts"; +import { + IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + type IOSNativeReadinessAudit, +} from "../../commands/init/ios/native-readiness.ts"; export const FAKE_CTX = { cwd: "/tmp/test", @@ -69,6 +83,36 @@ export const FAKE_BOOTSTRAP = { packageManager: "npm" as const, }; +export const FAKE_IOS_NATIVE_READINESS: IOSNativeReadinessAudit = { + schemaVersion: 1, + kind: "clerk-ios-native-readiness", + root: "/tmp/test", + target: { + status: "selected", + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + targetName: "MyApp", + bundleIdentifier: { status: "resolved", value: "com.example.MyApp" }, + appIdPrefix: { + status: "resolved", + source: "literal-entitlements", + value: "LEGACY1234", + }, + }, + associatedDomain: { + status: "satisfied", + expectedDomain: "webcredentials:clerk.example.test", + files: ["MyApp/MyApp.entitlements"], + automatable: false, + blockers: [], + }, + remote: { + status: "not-inspected", + reason: "dry-run-does-not-read-remote-state", + requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, + }, +}; + type FakeFramework = { dep: string; name: string; @@ -158,8 +202,58 @@ export function useInitHarness(): InitHarness { spyOn(loginModule, "login").mockResolvedValue(undefined as never), spyOn(linkModule, "link").mockResolvedValue(undefined), spyOn(pullModule, "pull").mockResolvedValue(undefined), + spyOn(pullModule, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_test", + instanceId: "ins_test", + instanceLabel: "development", + publishableKey: "pk_test_redacted", + }), + spyOn(fapiModule, "fetchUserSettings").mockResolvedValue({ social: {} } as never), spyOn(bootstrapModule, "promptAndBootstrap").mockResolvedValue(FAKE_BOOTSTRAP), spyOn(bootstrapModule, "confirmOverwrite").mockResolvedValue(undefined), + spyOn(iosApplyModule, "applyIOSLocalSetup").mockResolvedValue({ + targetName: "MyApp", + nativeReadiness: FAKE_IOS_NATIVE_READINESS, + prebuiltAuthRequested: false, + prebuiltAuthActive: false, + nativeAppleRequested: false, + requiresLinkedApp: false, + requiresDevelopmentKey: false, + verifiesExistingKey: false, + }), + spyOn(iosApplyModule, "applyIOSPlannedLocalSetup").mockResolvedValue(undefined), + spyOn(iosApplyModule, "applyIOSRuntimeKeySetup").mockResolvedValue(undefined), + spyOn(iosApplyModule, "verifyIOSRuntimeKeySetup").mockResolvedValue(undefined), + spyOn(nativeRemoteModule, "prepareIOSNativeRemoteSetup").mockResolvedValue({ + schemaVersion: 1, + kind: "clerk-ios-native-remote-setup", + status: "satisfied", + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + appIdPrefix: "LEGACY1234", + nativeApi: "satisfied", + registration: "satisfied", + actions: [], + blockers: [], + }), + spyOn(nativeRemoteModule, "applyIOSNativeRemoteSetup").mockResolvedValue(undefined), + spyOn(nativeAppleModule, "prepareIOSNativeAppleConnection").mockResolvedValue({ + schemaVersion: 1, + kind: "clerk-ios-native-apple-connection", + status: "satisfied", + applicationId: "app_test", + instanceId: "ins_test", + bundleIdentifier: "com.example.MyApp", + connection: "satisfied", + bundleIdentifierConfiguration: "satisfied", + current: { enabled: true, authenticatable: true }, + desired: { enabled: true, authenticatable: true }, + actions: [], + blockers: [], + }), + spyOn(nativeAppleModule, "applyIOSNativeAppleConnection").mockResolvedValue(undefined), + spyOn(plapiModule, "listApplications").mockResolvedValue([]), spyOn(keylessModule, "createAccountlessApp").mockResolvedValue({ publishable_key: "pk_test_stub", secret_key: "sk_test_stub", diff --git a/test/e2e/fixtures/ios/MyApp.xcodeproj/project.pbxproj b/test/e2e/fixtures/ios/MyApp.xcodeproj/project.pbxproj index 95eb6a49f..b62d40472 100644 --- a/test/e2e/fixtures/ios/MyApp.xcodeproj/project.pbxproj +++ b/test/e2e/fixtures/ios/MyApp.xcodeproj/project.pbxproj @@ -1,3 +1,50 @@ // !$*UTF8*$! -// Stub Xcode project file. Detection only requires that a *.xcodeproj -// directory bundle exists — the contents are never parsed. +{ + archiveVersion = 1; + classes = { }; + objectVersion = 56; + objects = { + AAAAAAAAAAAAAAAAAAAAAAAA = { + isa = PBXProject; + attributes = { LastUpgradeCheck = 1600; }; + buildConfigurationList = 131313131313131313131313; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + knownRegions = ( en, Base, ); + mainGroup = BBBBBBBBBBBBBBBBBBBBBBBB; + packageReferences = ( ); + projectDirPath = ""; + projectRoot = ""; + targets = ( 111111111111111111111111, ); + }; + BBBBBBBBBBBBBBBBBBBBBBBB = { isa = PBXGroup; children = ( CCCCCCCCCCCCCCCCCCCCCCCC, EEEEEEEEEEEEEEEEEEEEEEEE, ); sourceTree = ""; }; + CCCCCCCCCCCCCCCCCCCCCCCC = { isa = PBXGroup; children = ( DDDDDDDDDDDDDDDDDDDDDDDD, 232323232323232323232323, ); path = MyApp; sourceTree = ""; }; + DDDDDDDDDDDDDDDDDDDDDDDD = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppApp.swift; sourceTree = ""; }; + 232323232323232323232323 = { isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + EEEEEEEEEEEEEEEEEEEEEEEE = { isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MyApp/MyApp.entitlements; sourceTree = ""; }; + 111111111111111111111111 = { + isa = PBXNativeTarget; + buildConfigurationList = 161616161616161616161616; + buildPhases = ( 191919191919191919191919, 212121212121212121212121, ); + buildRules = ( ); + dependencies = ( ); + name = MyApp; + productName = MyApp; + productReference = 121212121212121212121212; + productType = "com.apple.product-type.application"; + packageProductDependencies = ( ); + }; + 121212121212121212121212 = { isa = PBXFileReference; explicitFileType = wrapper.application; path = MyApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 191919191919191919191919 = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( 202020202020202020202020, 242424242424242424242424, ); runOnlyForDeploymentPostprocessing = 0; }; + 202020202020202020202020 = { isa = PBXBuildFile; fileRef = DDDDDDDDDDDDDDDDDDDDDDDD; }; + 242424242424242424242424 = { isa = PBXBuildFile; fileRef = 232323232323232323232323; }; + 212121212121212121212121 = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; + 131313131313131313131313 = { isa = XCConfigurationList; buildConfigurations = ( 141414141414141414141414, 151515151515151515151515, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 141414141414141414141414 = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = iphoneos; }; name = Debug; }; + 151515151515151515151515 = { isa = XCBuildConfiguration; buildSettings = { SDKROOT = iphoneos; }; name = Release; }; + 161616161616161616161616 = { isa = XCConfigurationList; buildConfigurations = ( 171717171717171717171717, 181818181818181818181818, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 171717171717171717171717 = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; DEVELOPMENT_TEAM = ABCDE12345; PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp; IPHONEOS_DEPLOYMENT_TARGET = 17.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; }; name = Debug; }; + 181818181818181818181818 = { isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements; DEVELOPMENT_TEAM = ABCDE12345; PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp; IPHONEOS_DEPLOYMENT_TARGET = 17.0; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; }; name = Release; }; + }; + rootObject = AAAAAAAAAAAAAAAAAAAAAAAA; +} diff --git a/test/e2e/fixtures/ios/MyApp/ContentView.swift b/test/e2e/fixtures/ios/MyApp/ContentView.swift new file mode 100644 index 000000000..f6a40a672 --- /dev/null +++ b/test/e2e/fixtures/ios/MyApp/ContentView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct ContentView: View { + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/test/e2e/fixtures/ios/MyApp/MyApp.entitlements b/test/e2e/fixtures/ios/MyApp/MyApp.entitlements new file mode 100644 index 000000000..f76746c62 --- /dev/null +++ b/test/e2e/fixtures/ios/MyApp/MyApp.entitlements @@ -0,0 +1,7 @@ + + + +application-identifierLEGACY1234.com.example.MyApp +com.apple.developer.team-identifierABCDE12345 +com.apple.developer.associated-domainswebcredentials:clerk.example.test + diff --git a/test/e2e/fixtures/ios/MyApp/MyAppApp.swift b/test/e2e/fixtures/ios/MyApp/MyAppApp.swift new file mode 100644 index 000000000..6ca35d65c --- /dev/null +++ b/test/e2e/fixtures/ios/MyApp/MyAppApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/test/e2e/fixtures/ios/README.md b/test/e2e/fixtures/ios/README.md index b7da2569b..d1823f329 100644 --- a/test/e2e/fixtures/ios/README.md +++ b/test/e2e/fixtures/ios/README.md @@ -1,7 +1,15 @@ # iOS e2e fixture -Bare Xcode project markers, hand-authored (not in `fixtures.manifest.ts` — the -refresh script never touches this directory). `clerk init` on iOS writes no -project files: it detects the platform via the `*.xcodeproj` bundle, pulls keys -into `.env`, and prints the SDK quickstart steps. `native-init.test.ts` asserts -exactly that. +A minimal, parseable native iOS application target, hand-authored outside +`fixtures.manifest.ts` so the refresh script never replaces it. Its explicit +target sources are the pristine Xcode SwiftUI `App` and canonical +`ContentView` placeholder, making it eligible for the optional prebuilt +authentication UI without treating an in-progress application as disposable. + +The native init E2E test verifies that `clerk init` links ClerkKit and +ClerkKitUI to this exact target, configures the linked development publishable +key directly in the shipping `@main` source, injects `Clerk.shared` into the +root SwiftUI view, keeps `ContentView` unchanged unless the prebuilt UI is +explicitly selected, avoids intermediate dotenv/plist files, verifies the +Native API and exact iOS registration through the Platform API, and leaves +unrelated files unchanged. diff --git a/test/e2e/lib/fixture-setup.ts b/test/e2e/lib/fixture-setup.ts index 31d63df6b..5e05701d9 100644 --- a/test/e2e/lib/fixture-setup.ts +++ b/test/e2e/lib/fixture-setup.ts @@ -61,7 +61,11 @@ async function safeRm(path: string): Promise { * CLERK_CONFIG_DIR, so `clerk init` finds an existing link and skips the * interactive app picker. */ -export async function linkProject(projectDir: string, configDir: string): Promise { +export async function linkProject( + projectDir: string, + configDir: string, + options: { platformApiUrl?: string } = {}, +): Promise { const appId = requireEnv("CLERK_CLI_TEST_APP_ID"); const platformAPIKey = requireEnv("CLERK_PLATFORM_API_KEY"); @@ -71,6 +75,7 @@ export async function linkProject(projectDir: string, configDir: string): Promis CLERK_CONFIG_DIR: configDir, CLERK_PLATFORM_API_KEY: platformAPIKey, CLERK_TELEMETRY_DISABLED: "1", + ...(options.platformApiUrl ? { CLERK_PLATFORM_API_URL: options.platformApiUrl } : {}), }) .quiet() .nothrow(); diff --git a/test/e2e/native-init.test.ts b/test/e2e/native-init.test.ts index 30bd28287..cd9f0f0ad 100644 --- a/test/e2e/native-init.test.ts +++ b/test/e2e/native-init.test.ts @@ -1,4 +1,5 @@ import { test, expect } from "bun:test"; +import { parse as parsePbxProject } from "@bacons/xcode/json"; import { mkdtemp, cp, rm, realpath } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -10,42 +11,159 @@ const FIXTURES_DIR = join(import.meta.dir, "fixtures"); const CLI_PATH = join(import.meta.dir, "../../packages/cli-core/src/cli.ts"); /** - * Native platforms (iOS, Android) have no package.json, no npm install, and no - * build CI can run — Xcode and Gradle toolchains aren't available. So instead - * of the manifest/`createFixtureHarness` flow, this test asserts the whole of - * what `clerk init` promises on native: platform detection from marker files, - * keys pulled into `.env`, zero project files written, and the SDK quickstart - * printed. The fixtures are hand-authored marker stubs the refresh script - * never touches. + * Native platforms (iOS, Android) have no package.json or npm install, and CI + * does not need Xcode or Gradle to verify their local setup boundary. These + * hand-authored fixtures exercise detection, key pulling, bounded project + * writes, and the remaining quickstart guidance end to end. */ const PLATFORMS = [ { fixture: "ios", detectedName: "iOS (Swift)", - // One stable phrase per printed quickstart step that would break setup if dropped. - instructions: ["Swift Package Manager", "dashboard.clerk.com/~/native-applications"], + instructions: [ + "ClerkKit and ClerkKitUI linked to MyApp", + "Clerk configured in MyApp/MyAppApp.swift", + "Clerk Native API and iOS application registration verified", + ], + expectedGitEntries: ["M MyApp.xcodeproj/project.pbxproj", "M MyApp/MyAppApp.swift"], }, { fixture: "android", detectedName: "Android (Kotlin)", instructions: ["app/build.gradle.kts", "dashboard.clerk.com/~/native-applications"], + expectedGitEntries: ["?? .env"], }, ] as const; +function startIOSPlatformStub(applicationId: string) { + const developmentInstanceId = "ins_ios_e2e"; + const publishableKey = `pk_test_${btoa("clerk.example.test$")}`; + const applicationPath = `/v1/platform/applications/${applicationId}`; + const nativePath = `${applicationPath}/instances/${developmentInstanceId}`; + + return Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === applicationPath) { + return Response.json({ + application_id: applicationId, + name: "Native iOS E2E", + instances: [ + { + instance_id: developmentInstanceId, + environment_type: "development", + publishable_key: publishableKey, + }, + ], + }); + } + if (request.method === "GET" && url.pathname === `${nativePath}/native_settings`) { + return Response.json({ object: "native_settings", api_enabled: true }); + } + if (request.method === "GET" && url.pathname === `${nativePath}/native_applications/ios`) { + return Response.json([ + { + object: "ios_application", + id: "iosapp_e2e", + app_id_prefix: "LEGACY1234", + bundle_id: "com.example.MyApp", + created_at: 0, + updated_at: 0, + }, + ]); + } + return new Response("Not found", { status: 404 }); + }, + }); +} + +// Keep this opt-in check local-only: the shared production E2E application can +// change its enabled social connections, while AuthView's Apple entitlement +// decision must be exercised against a controlled local-stack environment. +test( + "clerk init dry-run recognizes the explicit prebuilt AuthView opt-in without writing", + async () => { + const tmp = await realpath(tmpdir()); + const projectDir = await mkdtemp(join(tmp, "clerk-e2e-ios-auth-dry-run-")); + const configDir = await mkdtemp(join(tmp, "clerk-e2e-ios-auth-config-")); + + try { + await cp(join(FIXTURES_DIR, "ios"), projectDir, { recursive: true }); + await gitInit(projectDir); + const pristineApp = await Bun.file(join(projectDir, "MyApp", "MyAppApp.swift")).text(); + const pristineContentView = await Bun.file( + join(projectDir, "MyApp", "ContentView.swift"), + ).text(); + + const result = + await Bun.$`bun ${CLI_PATH} --mode human init --dry-run --prebuilt-auth-ui --target MyApp --no-skills` + .cwd(projectDir) + .env({ + ...process.env, + CLERK_CONFIG_DIR: configDir, + CLERK_PLATFORM_API_KEY: "", + CLERK_TELEMETRY_DISABLED: "1", + }) + .quiet() + .nothrow(); + const output = result.stdout.toString() + result.stderr.toString(); + log(`prebuilt auth dry-run output:\n${output}`); + + expect(result.exitCode).toBe(0); + expect(output).toContain( + "Add ClerkKitUI's documented UserButton entry, AuthView sheet, and image prefetching to MyApp/ContentView.swift", + ); + expect(output).not.toContain("pending session tasks"); + expect(output).toContain( + "No files, Xcode settings, Clerk applications, or remote resources were changed.", + ); + expect(output).not.toContain("pk_test_"); + + expect(await Bun.file(join(projectDir, "MyApp", "MyAppApp.swift")).text()).toBe(pristineApp); + expect(await Bun.file(join(projectDir, "MyApp", "ContentView.swift")).text()).toBe( + pristineContentView, + ); + expect(await Bun.file(join(projectDir, ".env")).exists()).toBe(false); + expect(await Bun.file(join(projectDir, "MyApp", "LocalSecrets.plist")).exists()).toBe(false); + const status = await Bun.$`git status --porcelain`.cwd(projectDir).quiet().nothrow(); + expect(status.stdout.toString()).toBe(""); + } finally { + await Promise.all( + [projectDir, configDir].map((path) => + rm(path, { recursive: true, force: true }).catch((err) => log(`rm: ${err}`)), + ), + ); + } + }, + { timeout: 30_000 }, +); + test.each([...PLATFORMS])( - "clerk init on a $fixture project pulls keys and writes nothing else", - async ({ fixture, detectedName, instructions }) => { + "clerk init sets up a $fixture project within its native write boundary", + async ({ fixture, detectedName, instructions, expectedGitEntries }) => { const platformAPIKey = process.env.CLERK_PLATFORM_API_KEY; if (!platformAPIKey) throw new Error("Missing required env var: CLERK_PLATFORM_API_KEY"); const tmp = await realpath(tmpdir()); const projectDir = await mkdtemp(join(tmp, `clerk-e2e-${fixture}-`)); const configDir = await mkdtemp(join(tmp, "clerk-e2e-config-")); + let iosPlatformStub: ReturnType | undefined; try { await cp(join(FIXTURES_DIR, fixture), projectDir, { recursive: true }); + const pristineIOSContentView = + fixture === "ios" + ? await Bun.file(join(projectDir, "MyApp", "ContentView.swift")).text() + : undefined; + if (fixture === "ios") { + iosPlatformStub = startIOSPlatformStub(process.env.CLERK_CLI_TEST_APP_ID!); + } await gitInit(projectDir); - await linkProject(projectDir, configDir); + await linkProject(projectDir, configDir, { + platformApiUrl: iosPlatformStub?.url.origin, + }); const result = await Bun.$`bun ${CLI_PATH} --mode human init --yes --no-skills` .cwd(projectDir) @@ -53,6 +171,7 @@ test.each([...PLATFORMS])( CLERK_CONFIG_DIR: configDir, CLERK_PLATFORM_API_KEY: platformAPIKey, CLERK_TELEMETRY_DISABLED: "1", + ...(iosPlatformStub ? { CLERK_PLATFORM_API_URL: iosPlatformStub.url.origin } : {}), }) .quiet() .nothrow(); @@ -65,27 +184,86 @@ test.each([...PLATFORMS])( expect(output).toContain(instruction); } - // Keys were pulled into .env (natives configure the publishable key in - // source, so the quickstart tells users to copy it from here). - const envFile = Bun.file(join(projectDir, ".env")); - expect(await envFile.exists()).toBe(true); - const envVars = parseEnv(await envFile.text()) as Record; - expect(envVars["CLERK_PUBLISHABLE_KEY"]).toStartWith("pk_"); - // Deliberately absent: native apps never use the secret key, and their - // default .gitignore templates don't cover .env, so pull skips it. - expect(envVars["CLERK_SECRET_KEY"]).toBeUndefined(); - - // Native scaffolding is instruction-only by design: the only thing init - // may leave behind is the env file. Everything else was committed by - // gitInit, so any other entry here is an unexpected write. + if (fixture === "ios") { + // Fresh SwiftUI setup writes the public development key directly to + // the proven @main source, never through an unused native dotenv/plist. + expect(await Bun.file(join(projectDir, ".env")).exists()).toBe(false); + expect(await Bun.file(join(projectDir, "MyApp", "LocalSecrets.plist")).exists()).toBe( + false, + ); + + const project = await Bun.file( + join(projectDir, "MyApp.xcodeproj", "project.pbxproj"), + ).text(); + expect(project).toContain("https://github.com/clerk/clerk-ios"); + const archive = parsePbxProject(project) as unknown as { + objects: Record>; + }; + const target = Object.values(archive.objects).find( + (object) => object.isa === "PBXNativeTarget" && object.name === "MyApp", + ); + const dependencyIds = target?.packageProductDependencies; + expect(Array.isArray(dependencyIds)).toBe(true); + const products = (dependencyIds as string[]) + .map((id) => archive.objects[id]?.productName) + .sort((left, right) => String(left).localeCompare(String(right))); + expect(products).toEqual(["ClerkKit", "ClerkKitUI"]); + + const frameworkPhaseId = (target?.buildPhases as string[] | undefined)?.find( + (id) => archive.objects[id]?.isa === "PBXFrameworksBuildPhase", + ); + const buildFileIds = archive.objects[frameworkPhaseId!]?.files as string[]; + const linkedProducts = buildFileIds + .map((id) => archive.objects[id]?.productRef) + .map((id) => archive.objects[id as string]?.productName) + .sort((left, right) => String(left).localeCompare(String(right))); + expect(linkedProducts).toEqual(["ClerkKit", "ClerkKitUI"]); + + const sourcePhaseId = (target?.buildPhases as string[] | undefined)?.find( + (id) => archive.objects[id]?.isa === "PBXSourcesBuildPhase", + ); + const sourceBuildFileIds = archive.objects[sourcePhaseId!]?.files as string[]; + const sourceMembers = sourceBuildFileIds + .map((id) => archive.objects[id]?.fileRef) + .map((id) => archive.objects[id as string]?.path) + .sort((left, right) => String(left).localeCompare(String(right))); + expect(sourceMembers).toEqual(["ContentView.swift", "MyAppApp.swift"]); + + const source = await Bun.file(join(projectDir, "MyApp", "MyAppApp.swift")).text(); + expect(source).toContain("import ClerkKit"); + expect(source.match(/Clerk\.configure\(publishableKey:/g)).toHaveLength(1); + expect(source).toContain(".environment(Clerk.shared)"); + const inlineKey = source.match(/Clerk\.configure\(publishableKey:\s*"([^"]+)"\)/)?.[1]; + expect(inlineKey).toStartWith("pk_test_"); + expect(output).not.toContain(inlineKey!); + + // --yes authorizes the core SDK/configuration work; it does not opt + // into replacing even this proven canonical placeholder with AuthView. + const contentView = await Bun.file(join(projectDir, "MyApp", "ContentView.swift")).text(); + expect(contentView).toBe(pristineIOSContentView!); + expect(contentView).not.toContain("AuthView("); + expect(contentView).not.toContain("UserButton("); + expect(contentView).not.toContain("Clerk.preview()"); + expect(contentView).not.toContain(inlineKey!); + } else { + const envFile = Bun.file(join(projectDir, ".env")); + expect(await envFile.exists()).toBe(true); + const envVars = parseEnv(await envFile.text()) as Record; + expect(envVars["CLERK_PUBLISHABLE_KEY"]).toStartWith("pk_"); + expect(envVars["CLERK_SECRET_KEY"]).toBeUndefined(); + } + + // Everything was committed by gitInit. Only the declared native setup + // files and any platform-consumed publishable-key file may remain changed. const status = await Bun.$`git status --porcelain`.cwd(projectDir).quiet().nothrow(); const entries = status.stdout .toString() .split("\n") .map((line) => line.trim()) .filter(Boolean); - expect(entries).toEqual(["?? .env"]); + expect(entries.sort()).toEqual([...expectedGitEntries].sort()); } finally { + if (iosPlatformStub) await iosPlatformStub.stop(true); await rm(projectDir, { recursive: true, force: true }).catch((err) => log(`rm: ${err}`)); await rm(configDir, { recursive: true, force: true }).catch((err) => log(`rm: ${err}`)); } From 801348d2db0aa60f3f4951548c664fbea4397f04 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 18:24:57 -0400 Subject: [PATCH 02/55] fix(init): harden iOS native reconciliation --- .../src/commands/init/index-ios.test.ts | 158 ++++++++++++++++++ packages/cli-core/src/commands/init/index.ts | 43 ++++- .../commands/init/ios/native-remote.test.ts | 101 +++++++++-- .../src/commands/init/ios/native-remote.ts | 117 +++++++++++-- 4 files changed, 384 insertions(+), 35 deletions(-) diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index 1f70267ea..9d1cce1c6 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -30,6 +30,7 @@ import type { IOSLocalSetupResult } from "./ios/apply.ts"; import type { IOSAppleEntitlementPlan } from "./ios/apple-entitlement.ts"; import type { IOSNativeApplePlan } from "./ios/native-apple.ts"; import type { IOSNativeRemotePlan } from "./ios/native-remote.ts"; +import type { IOSNativeReadinessTarget } from "./ios/native-readiness.ts"; import type { IOSPrebuiltAuthPlan } from "./ios/prebuilt-auth.ts"; const VALID_DEVELOPMENT_KEY = `pk_test_${btoa("example.clerk.accounts.dev$")}`; @@ -138,6 +139,14 @@ function iosSetupResult(overrides: Partial = {}): IOSLocalS }; } +function selectedNativeTarget( + overrides: Partial> = {}, +): Extract { + const target = FAKE_IOS_NATIVE_READINESS.target; + if (target.status !== "selected") throw new Error("Expected a selected iOS test target"); + return { ...target, ...overrides }; +} + describe("init iOS", () => { const { setup, track } = useInitHarness(); @@ -241,6 +250,155 @@ describe("init iOS", () => { } }); + test("requires a confirmed App ID Prefix before an agent creates or links a new application", async () => { + setup({ isAgent: true, email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ + requiresLinkedApp: true, + nativeReadiness: { + ...FAKE_IOS_NATIVE_READINESS, + target: selectedNativeTarget({ + appIdPrefix: { status: "missing", source: "literal-entitlements" }, + }), + }, + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + }), + ); + + await expect(init({ yes: true })).rejects.toThrow( + "Ask the user whether to use ABCDE12345 or enter a different App ID Prefix", + ); + + expect(linkMod.link).not.toHaveBeenCalled(); + expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + }); + + test("names an agent-created Clerk application after the selected Xcode target", async () => { + setup({ isAgent: true, email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ profile: { appId: "app_test" } } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ + targetName: "AnotherPromptTest", + requiresLinkedApp: true, + nativeReadiness: { + ...FAKE_IOS_NATIVE_READINESS, + target: selectedNativeTarget({ + projectPath: "ContainerProject.xcodeproj", + targetName: "AnotherPromptTest", + appIdPrefix: { status: "missing", source: "literal-entitlements" }, + }), + }, + }), + ); + + await init({ yes: true, appIdPrefix: "CONFIRMED123" }); + + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: undefined, + cwd: iosCtx.cwd, + createIfMissing: "AnotherPromptTest", + skipAutolink: true, + }); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith( + expect.objectContaining({ + appIdPrefix: "CONFIRMED123", + applicationLinkChange: "created-and-linked", + }), + ); + }); + + test("lets an explicit existing app supply its registered prefix without auto-creation", async () => { + setup({ isAgent: true, email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ profile: { appId: "app_existing" } } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ + requiresLinkedApp: true, + nativeReadiness: { + ...FAKE_IOS_NATIVE_READINESS, + target: selectedNativeTarget({ + appIdPrefix: { status: "missing", source: "literal-entitlements" }, + }), + }, + }), + ); + spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ + appId: "app_existing", + instanceId: "ins_existing", + instanceLabel: "development", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan({ + applicationId: "app_existing", + instanceId: "ins_existing", + appIdPrefix: "REGISTERED123", + nativeApi: "satisfied", + registration: "satisfied", + status: "satisfied", + actions: [], + }), + ); + + await init({ yes: true, app: "app_existing" }); + + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: "app_existing", + cwd: iosCtx.cwd, + createIfMissing: undefined, + skipAutolink: true, + }); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith( + expect.objectContaining({ + appIdPrefix: undefined, + applicationLinkChange: "link-updated", + }), + ); + }); + + test("does not auto-create a replacement when an existing iOS link disappears", async () => { + setup({ isAgent: true, email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile") + .mockResolvedValueOnce({ profile: { appId: "app_existing" } } as never) + .mockResolvedValue(undefined); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ requiresLinkedApp: true }), + ); + + await expect(init({ yes: true })).rejects.toThrow( + "The Clerk application link could not be verified", + ); + + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: undefined, + cwd: iosCtx.cwd, + createIfMissing: undefined, + skipAutolink: true, + }); + expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + }); + test("rejects --allow-dirty with --dry-run before project work", async () => { setup(); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 4d223f727..58d73f370 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -83,6 +83,7 @@ import { } from "./ios/apply.ts"; import { applyIOSNativeRemoteSetup, + assertIOSAppIdPrefixBeforeApplicationCreation, prepareIOSNativeRemoteSetup, validateAppIdPrefix, } from "./ios/native-remote.ts"; @@ -435,19 +436,35 @@ export async function init(options: InitOptions = {}) { assertKeylessOnlyFlags(options, strategy); let authenticatedAppId: string | undefined; + let iosApplicationLinkChange: "created-and-linked" | "link-updated" | undefined; if (strategy === "authenticate") { setTelemetryStage("link"); + if (agent && iosLocalSetup?.requiresLinkedApp && !iosProfile && !options.app) { + assertIOSAppIdPrefixBeforeApplicationCreation({ + target: iosLocalSetup.nativeReadiness.target, + appIdPrefix: options.appIdPrefix, + ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion + ? { unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion } + : {}), + }); + } bar(); - const createIfMissing = agent - ? await deriveProjectName(ctx.cwd, bootstrap?.projectName) + const mayCreateApplication = + agent && (ctx.framework.dep !== "ios" || (!iosProfile && !options.app)); + const createIfMissing = mayCreateApplication + ? await deriveProjectName(ctx.cwd, bootstrap?.projectName ?? iosLocalSetup?.targetName) : undefined; - authenticatedAppId = await authenticateAndLink( + const authenticated = await authenticateAndLink( ctx.cwd, options.app, createIfMissing, iosLocalSetup?.requiresLinkedApp === true, preauthenticatedIOSLabel, ); + authenticatedAppId = authenticated.applicationId; + if (ctx.framework.dep === "ios") { + iosApplicationLinkChange = authenticated.applicationLinkChange; + } } let authenticatedKeysHandled = false; @@ -529,6 +546,7 @@ export async function init(options: InitOptions = {}) { ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion } : {}), + ...(iosApplicationLinkChange ? { applicationLinkChange: iosApplicationLinkChange } : {}), agent, yes: options.yes === true, }); @@ -1095,7 +1113,10 @@ async function authenticateAndLink( createIfMissing: string | undefined, requireLinkedAppId: boolean, preauthenticatedLabel?: string, -): Promise { +): Promise<{ + applicationId?: string; + applicationLinkChange?: "created-and-linked" | "link-updated"; +}> { const label = preauthenticatedLabel ?? (await resolveAuthLabel()); const profile = await resolveProfile(cwd); @@ -1103,7 +1124,7 @@ async function authenticateAndLink( if (label && alreadyOnRequestedApp) { log.info(dim(`${label} · Linked to ${profile.profile.appId}`)); - return profile.profile.appId; + return { applicationId: profile.profile.appId }; } if (label) { @@ -1131,7 +1152,17 @@ async function authenticateAndLink( code: ERROR_CODE.NOT_LINKED, }); } - return linked?.profile.appId; + const applicationId = linked?.profile.appId; + const applicationLinkChange = + applicationId && !profile && !app && createIfMissing + ? ("created-and-linked" as const) + : applicationId && profile?.profile.appId !== applicationId + ? ("link-updated" as const) + : undefined; + return { + applicationId, + ...(applicationLinkChange ? { applicationLinkChange } : {}), + }; } // --- Keyless app setup --- 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 1ba35a8c8..3df564cf2 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 @@ -392,25 +392,100 @@ describe("Clerk Native Application remote setup", () => { }); }); - test("requires an explicit prefix in agent mode instead of prompting", async () => { + test("offers an unverified Xcode suggestion in agent mode instead of prompting", async () => { const { api } = scriptedAPI({ nativeReads: [nativeSettings(false)], registrationReads: [[]], }); - await expect( - prepareIOSNativeRemoteSetup( - prepareOptions({ - target: selectedTarget({ appIdPrefix: null }), - unverifiedAppIdPrefixSuggestion: { - source: "xcode-development-team", - value: "ABCDE12345", - }, - agent: true, + const error = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + agent: true, + }), + { api, prompts: prompts() }, + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("ABCDE12345"); + expect(message).toContain("Xcode DEVELOPMENT_TEAM"); + expect(message).toContain("unverified suggestion"); + expect(message).toContain("Ask the user whether to use ABCDE12345 or enter a different"); + expect(message).toContain('--app-id-prefix ""'); + }); + + test("offers partial literal entitlement evidence in agent mode", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const error = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: [LOCAL_PREFIX], }), - { api, prompts: prompts() }, - ), - ).rejects.toThrow("requires --app-id-prefix"); + agent: true, + }), + { api, prompts: prompts() }, + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain(LOCAL_PREFIX); + expect(message).toContain("literal App ID Prefix evidence"); + expect(message).toContain("unverified suggestion"); + expect(message).toContain(`Ask the user whether to use ${LOCAL_PREFIX} or enter a different`); + }); + + test("directs the agent to Apple Developer when no prefix suggestion exists", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const error = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + agent: true, + }), + { api, prompts: prompts() }, + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("requires --app-id-prefix"); + expect(message).toContain("copy the value labeled App ID Prefix in Apple Developer"); + expect(message).toContain('--app-id-prefix ""'); + }); + + test("reports an application link that changed before a missing-prefix block", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + const error = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + applicationLinkChange: "link-updated", + agent: true, + }), + { api, prompts: prompts() }, + ).catch((cause: unknown) => cause); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("The project's Clerk application link was updated"); + expect((error as Error).message).toContain( + "no Xcode or Clerk Native Application settings changes were written", + ); + expect((error as Error).message).not.toContain("No local or remote setup changes were written"); }); test("blocks an explicit prefix that conflicts with a partial local candidate", () => { 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 0a9d51f39..ce2920964 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -94,6 +94,8 @@ export interface PrepareIOSNativeRemoteSetupOptions { target: IOSNativeReadinessTarget; appIdPrefix?: string; unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; + /** A completed application/link change that must be reported if planning stops here. */ + applicationLinkChange?: "created-and-linked" | "link-updated"; agent: boolean; yes: boolean; } @@ -338,6 +340,93 @@ function formatBlockers(plan: IOSNativeRemotePlan): string { return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); } +function nativeSetupOutcome( + applicationLinkChange?: PrepareIOSNativeRemoteSetupOptions["applicationLinkChange"], +): string { + return applicationLinkChange === "created-and-linked" + ? "A new Clerk application was created and linked, but no Xcode or Clerk Native Application settings changes were written." + : applicationLinkChange === "link-updated" + ? "The project's Clerk application link was updated, but no Xcode or Clerk Native Application settings changes were written." + : "No local or remote setup changes were written."; +} + +function agentAppIdPrefixRequiredMessage( + bundleIdentifier: string, + suggestion?: IOSNativeRemoteAppIdPrefixSuggestion, + applicationLinkChange?: PrepareIOSNativeRemoteSetupOptions["applicationLinkChange"], +): string { + const retry = + 'After the user confirms the value, rerun the same command with --app-id-prefix "".'; + const outcome = nativeSetupOutcome(applicationLinkChange); + + if (!suggestion) { + return `Registering ${bundleIdentifier} in agent mode requires --app-id-prefix . Ask the user to copy the value labeled App ID Prefix in Apple Developer. ${retry} ${outcome}`; + } + + const source = + suggestion.source === "xcode-development-team" + ? "the selected target's Xcode DEVELOPMENT_TEAM setting. DEVELOPMENT_TEAM often matches the Apple App ID Prefix, but older Apple Developer accounts can differ" + : "literal App ID Prefix evidence found in only some of the selected target's entitlement configurations, so it could not be verified across the whole target"; + + return `Registering ${bundleIdentifier} in agent mode requires a confirmed App ID Prefix through --app-id-prefix . The CLI found ${suggestion.value} from ${source}. Treat it only as an unverified suggestion: do not use it automatically. Ask the user whether to use ${suggestion.value} or enter a different App ID Prefix. ${retry} ${outcome}`; +} + +function appIdPrefixSuggestion( + target: IOSNativeReadinessTarget, + unverifiedSuggestion?: IOSUnverifiedAppIdPrefixSuggestion, +): IOSNativeRemoteAppIdPrefixSuggestion | undefined { + const literalSuggestion = + target.status === "selected" && target.appIdPrefix.status === "missing" + ? target.appIdPrefix.candidates?.length === 1 + ? { + source: "partial-literal-entitlements" as const, + value: target.appIdPrefix.candidates[0]!, + } + : undefined + : undefined; + return literalSuggestion ?? unverifiedSuggestion; +} + +/** + * A newly created Clerk application cannot already contain the selected iOS + * registration. Stop before application creation when agent mode still needs + * the user to confirm an App ID Prefix. + */ +export function assertIOSAppIdPrefixBeforeApplicationCreation(options: { + target: IOSNativeReadinessTarget; + appIdPrefix?: string; + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; +}): void { + const plan = buildIOSNativeRemotePlan({ + applicationId: "preflight", + instanceId: "preflight", + target: options.target, + requestedAppIdPrefix: options.appIdPrefix, + nativeSettings: { object: "native_settings", api_enabled: false }, + registrations: [], + }); + if (plan.status !== "blocked") return; + + const onlyMissingPrefix = + plan.blockers.length === 1 && + plan.blockers[0]?.code === "app-id-prefix-required" && + options.appIdPrefix == null && + plan.bundleIdentifier != null; + if (!onlyMissingPrefix) { + throw iosRemoteError( + `Clerk Native Application readiness could not be completed safely. No local or remote setup changes were written:\n${formatBlockers(plan)}`, + ERROR_CODE.IOS_SETUP_BLOCKED, + ); + } + + throwUsageError( + agentAppIdPrefixRequiredMessage( + plan.bundleIdentifier!, + appIdPrefixSuggestion(options.target, options.unverifiedAppIdPrefixSuggestion), + ), + ); +} + export async function prepareIOSNativeRemoteSetup( options: PrepareIOSNativeRemoteSetupOptions, dependencies: { @@ -356,7 +445,7 @@ export async function prepareIOSNativeRemoteSetup( log.debug(`Could not inspect Clerk Native Application settings: ${errorMessage(error)}`); rethrowKnownRemoteError(error); throw iosRemoteError( - "Clerk Native Application settings could not be inspected. No local or remote setup changes were written; verify your application access and rerun clerk init.", + `Clerk Native Application settings could not be inspected. ${nativeSetupOutcome(options.applicationLinkChange)} Verify your application access and rerun clerk init.`, ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, ); } @@ -375,24 +464,20 @@ export async function prepareIOSNativeRemoteSetup( options.appIdPrefix == null && plan.bundleIdentifier != null; if (onlyMissingPrefix) { + const suggestion = appIdPrefixSuggestion( + options.target, + options.unverifiedAppIdPrefixSuggestion, + ); if (options.agent) { throwUsageError( - `Registering ${plan.bundleIdentifier} in agent mode requires --app-id-prefix . Verify the App ID Prefix in Apple Developer, then rerun. No local or remote setup changes were written.`, + agentAppIdPrefixRequiredMessage( + plan.bundleIdentifier!, + suggestion, + options.applicationLinkChange, + ), ); } - const literalSuggestion = - options.target.status === "selected" && options.target.appIdPrefix.status === "missing" - ? options.target.appIdPrefix.candidates?.length === 1 - ? { - source: "partial-literal-entitlements" as const, - value: options.target.appIdPrefix.candidates[0]!, - } - : undefined - : undefined; - const appIdPrefix = await prompts.appIdPrefix( - plan.bundleIdentifier!, - literalSuggestion ?? options.unverifiedAppIdPrefixSuggestion, - ); + const appIdPrefix = await prompts.appIdPrefix(plan.bundleIdentifier!, suggestion); plan = buildIOSNativeRemotePlan({ applicationId: options.applicationId, instanceId: options.instanceId, @@ -404,7 +489,7 @@ export async function prepareIOSNativeRemoteSetup( if (plan.status === "blocked") { throw iosRemoteError( - `Clerk Native Application readiness could not be completed safely. No local or remote setup changes were written:\n${formatBlockers(plan)}\n Review https://dashboard.clerk.com/~/native-applications`, + `Clerk Native Application readiness could not be completed safely. ${nativeSetupOutcome(options.applicationLinkChange)}\n${formatBlockers(plan)}\n Review https://dashboard.clerk.com/~/native-applications`, ERROR_CODE.IOS_SETUP_BLOCKED, ); } From b35badae966822a2828b90ffeef62cfa8e6b3b5d Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 20:56:11 -0400 Subject: [PATCH 03/55] fix(init): verify bundled iOS plist parsing --- .../commands/init/ios/apple-entitlement.ts | 4 +- .../commands/init/ios/compiled-cli.test.ts | 143 ++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/compiled-cli.test.ts diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts index 17b6ef3e0..ae10fd419 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -1,6 +1,5 @@ import { lstat, readFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; -import plist from "@expo/plist"; import { planIOSAssociatedDomain, type IOSAssociatedDomainBlockerCode, @@ -19,6 +18,7 @@ import { type IOSMissingEntitlementsSettingsPlan, } from "./entitlements-settings.ts"; import { isRecord } from "./pbx.ts"; +import { parseIOSPlist } from "./plist.ts"; const APPLE_SIGN_IN_KEY = "com.apple.developer.applesignin"; const APPLE_SIGN_IN_VALUE = "Default"; @@ -229,7 +229,7 @@ function inspectEntitlementsBytes( } const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf; const source = new TextDecoder("utf-8", { fatal: true }).decode(bom ? bytes.slice(3) : bytes); - const parsed: unknown = plist.parse(source); + const parsed = parseIOSPlist(source); if (!isRecord(parsed)) throw new Error("plist root is not a dictionary"); const rawValue = parsed[APPLE_SIGN_IN_KEY]; const structure = appleKeyStructure(source); diff --git a/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts b/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts new file mode 100644 index 000000000..c35994be0 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts @@ -0,0 +1,143 @@ +import { expect, setDefaultTimeout, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { createIOSFixture } from "./test-helpers.ts"; + +setDefaultTimeout(30_000); + +const cliEntry = resolve(import.meta.dir, "../../../cli.ts"); +const repositoryRoot = resolve(import.meta.dir, "../../../../../.."); + +function isolatedCLIEnvironment(configDir: string): Record { + const env: Record = { ...Bun.env }; + for (const key of Object.keys(env)) { + if (key.includes("CLERK")) delete env[key]; + } + delete env.CI; + delete env.DO_NOT_TRACK; + delete env.NO_UPDATE_NOTIFIER; + return { + ...env, + NO_COLOR: "1", + CLERK_CONFIG_DIR: configDir, + CLERK_TELEMETRY_DISABLED: "1", + }; +} + +async function run( + command: string[], + options: { cwd: string; env?: Record }, +) { + const child = Bun.spawn(command, { + ...options, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exitCode }; +} + +test("the compiled CLI semantically parses iOS XML plists", async () => { + const temporaryRoot = await mkdtemp(join(tmpdir(), "clerk-ios-compiled-cli-")); + try { + const binary = join(temporaryRoot, "clerk"); + const fixtureRoot = join(temporaryRoot, "fixture"); + const configDir = join(temporaryRoot, "config"); + await mkdir(configDir); + await createIOSFixture(fixtureRoot, { + complete: true, + includeKey: false, + localSecrets: true, + }); + await Bun.write( + join(fixtureRoot, "MyApp", "LocalSecrets.plist"), + 'CLERK_PUBLISHABLE_KEYreplace-me', + ); + await Bun.write( + join(configDir, "config.json"), + `${JSON.stringify({ + profiles: {}, + telemetryNoticeShown: true, + machineUuid: "00000000-0000-4000-8000-000000000000", + })}\n`, + ); + + const compiled = await run( + [ + process.execPath, + "build", + "--compile", + "--minify", + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", + cliEntry, + "--outfile", + binary, + ], + { cwd: repositoryRoot }, + ); + expect(compiled.exitCode, `${compiled.stdout}\n${compiled.stderr}`).toBe(0); + + const result = await run( + [binary, "--mode", "human", "init", "--dry-run", "--json", "--sign-in-with-apple"], + { cwd: fixtureRoot, env: isolatedCLIEnvironment(configDir) }, + ); + expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.stderr).toBe(""); + + const output = JSON.parse(result.stdout) as { + inspection: { + appTargets: Array<{ + configurations: Array<{ + entitlements?: { + associatedDomains: string[]; + literalAppIdentifierPrefix?: string; + }; + }>; + }>; + diagnostics: Array<{ code: string }>; + }; + plan: { + steps: Array<{ id: string; status: string; automatable: boolean }>; + }; + }; + const configurations = output.inspection.appTargets[0]?.configurations ?? []; + expect(configurations).toHaveLength(2); + expect(configurations.map((configuration) => configuration.entitlements)).toEqual([ + expect.objectContaining({ + associatedDomains: ["webcredentials:clerk.example.test"], + literalAppIdentifierPrefix: "LEGACY1234", + }), + expect.objectContaining({ + associatedDomains: ["webcredentials:clerk.example.test"], + literalAppIdentifierPrefix: "LEGACY1234", + }), + ]); + expect(output.inspection.diagnostics.map((diagnostic) => diagnostic.code)).not.toContain( + "xcode.unreadable-entitlements", + ); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "configure-publishable-key", + status: "required", + automatable: true, + }), + ); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "enable-native-apple", + status: "required", + automatable: true, + }), + ); + expect(result.stdout).not.toContain("unreadable-entitlements"); + expect(result.stdout).not.toContain("unreadable-local-secrets"); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +}); From 14566862f36c5ab31c372be32a5a42938d0a33ab Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 22:44:33 -0400 Subject: [PATCH 04/55] refactor(init): scope public key lookup to iOS --- .../cli-core/src/commands/env/pull.test.ts | 165 ------------------ packages/cli-core/src/commands/env/pull.ts | 123 ++----------- .../src/commands/init/index-ios.test.ts | 121 +++++++------ .../cli-core/src/commands/init/index.test.ts | 3 +- packages/cli-core/src/commands/init/index.ts | 17 +- .../commands/init/ios/development-key.test.ts | 78 +++++++++ .../src/commands/init/ios/development-key.ts | 41 +++++ packages/cli-core/src/lib/config-instance.ts | 61 ------- packages/cli-core/src/lib/config.ts | 68 +++++++- .../cli-core/src/test/lib/init-harness.ts | 7 +- 10 files changed, 275 insertions(+), 409 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/development-key.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/development-key.ts delete mode 100644 packages/cli-core/src/lib/config-instance.ts diff --git a/packages/cli-core/src/commands/env/pull.test.ts b/packages/cli-core/src/commands/env/pull.test.ts index 383e9e298..e5aa89f47 100644 --- a/packages/cli-core/src/commands/env/pull.test.ts +++ b/packages/cli-core/src/commands/env/pull.test.ts @@ -9,7 +9,6 @@ import { stubFetch, useCaptureLog, } from "../../test/lib/stubs.ts"; -import { resolveFetchedApplicationInstance } from "../../lib/config-instance.ts"; mock.module("../../lib/credential-store.ts", () => credentialStoreStubs); mock.module("../../lib/git.ts", () => gitStubs); @@ -30,7 +29,6 @@ mock.module("../../lib/spinner.ts", () => ({ type Profile = { workspaceId: string; appId: string; instances: Record }; const _profiles: Record = {}; -let _resolveAppContextCalls = 0; const INSTANCE_ALIASES: Record = { dev: "development", development: "development", @@ -56,9 +54,7 @@ mock.module("../../lib/config.ts", () => ({ if (!id) throw new Error(`No ${env} instance configured. Run \`clerk link\` to set one up.`); return { id, label: env }; }, - resolveFetchedApplicationInstance, resolveAppContext: async (options: { app?: string; instance?: string; cwd?: string }) => { - _resolveAppContextCalls++; if (options.app) { const app = { application_id: "app_1", @@ -158,7 +154,6 @@ describe("env pull", () => { beforeEach(async () => { Object.keys(_profiles).forEach((k) => delete _profiles[k]); - _resolveAppContextCalls = 0; tempDir = await mkdtemp(join(tmpdir(), "clerk-env-pull-test-")); _setConfigDir(tempDir); process.env.CLERK_PLATFORM_API_KEY = "test_key"; @@ -199,160 +194,6 @@ describe("env pull", () => { return pull(options); } - async function resolveKeys( - options: { - app?: string; - instance?: string; - cwd?: string; - includeSecretKey?: boolean; - } = {}, - ) { - const { resolveEnvironmentKeys } = await import("./pull.ts"); - return resolveEnvironmentKeys(options); - } - - test("resolves the linked development publishable key in memory without requesting secrets", async () => { - await setProfile(tempDir, { - workspaceId: "org_1", - appId: "app_1", - instances: { development: "ins_dev", production: "ins_prod" }, - }); - let requestedUrl = ""; - stubFetch(async (input) => { - requestedUrl = input.toString(); - return new Response(JSON.stringify(mockApplication), { status: 200 }); - }); - - const keys = await resolveKeys({ cwd: tempDir }); - - expect(keys).toEqual({ - appId: "app_1", - instanceId: "ins_dev", - instanceLabel: "development", - publishableKey: "pk_test_abc123", - }); - expect(new URL(requestedUrl).searchParams.has("include_secret_keys")).toBe(false); - expect(await Bun.file(join(tempDir, ".env")).exists()).toBe(false); - expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); - expect(captured.out).not.toContain("pk_test_abc123"); - expect(captured.err).not.toContain("pk_test_abc123"); - expect(captured.out).not.toContain("sk_test_xyz789"); - expect(captured.err).not.toContain("sk_test_xyz789"); - }); - - test("returns a secret key only when explicitly requested", async () => { - await setProfile(tempDir, { - workspaceId: "org_1", - appId: "app_1", - instances: { development: "ins_dev" }, - }); - let requestedUrl = ""; - stubFetch(async (input) => { - requestedUrl = input.toString(); - return new Response(JSON.stringify(mockApplication), { status: 200 }); - }); - - const keys = await resolveKeys({ cwd: tempDir, includeSecretKey: true }); - - expect(keys.secretKey).toBe("sk_test_xyz789"); - expect(new URL(requestedUrl).searchParams.get("include_secret_keys")).toBe("true"); - expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); - }); - - test("resolves an explicit app's development key with one public-only request and no profile lookup", async () => { - const exactApp = { - application_id: "app_exact", - instances: [mockApplication.instances[1], mockApplication.instances[0]], - }; - const requestedUrls: string[] = []; - stubFetch(async (input) => { - requestedUrls.push(input.toString()); - return new Response(JSON.stringify(exactApp), { status: 200 }); - }); - - const keys = await resolveKeys({ - app: "app_exact", - cwd: join(tempDir, "unlinked"), - includeSecretKey: true, - }); - - expect(keys).toEqual({ - appId: "app_exact", - instanceId: "ins_dev", - instanceLabel: "development", - publishableKey: "pk_test_abc123", - }); - expect(requestedUrls).toHaveLength(1); - const requestedUrl = new URL(requestedUrls[0]!); - expect(requestedUrl.pathname).toEndWith("/v1/platform/applications/app_exact"); - expect(requestedUrl.searchParams.has("include_secret_keys")).toBe(false); - expect(_resolveAppContextCalls).toBe(0); - expect(keys).not.toHaveProperty("secretKey"); - expect(await Bun.file(join(tempDir, ".env")).exists()).toBe(false); - expect(await Bun.file(join(tempDir, ".env.local")).exists()).toBe(false); - expect(captured.out).not.toContain("pk_test_abc123"); - expect(captured.err).not.toContain("pk_test_abc123"); - expect(captured.out).not.toContain("sk_test_xyz789"); - expect(captured.err).not.toContain("sk_test_xyz789"); - }); - - test("uses canonical instance selection for an explicit app", async () => { - let requestCount = 0; - stubFetch(async () => { - requestCount++; - return new Response(JSON.stringify(mockApplication), { status: 200 }); - }); - - const keys = await resolveKeys({ app: "app_1", instance: "prod" }); - - expect(keys).toEqual({ - appId: "app_1", - instanceId: "ins_prod", - instanceLabel: "production", - publishableKey: "pk_live_abc123", - }); - expect(requestCount).toBe(1); - expect(_resolveAppContextCalls).toBe(0); - }); - - test("propagates an inaccessible explicit-app fetch without logging credentials", async () => { - const publishableKey = "pk_test_must_not_be_logged"; - const secretKey = "sk_test_must_not_be_logged"; - let requestCount = 0; - stubFetch(async () => { - requestCount++; - return new Response( - JSON.stringify({ - errors: [ - { - code: "resource_not_found", - message: "Application is inaccessible", - meta: { publishableKey, secretKey }, - }, - ], - }), - { status: 404 }, - ); - }); - - let thrown: unknown; - try { - await resolveKeys({ app: "app_inaccessible" }); - } catch (error) { - thrown = error; - } - - const { PlapiError } = await import("../../lib/errors.ts"); - expect(thrown).toBeInstanceOf(PlapiError); - expect((thrown as { context?: string }).context).toBe("Failed to fetch API keys"); - expect(requestCount).toBe(1); - expect(_resolveAppContextCalls).toBe(0); - expect(captured.out).not.toContain(publishableKey); - expect(captured.err).not.toContain(publishableKey); - expect(captured.out).not.toContain(secretKey); - expect(captured.err).not.toContain(secretKey); - }); - test("errors when no profile is linked", async () => { await expect(runEnvPull()).rejects.toThrow("No Clerk project linked"); }); @@ -829,18 +670,12 @@ describe("env pull", () => { // Replace beforeEach's Express package.json with a native Xcode project marker. await rm(join(tempDir, "package.json"), { force: true }); await mkdir(join(tempDir, "MyApp.xcodeproj"), { recursive: true }); - let requestedUrl = ""; - stubFetch(async (input) => { - requestedUrl = input.toString(); - return new Response(JSON.stringify(mockApplication), { status: 200 }); - }); await runEnvPull(); const content = await Bun.file(join(tempDir, ".env")).text(); expect(content).toContain("CLERK_PUBLISHABLE_KEY=pk_test_abc123"); expect(content).not.toContain("CLERK_SECRET_KEY"); - expect(new URL(requestedUrl).searchParams.has("include_secret_keys")).toBe(false); }); describe("keyless", () => { diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index 477bf0e69..62b977fbd 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -1,9 +1,5 @@ import { resolve, join, basename } from "node:path"; -import { - resolveAppContext, - resolveFetchedApplicationInstance, - type AppContextOptions, -} from "../../lib/config.ts"; +import { resolveAppContext, type AppContextOptions } from "../../lib/config.ts"; import { fetchApplication } from "../../lib/plapi.ts"; import { parseEnvFile, mergeEnvVars, serializeEnvFile } from "../../lib/dotenv.ts"; import { @@ -29,27 +25,6 @@ interface EnvPullOptions extends AppContextOptions { file?: string; } -export interface ResolveEnvironmentKeysOptions { - /** Directory whose linked Clerk profile should be resolved. */ - cwd?: string; - /** Application ID to resolve directly without consulting a linked profile. */ - app?: string; - /** Instance alias or ID. Defaults to the linked development instance. */ - instance?: string; - /** Request the instance secret key as well as its publishable key. */ - includeSecretKey?: boolean; -} - -export interface ResolvedEnvironmentKeys { - appId: string; - instanceId: string; - instanceLabel: string; - publishableKey: string; - secretKey?: string; -} - -type ResolvedAppContext = Awaited>; - /** Check whether a file contains Clerk keys (for backwards compat detection). */ async function hasClerkKeys(path: string): Promise { const file = Bun.file(path); @@ -80,72 +55,6 @@ async function resolveTargetFile( return fallback; } -/** - * Resolve an application's selected instance keys without writing them or - * logging their values. With no explicit instance, linked profiles resolve to - * their development instance. Secret keys are neither requested nor returned - * unless the caller opts in. - * - * An explicit application is always resolved through a public-only request. - * This path never consults the current directory's linked profile and ignores - * `includeSecretKey`, so callers can safely resolve client-side credentials. - * - * `resolvedContext` lets command orchestrators that already resolved the - * instance reuse that result without repeating profile or application lookup. - */ -export async function resolveEnvironmentKeys( - options: ResolveEnvironmentKeysOptions, - resolvedContext?: ResolvedAppContext, -): Promise { - if (options.app) { - const app = await withApiContext( - fetchApplication(options.app, { includeSecretKeys: false }), - "Failed to fetch API keys", - ); - const resolved = resolveFetchedApplicationInstance(options.app, app, options.instance); - if (!resolved.found) { - throw new CliError( - `Instance ${resolved.instanceId} not found in application ${options.app}.`, - { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - docsUrl: "https://clerk.com/docs/guides/development/managing-environments", - }, - ); - } - - return { - appId: options.app, - instanceId: resolved.instanceId, - instanceLabel: resolved.instanceLabel, - publishableKey: resolved.instance.publishable_key, - }; - } - - const cwd = options.cwd ?? process.cwd(); - const ctx = resolvedContext ?? (await resolveAppContext({ instance: options.instance, cwd })); - const app = await withApiContext( - fetchApplication(ctx.appId, { includeSecretKeys: options.includeSecretKey === true }), - "Failed to fetch API keys", - ); - - const matched = app.instances.find((instance) => instance.instance_id === ctx.instanceId); - if (!matched) { - throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - docsUrl: "https://clerk.com/docs/guides/development/managing-environments", - }); - } - - return { - appId: ctx.appId, - instanceId: matched.instance_id, - instanceLabel: ctx.instanceLabel, - publishableKey: matched.publishable_key, - ...(options.includeSecretKey === true && - matched.secret_key && { secretKey: matched.secret_key }), - }; -} - export async function pull(options: EnvPullOptions): Promise { await withGutter("Pulling environment variables", async () => { const cwd = options.cwd ?? process.cwd(); @@ -159,30 +68,36 @@ export async function pull(options: EnvPullOptions): Promise { return; } - const [ctx, preferredEnvFile, framework] = await Promise.all([ + const [ctx, preferredEnvFile] = await Promise.all([ resolveAppContext({ ...options, cwd }), detectEnvFile(cwd), - detectFramework(cwd), ]); const targetFile = await resolveTargetFile(cwd, options.file, preferredEnvFile); const displayPath = options.file ?? basename(targetFile); - // Native platforms configure Clerk with only the publishable key. Avoid - // requesting a secret key that they cannot use; npm/server projects retain - // the existing key-pair behavior. - const includeSecretKey = isNpmFramework(framework ?? {}); await withSpinner(`Pulling env vars from ${ctx.instanceLabel} instance...`, async () => { - const keys = await resolveEnvironmentKeys( - { cwd, instance: options.instance, includeSecretKey }, - ctx, - ); + const app = await withApiContext(fetchApplication(ctx.appId), "Failed to fetch API keys"); + + const matched = app.instances.find((i) => i.instance_id === ctx.instanceId); + if (!matched) { + throw new CliError(`Instance ${ctx.instanceId} not found in application response.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + docsUrl: "https://clerk.com/docs/guides/development/managing-environments", + }); + } const publishableKeyName = await detectPublishableKeyName(cwd); const secretKeyName = await detectSecretKeyName(cwd); + // Native platforms (iOS/Android) configure Clerk with only the publishable + // key in client source; a secret key has no use there and their default + // .gitignore templates don't cover .env, so skip writing it entirely + // rather than leaving a live credential in a tracked file. + const framework = await detectFramework(cwd); + const includeSecretKey = isNpmFramework(framework ?? {}); await mergeKeysIntoEnvFile(targetFile, { - [publishableKeyName]: keys.publishableKey, - ...(keys.secretKey && { [secretKeyName]: keys.secretKey }), + [publishableKeyName]: matched.publishable_key, + ...(matched.secret_key && includeSecretKey && { [secretKeyName]: matched.secret_key }), }); }); diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index 9d1cce1c6..967d44df4 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -19,6 +19,7 @@ import { iosApplyMod, nativeRemoteMod, nativeAppleMod, + iosDevelopmentKeyMod, plapiMod, fapiMod, FAKE_IOS_NATIVE_READINESS, @@ -275,7 +276,7 @@ describe("init iOS", () => { ); expect(linkMod.link).not.toHaveBeenCalled(); - expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).not.toHaveBeenCalled(); expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); }); @@ -339,10 +340,9 @@ describe("init iOS", () => { }, }), ); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_existing", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_existing", instanceId: "ins_existing", - instanceLabel: "development", publishableKey: VALID_DEVELOPMENT_KEY, }); spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( @@ -396,7 +396,7 @@ describe("init iOS", () => { createIfMissing: undefined, skipAutolink: true, }); - expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); }); test("rejects --allow-dirty with --dry-run before project work", async () => { @@ -524,7 +524,7 @@ describe("init iOS", () => { }); expect(heuristics.installSdk).not.toHaveBeenCalled(); expect(pullMod.pull).not.toHaveBeenCalled(); - expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); }); test("forwards only an explicit prebuilt AuthView opt-in to iOS preflight", async () => { @@ -579,10 +579,9 @@ describe("init iOS", () => { profile: { appId: "app_test" }, } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_test", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", instanceId: "ins_test", - instanceLabel: "development", publishableKey: VALID_DEVELOPMENT_KEY, }); const environment = spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ @@ -630,10 +629,9 @@ describe("init iOS", () => { profile: { appId: "app_test" }, } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_test", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", instanceId: "ins_test", - instanceLabel: "development", publishableKey: VALID_DEVELOPMENT_KEY, }); spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ @@ -676,10 +674,9 @@ describe("init iOS", () => { profile: { appId: "app_test" }, } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_test", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", instanceId: "ins_test", - instanceLabel: "development", publishableKey: VALID_DEVELOPMENT_KEY, }); const environment = spyOn(fapiMod, "fetchUserSettings") @@ -734,10 +731,9 @@ describe("init iOS", () => { profile: { appId: "app_test" }, } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_test", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", instanceId: "ins_test", - instanceLabel: "development", publishableKey: VALID_DEVELOPMENT_KEY, }); spyOn(fapiMod, "fetchUserSettings").mockResolvedValue({ @@ -776,10 +772,9 @@ describe("init iOS", () => { profile: { appId: "app_test" }, } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_test", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", instanceId: "ins_test", - instanceLabel: "development", publishableKey: VALID_DEVELOPMENT_KEY, }); const secret = "provider-secret-must-not-escape"; @@ -847,10 +842,12 @@ describe("init iOS", () => { }); const preflightSpy = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); const linkSpy = spyOn(linkMod, "link").mockResolvedValue(undefined); - const resolveKeysSpy = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_test", + const resolveKeysSpy = spyOn( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_test", instanceId: "ins_test", - instanceLabel: "development", publishableKey: "pk_test_redacted", }); const applyPlannedSpy = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( @@ -863,11 +860,8 @@ describe("init iOS", () => { await init({ yes: true }); - expect(pullMod.resolveEnvironmentKeys).toHaveBeenCalledWith({ - app: "app_test", - cwd: iosCtx.cwd, - }); - expect(pullMod.resolveEnvironmentKeys).toHaveBeenCalledTimes(1); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).toHaveBeenCalledWith("app_test"); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).toHaveBeenCalledTimes(1); expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith( setupResult, "pk_test_redacted", @@ -899,10 +893,12 @@ describe("init iOS", () => { spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_test" }, } as never); - const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_test", + const resolveKeys = spyOn( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_test", instanceId: "ins_test", - instanceLabel: "development", publishableKey: "pk_test_must_not_be_forwarded", }); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); @@ -1171,10 +1167,9 @@ describe("init iOS", () => { spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( iosSetupResult({ runtimeKeyPlan, requiresLinkedApp: true }), ); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_changed", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_changed", instanceId: "ins_changed", - instanceLabel: "development", publishableKey: "pk_test_redacted", }); @@ -1197,12 +1192,11 @@ describe("init iOS", () => { profile: { appId: "app_production" }, } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_production", - instanceId: "ins_production", - instanceLabel: "production", - publishableKey: productionKey, - }); + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockRejectedValue( + new Error( + "Automatic iOS configuration is limited to the linked development instance. No local setup changes were written.", + ), + ); await expect(init({ yes: true })).rejects.toThrow("limited to the linked development instance"); @@ -1221,10 +1215,9 @@ describe("init iOS", () => { .mockResolvedValueOnce({ profile: { appId: "app_selected" } } as never) .mockResolvedValueOnce({ profile: { appId: "app_changed" } } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_selected", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_selected", instanceId: "ins_selected", - instanceLabel: "development", publishableKey: "pk_test_redacted", }); @@ -1251,10 +1244,9 @@ describe("init iOS", () => { const commit = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( new Error("The existing iOS runtime publishable key does not match the linked app."), ); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_same_profile", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_same_profile", instanceId: "ins_same_profile", - instanceLabel: "development", publishableKey: linkedKey, }); await expect(init({ yes: true, app: "app_same_profile" })).rejects.toThrow( @@ -1287,10 +1279,9 @@ describe("init iOS", () => { spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( new Error("The existing iOS runtime publishable key does not match the linked app."), ); - spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_implicitly_linked", + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_implicitly_linked", instanceId: "ins_implicitly_linked", - instanceLabel: "development", publishableKey: linkedKey, }); await expect(init({ yes: true })).rejects.toThrow("does not match the linked app"); @@ -1314,16 +1305,18 @@ describe("init iOS", () => { verifiesExistingKey: true, }); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_matching", + const resolveKeys = spyOn( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_matching", instanceId: "ins_matching", - instanceLabel: "development", publishableKey: linkedKey, }); await init({ yes: true }); expect(resolveKeys).toHaveBeenCalledTimes(1); - expect(resolveKeys).toHaveBeenCalledWith({ app: "app_matching", cwd: iosCtx.cwd }); + expect(resolveKeys).toHaveBeenCalledWith("app_matching"); expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, linkedKey); expect(pullMod.pull).not.toHaveBeenCalled(); expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); @@ -1338,10 +1331,12 @@ describe("init iOS", () => { .mockResolvedValueOnce({ profile: { appId: "app_previous" } } as never) .mockResolvedValueOnce({ profile: { appId: "app_previous" } } as never) .mockResolvedValue({ profile: { appId: "app_requested" } } as never); - const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_requested", + const resolveKeys = spyOn( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_requested", instanceId: "ins_requested", - instanceLabel: "development", publishableKey: key, }); const setupResult = iosSetupResult({ @@ -1392,10 +1387,12 @@ describe("init iOS", () => { .mockResolvedValueOnce(undefined) .mockResolvedValueOnce({ profile: { appId: "app_raced" } } as never) .mockResolvedValue({ profile: { appId: "app_requested" } } as never); - const resolveKeys = spyOn(pullMod, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_requested", + const resolveKeys = spyOn( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_requested", instanceId: "ins_requested", - instanceLabel: "development", publishableKey: key, }); const setupResult = iosSetupResult({ @@ -1407,7 +1404,7 @@ describe("init iOS", () => { await init({ yes: true, app: "app_requested" }); expect(resolveKeys).toHaveBeenCalledTimes(1); - expect(resolveKeys).toHaveBeenCalledWith({ app: "app_requested", cwd: iosCtx.cwd }); + expect(resolveKeys).toHaveBeenCalledWith("app_requested"); expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, key); expect(`${captured.out}\n${captured.err}`).not.toContain(key); }); @@ -1470,6 +1467,6 @@ describe("init iOS", () => { expect(bootstrapMod.promptAndBootstrap).not.toHaveBeenCalled(); expect(pullMod.pull).not.toHaveBeenCalled(); - expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 9eabd2f85..0311e3230 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -22,6 +22,7 @@ import { mockExistingProject, mockMiddlewareScaffold, iosApplyMod, + iosDevelopmentKeyMod, } from "../../test/lib/init-harness.ts"; import * as telemetryMod from "../../lib/telemetry.ts"; import { init } from "./index.ts"; @@ -92,7 +93,7 @@ describe("init", () => { name: "UserAbortError", }); - expect(pullMod.resolveEnvironmentKeys).not.toHaveBeenCalled(); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); expect(pullMod.pull).not.toHaveBeenCalled(); expect(iosApplyMod.applyIOSRuntimeKeySetup).not.toHaveBeenCalled(); }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 58d73f370..5245bb932 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -2,7 +2,7 @@ import { createOption } from "@commander-js/extra-typings"; import type { Program } from "../../cli-program.ts"; import { login } from "../auth/login.js"; import { link } from "../link/index.js"; -import { pull, resolveEnvironmentKeys } from "../env/pull.js"; +import { pull } from "../env/pull.js"; import { isAgent } from "../../mode.js"; import { dim, bold } from "../../lib/color.js"; import { @@ -73,6 +73,7 @@ import { planIOSRuntimeKey } from "./ios/runtime-key.ts"; import { planIOSAssociatedDomain } from "./ios/associated-domain.ts"; import { planIOSAppleEntitlement } from "./ios/apple-entitlement.ts"; import { planIOSPrebuiltAuth } from "./ios/prebuilt-auth.ts"; +import { resolveIOSDevelopmentPublicKey } from "./ios/development-key.ts"; import { createIOSDryRunOutput, formatIOSSetupPlan } from "./ios/output.ts"; import { applyIOSLocalSetup, @@ -483,15 +484,9 @@ export async function init(options: InitOptions = {}) { } setTelemetryStage("keys"); const keys = await withSpinner("Fetching the development publishable key...", async () => - resolveEnvironmentKeys({ app: authenticatedAppId, cwd: ctx.cwd }), + resolveIOSDevelopmentPublicKey(authenticatedAppId), ); - if (keys.instanceLabel !== "development") { - throw new CliError( - "Automatic iOS configuration is limited to the linked development instance. No local setup changes were written.", - { code: ERROR_CODE.INVALID_ENVIRONMENT }, - ); - } - if (keys.appId !== authenticatedAppId) { + if (keys.applicationId !== authenticatedAppId) { throw new CliError( "The linked Clerk application changed while its iOS publishable key was being resolved. No local setup changes were written; rerun clerk init.", { code: ERROR_CODE.IOS_SETUP_STALE }, @@ -539,7 +534,7 @@ export async function init(options: InitOptions = {}) { } setTelemetryStage("ios_native_plan"); const nativeRemotePlan = await prepareIOSNativeRemoteSetup({ - applicationId: keys.appId, + applicationId: keys.applicationId, instanceId: keys.instanceId, target: iosLocalSetup.nativeReadiness.target, appIdPrefix: options.appIdPrefix, @@ -567,7 +562,7 @@ export async function init(options: InitOptions = {}) { } setTelemetryStage("ios_apple_plan"); const preparedApple = await prepareIOSNativeAppleConnection({ - applicationId: keys.appId, + applicationId: keys.applicationId, instanceId: keys.instanceId, bundleIdentifier: target.bundleIdentifier.value, nativeApplicationReady: diff --git a/packages/cli-core/src/commands/init/ios/development-key.test.ts b/packages/cli-core/src/commands/init/ios/development-key.test.ts new file mode 100644 index 000000000..ca100dbb5 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/development-key.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as plapi from "../../../lib/plapi.ts"; +import { resolveIOSDevelopmentPublicKey } from "./development-key.ts"; + +const spies: Array<{ mockRestore(): void }> = []; + +afterEach(() => { + for (const spy of spies.splice(0)) spy.mockRestore(); +}); + +describe("iOS development publishable-key resolution", () => { + test("fetches an exact application without secret keys and selects its development instance", async () => { + const fetchApplication = spyOn(plapi, "fetchApplication").mockResolvedValue({ + application_id: "app_native", + instances: [ + { + instance_id: "ins_production", + environment_type: "production", + publishable_key: "pk_live_redacted", + secret_key: "sk_live_must_not_escape", + }, + { + instance_id: "ins_development", + environment_type: "development", + publishable_key: "pk_test_redacted", + secret_key: "sk_test_must_not_escape", + }, + ], + }); + spies.push(fetchApplication); + + const resolved = await resolveIOSDevelopmentPublicKey("app_native"); + + expect(fetchApplication).toHaveBeenCalledWith("app_native", { includeSecretKeys: false }); + expect(resolved).toEqual({ + applicationId: "app_native", + instanceId: "ins_development", + publishableKey: "pk_test_redacted", + }); + expect(resolved).not.toHaveProperty("secretKey"); + }); + + test("requires the exact application to have a development instance", async () => { + const fetchApplication = spyOn(plapi, "fetchApplication").mockResolvedValue({ + application_id: "app_production_only", + instances: [ + { + instance_id: "ins_production", + environment_type: "production", + publishable_key: "pk_live_redacted", + }, + ], + }); + spies.push(fetchApplication); + + await expect(resolveIOSDevelopmentPublicKey("app_production_only")).rejects.toThrow( + "No development instance found", + ); + }); + + test("returns the fetched application identity for the commit-time stale check", async () => { + const fetchApplication = spyOn(plapi, "fetchApplication").mockResolvedValue({ + application_id: "app_changed", + instances: [ + { + instance_id: "ins_development", + environment_type: "development", + publishable_key: "pk_test_redacted", + }, + ], + }); + spies.push(fetchApplication); + + const resolved = await resolveIOSDevelopmentPublicKey("app_requested"); + + expect(resolved.applicationId).toBe("app_changed"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/development-key.ts b/packages/cli-core/src/commands/init/ios/development-key.ts new file mode 100644 index 000000000..1b7b7e785 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/development-key.ts @@ -0,0 +1,41 @@ +import { resolveFetchedApplicationInstance } from "../../../lib/config.ts"; +import { CliError, ERROR_CODE, withApiContext } from "../../../lib/errors.ts"; +import { fetchApplication } from "../../../lib/plapi.ts"; + +export interface IOSDevelopmentPublicKey { + applicationId: string; + instanceId: string; + publishableKey: string; +} + +/** Resolve only the public development identity needed by native iOS setup. */ +export async function resolveIOSDevelopmentPublicKey( + applicationId: string, +): Promise { + const application = await withApiContext( + fetchApplication(applicationId, { includeSecretKeys: false }), + "Failed to fetch the iOS development publishable key", + ); + const resolved = resolveFetchedApplicationInstance(applicationId, application); + if (!resolved.found) { + throw new CliError( + `Development instance ${resolved.instanceId} not found in application ${applicationId}.`, + { code: ERROR_CODE.INSTANCE_NOT_FOUND }, + ); + } + if ( + resolved.instanceLabel !== "development" || + resolved.instance.environment_type !== "development" + ) { + throw new CliError( + "Automatic iOS configuration is limited to the linked development instance. No local setup changes were written.", + { code: ERROR_CODE.INVALID_ENVIRONMENT }, + ); + } + + return { + applicationId: application.application_id, + instanceId: resolved.instanceId, + publishableKey: resolved.instance.publishable_key, + }; +} diff --git a/packages/cli-core/src/lib/config-instance.ts b/packages/cli-core/src/lib/config-instance.ts deleted file mode 100644 index 478682a00..000000000 --- a/packages/cli-core/src/lib/config-instance.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { CliError, ERROR_CODE } from "./errors.ts"; -import type { Application, ApplicationInstance } from "./plapi.ts"; - -export const INSTANCE_ALIASES: Record = { - dev: "development", - development: "development", - prod: "production", - production: "production", -}; - -export function resolveFetchedApplicationInstance( - appId: string, - app: Application, - instance?: string, -): - | { found: true; instance: ApplicationInstance; instanceId: string; instanceLabel: string } - | { found: false; instanceId: string; instanceLabel: string } { - if (instance) { - const environment = INSTANCE_ALIASES[instance]; - if (environment) { - const matched = app.instances.find((entry) => entry.environment_type === environment); - if (!matched) { - throw new CliError(`No ${environment} instance found for application ${appId}.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - }); - } - return { - found: true, - instance: matched, - instanceId: matched.instance_id, - instanceLabel: environment, - }; - } - - const matched = app.instances.find((entry) => entry.instance_id === instance); - if (matched) { - return { - found: true, - instance: matched, - instanceId: matched.instance_id, - // Downstream guardrails key off the environment label when it is available. - instanceLabel: matched.environment_type || instance, - }; - } - - return { found: false, instanceId: instance, instanceLabel: instance }; - } - - const development = app.instances.find((entry) => entry.environment_type === "development"); - if (!development) { - throw new CliError(`No development instance found for application ${appId}.`, { - code: ERROR_CODE.INSTANCE_NOT_FOUND, - }); - } - return { - found: true, - instance: development, - instanceId: development.instance_id, - instanceLabel: "development", - }; -} diff --git a/packages/cli-core/src/lib/config.ts b/packages/cli-core/src/lib/config.ts index 022805f3d..41943b990 100644 --- a/packages/cli-core/src/lib/config.ts +++ b/packages/cli-core/src/lib/config.ts @@ -11,8 +11,7 @@ import { getGitRepoIdentifier, getGitNormalizedRemote } from "./git.ts"; import { CliError, ERROR_CODE } from "./errors.ts"; import { withHomeFsAccess } from "./host-execution.ts"; import { log } from "./log.ts"; -import { INSTANCE_ALIASES, resolveFetchedApplicationInstance } from "./config-instance.ts"; -export { resolveFetchedApplicationInstance } from "./config-instance.ts"; +import type { Application, ApplicationInstance } from "./plapi.ts"; let overrideConfigFile: string | undefined; @@ -309,6 +308,13 @@ export async function resolveProfile(cwd: string): Promise< return undefined; } +const INSTANCE_ALIASES: Record = { + dev: "development", + development: "development", + prod: "production", + production: "production", +}; + export function resolveInstanceId(profile: Profile, flag?: string): { id: string; label: string } { if (!flag) { return { id: profile.instances.development, label: "development" }; @@ -333,6 +339,64 @@ interface AppContextOptions { cwd?: string; } +export function resolveFetchedApplicationInstance( + appId: string, + app: Application, + instance?: string, +): + | { found: true; instance: ApplicationInstance; instanceId: string; instanceLabel: string } + | { found: false; instanceId: string; instanceLabel: string } { + if (instance) { + const env = INSTANCE_ALIASES[instance]; + if (env) { + const matched = app.instances.find((entry) => entry.environment_type === env); + if (!matched) { + throw new CliError(`No ${env} instance found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + instanceLabel: env, + }; + } + + const matched = app.instances.find((entry) => entry.instance_id === instance); + if (matched) { + return { + found: true, + instance: matched, + instanceId: matched.instance_id, + // Label by environment type, not the raw id — downstream guardrails + // (e.g. the production impersonation warning) key off this label. + instanceLabel: matched.environment_type || instance, + }; + } + + return { + found: false, + instanceId: instance, + instanceLabel: instance, + }; + } + + const development = app.instances.find((entry) => entry.environment_type === "development"); + if (!development) { + throw new CliError(`No development instance found for application ${appId}.`, { + code: ERROR_CODE.INSTANCE_NOT_FOUND, + }); + } + + return { + found: true, + instance: development, + instanceId: development.instance_id, + instanceLabel: "development", + }; +} + /** * Resolve app context from explicit flags or linked profile. * This is the isomorphic resolution chain used by profile-dependent commands: diff --git a/packages/cli-core/src/test/lib/init-harness.ts b/packages/cli-core/src/test/lib/init-harness.ts index 73deb5427..72c9587ee 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -32,6 +32,7 @@ export * as keylessTargetMod from "../../lib/keyless-target.ts"; export * as iosApplyMod from "../../commands/init/ios/apply.ts"; export * as nativeRemoteMod from "../../commands/init/ios/native-remote.ts"; export * as nativeAppleMod from "../../commands/init/ios/native-apple.ts"; +export * as iosDevelopmentKeyMod from "../../commands/init/ios/development-key.ts"; export * as plapiMod from "../../lib/plapi.ts"; export * as fapiMod from "../../lib/fapi.ts"; @@ -53,6 +54,7 @@ import * as keylessModule from "../../lib/keyless.ts"; import * as iosApplyModule from "../../commands/init/ios/apply.ts"; import * as nativeRemoteModule from "../../commands/init/ios/native-remote.ts"; import * as nativeAppleModule from "../../commands/init/ios/native-apple.ts"; +import * as iosDevelopmentKeyModule from "../../commands/init/ios/development-key.ts"; import * as plapiModule from "../../lib/plapi.ts"; import * as fapiModule from "../../lib/fapi.ts"; import { @@ -202,10 +204,9 @@ export function useInitHarness(): InitHarness { spyOn(loginModule, "login").mockResolvedValue(undefined as never), spyOn(linkModule, "link").mockResolvedValue(undefined), spyOn(pullModule, "pull").mockResolvedValue(undefined), - spyOn(pullModule, "resolveEnvironmentKeys").mockResolvedValue({ - appId: "app_test", + spyOn(iosDevelopmentKeyModule, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", instanceId: "ins_test", - instanceLabel: "development", publishableKey: "pk_test_redacted", }), spyOn(fapiModule, "fetchUserSettings").mockResolvedValue({ social: {} } as never), From 910731582435d7532e10fbc168f7553d4a9f57a2 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 22:40:40 -0400 Subject: [PATCH 05/55] fix(deploy): distinguish native Apple readiness states --- .../src/commands/deploy/index.test.ts | 107 ++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 33 ++++-- .../src/commands/deploy/providers.test.ts | 48 +++++++- .../cli-core/src/commands/deploy/providers.ts | 18 ++- .../src/commands/deploy/status.test.ts | 61 +++++++++- .../cli-core/src/commands/deploy/status.ts | 27 ++++- 6 files changed, 275 insertions(+), 19 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 9f520aff7..b41372a3b 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1308,6 +1308,113 @@ describe("deploy", () => { expect(err).not.toContain("Configure Apple OAuth for production"); }); + test("refuses ambiguous App ID Prefix registrations for native-only Apple", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_first", + app_id_prefix: "FIRST12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + { + object: "ios_application", + id: "ios_second", + app_id_prefix: "SECOND1234", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + let thrown: unknown; + try { + await runDeploy({}); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CliError); + const message = (thrown as Error).message; + expect(message).toContain("more than one App ID Prefix registration"); + expect(message).toContain("Review the existing registrations"); + expect(message).toContain("Do not create another registration"); + expect(message).not.toContain("Register it at"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Failed"); + }); + + test("does not recommend registration creation when native Apple verification is unavailable", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockRejectedValue(new Error("native endpoint unavailable")); + mockIsAgent.mockReturnValue(false); + + let thrown: unknown; + try { + await runDeploy({}); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(CliError); + const message = (thrown as Error).message; + expect(message).toContain( + "could not verify the production Native Application registration for com.example.native", + ); + expect(message).toContain("no registration should be created from this unverified result"); + expect(message).toContain("Retry `clerk deploy`"); + expect(message).not.toContain("Register it at"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + expect(stripAnsi(captured.err)).toContain("Failed"); + }); + test("refuses to infer an App ID Prefix when native Apple lacks an exact production registration", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index ebeaf1a90..512a1efb3 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -631,10 +631,9 @@ async function nativeAppleCredentialsAreAlreadyConfigured( return false; } - let iosApplications: Awaited>; - let nativeSettings: Awaited>; + let nativeConfiguration: ReturnType; try { - [iosApplications, nativeSettings] = await withSpinner( + const [iosApplications, nativeSettings] = await withSpinner( "Checking production Native Application settings...", async () => Promise.all([ @@ -642,20 +641,27 @@ async function nativeAppleCredentialsAreAlreadyConfigured( getNativeSettings(ctx.appId, productionInstanceId), ]), ); + nativeConfiguration = inspectNativeAppleConfiguration( + productionConfig, + descriptor, + iosApplications, + nativeSettings, + ); } catch (error) { if (error instanceof UserAbortError) throw error; + nativeConfiguration = { + status: "verification-unavailable", + bundleId: preliminary.bundleId, + }; + } + + if (nativeConfiguration.status === "verification-unavailable") { throw new CliError( `clerk deploy could not verify the production Native Application registration for ${preliminary.bundleId}. ` + - "No Apple web credentials were requested. Verify the exact Bundle ID at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`.", + "No Apple web credentials were requested and no registration should be created from this unverified result. Retry `clerk deploy`, or review the existing registrations at https://dashboard.clerk.com/~/native-applications.", ); } - const nativeConfiguration = inspectNativeAppleConfiguration( - productionConfig, - descriptor, - iosApplications, - nativeSettings, - ); if (nativeConfiguration.status === "ready") { log.success( `Native Sign in with Apple is configured for ${nativeConfiguration.bundleId}; Apple web credentials are not required`, @@ -670,6 +676,13 @@ async function nativeAppleCredentialsAreAlreadyConfigured( ); } + if (nativeConfiguration.status === "registration-ambiguous") { + throwUsageError( + `Native Sign in with Apple has more than one App ID Prefix registration for ${nativeConfiguration.bundleId}. ` + + "Review the existing registrations at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. Do not create another registration or add unrelated Apple web credentials.", + ); + } + throwUsageError( `Native Sign in with Apple is configured for ${preliminary.bundleId}, but the production instance does not have an exact iOS Native Application registration for that Bundle ID. ` + "Register it at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. The CLI will not infer an App ID Prefix or request unrelated Apple web credentials.", diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index bb4c88c3d..04cec92d6 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -59,11 +59,15 @@ function descriptorByProvider( return descriptor; } -function iosApplication(bundleId: string): IOSApplication { +function iosApplication( + bundleId: string, + appIdPrefix = "ABCDE12345", + id = `ios_${appIdPrefix}_${bundleId}`, +): IOSApplication { return { object: "ios_application", - id: `ios_${bundleId}`, - app_id_prefix: "ABCDE12345", + id, + app_id_prefix: appIdPrefix, bundle_id: bundleId, created_at: 1, updated_at: 1, @@ -230,6 +234,44 @@ describe("deploy OAuth provider descriptors", () => { ).toEqual({ status: "registration-missing", bundleId: "com.example.app" }); }); + test("rejects multiple App ID prefixes for one native Apple Bundle ID", () => { + const result = buildOAuthProviderDescriptors( + ["apple"], + schemaResponse({ connection_oauth_apple: appleOAuthSchema }), + ); + const apple = descriptorByProvider(result.supported, "apple"); + const config = { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.app", + }, + }; + + expect( + inspectNativeAppleConfiguration( + config, + apple, + [ + iosApplication("com.example.app", "PREFIX_ONE"), + iosApplication("com.example.app", "PREFIX_TWO"), + ], + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "registration-ambiguous", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + config, + apple, + [ + iosApplication("com.example.app", "PREFIX_ONE", "ios_first"), + iosApplication("com.example.app", "PREFIX_ONE", "ios_duplicate"), + ], + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "ready", bundleId: "com.example.app" }); + }); + test("requires Native API and authenticatable Apple settings for native readiness", () => { const result = buildOAuthProviderDescriptors( ["apple"], diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index fa01c4e61..937603fcf 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -70,7 +70,13 @@ export type OAuthProviderDescriptorResult = { export type NativeAppleConfiguration = | { status: "not-apple" | "hosted-or-unconfigured" } | { - status: "ready" | "authentication-disabled" | "registration-missing" | "native-api-disabled"; + status: + | "ready" + | "authentication-disabled" + | "registration-missing" + | "registration-ambiguous" + | "native-api-disabled" + | "verification-unavailable"; bundleId: string; }; @@ -254,9 +260,17 @@ export function inspectNativeAppleConfiguration( return { status: "authentication-disabled", bundleId }; } - if (!iosApplications.some((application) => application.bundle_id === bundleId)) { + const registeredPrefixes = new Set( + iosApplications + .filter((application) => application.bundle_id === bundleId) + .map((application) => application.app_id_prefix), + ); + if (registeredPrefixes.size === 0) { return { status: "registration-missing", bundleId }; } + if (registeredPrefixes.size > 1) { + return { status: "registration-ambiguous", bundleId }; + } return nativeSettings?.api_enabled === true ? { status: "ready", bundleId } : { status: "native-api-disabled", bundleId }; diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index e10d2225c..305f960b6 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -292,7 +292,7 @@ describe("resolveDeployState", () => { } }); - test("keeps the preliminary native Apple status when native endpoint reads fail", async () => { + test("reports native Apple verification as unavailable when native endpoint reads fail", async () => { mockActiveProductionEnvironment(); mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => instanceId === "ins_prod" @@ -315,8 +315,65 @@ describe("resolveDeployState", () => { expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ bundleId: "com.example.native", - reason: "registration-missing", + reason: "verification-unavailable", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("could not verify"); + expect(report.nextAction).toContain("Retry `clerk deploy status`"); + expect(report.nextAction).toContain("do not create another registration"); + expect(report.nextAction).not.toContain("Register that Bundle ID"); + } + }); + + test("reports multiple App ID prefixes for one native Apple Bundle ID as ambiguous", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_first", + app_id_prefix: "FIRST12345", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + { + object: "ios_application", + id: "ios_second", + app_id_prefix: "SECOND1234", + bundle_id: "com.example.native", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.completedOAuthProviders).toEqual([]); + expect(state.snapshot.pending).toEqual({ type: "oauth", provider: "apple" }); + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-ambiguous", }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("more than one App ID Prefix registration"); + expect(report.nextAction).toContain("Review the existing registrations"); + expect(report.nextAction).toContain("do not create another registration"); + expect(report.nextAction).not.toContain("Register that Bundle ID"); } }); diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index e90a45878..fd56677e3 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -77,7 +77,12 @@ export interface DeployStatusReport { type NativeAppleReadinessIssue = { bundleId: string; - reason: "authentication-disabled" | "registration-missing" | "native-api-disabled"; + reason: + | "authentication-disabled" + | "registration-missing" + | "registration-ambiguous" + | "native-api-disabled" + | "verification-unavailable"; }; export type LiveDeploySnapshot = Omit< @@ -253,6 +258,10 @@ export async function resolveLiveDeploySnapshot( } catch (error) { if (error instanceof UserAbortError) throw error; log.debug(`Could not read production Native Application settings: ${errorMessage(error)}`); + nativeAppleConfiguration = { + status: "verification-unavailable", + bundleId: preliminaryNativeAppleConfiguration.bundleId, + }; } } const completedOAuthProviders = oauthProviderDescriptors @@ -540,11 +549,25 @@ function isNativeAppleReadinessIssue( return ( status === "authentication-disabled" || status === "registration-missing" || - status === "native-api-disabled" + status === "registration-ambiguous" || + status === "native-api-disabled" || + status === "verification-unavailable" ); } function nativeAppleReadinessNextAction(issue: NativeAppleReadinessIssue): string { + if (issue.reason === "verification-unavailable") { + return ( + `Clerk could not verify the production Native Application registration for ${issue.bundleId}. ` + + "Retry `clerk deploy status`; do not create another registration based on this unverified result." + ); + } + if (issue.reason === "registration-ambiguous") { + return ( + `Native Sign in with Apple has more than one App ID Prefix registration for ${issue.bundleId}. ` + + "Review the existing registrations at https://dashboard.clerk.com/~/native-applications before continuing; do not create another registration." + ); + } if (issue.reason === "authentication-disabled") { return ( `Apple is not explicitly enabled for authentication on the production instance for ${issue.bundleId}. ` + From cde861b9bf0e9d2ecac2d730a12dae3688897d50 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 26 Aug 2026 23:11:50 -0400 Subject: [PATCH 06/55] fix(init): revalidate iOS identity before remote writes --- .../src/commands/init/index-ios.test.ts | 3 + packages/cli-core/src/commands/init/index.ts | 1 + .../commands/init/ios/native-remote.test.ts | 208 +++++++++++++++++- .../src/commands/init/ios/native-remote.ts | 141 +++++++++++- 4 files changed, 344 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index 967d44df4..1fa829272 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -315,6 +315,7 @@ describe("init iOS", () => { }); expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith( expect.objectContaining({ + root: FAKE_IOS_NATIVE_READINESS.root, appIdPrefix: "CONFIRMED123", applicationLinkChange: "created-and-linked", }), @@ -368,6 +369,7 @@ describe("init iOS", () => { }); expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith( expect.objectContaining({ + root: FAKE_IOS_NATIVE_READINESS.root, appIdPrefix: undefined, applicationLinkChange: "link-updated", }), @@ -921,6 +923,7 @@ describe("init iOS", () => { expect(prepareRemote).toHaveBeenCalledWith({ applicationId: "app_test", instanceId: "ins_test", + root: setupResult.nativeReadiness.root, target: setupResult.nativeReadiness.target, appIdPrefix: "LEGACY1234", unverifiedAppIdPrefixSuggestion: setupResult.unverifiedAppIdPrefixSuggestion, diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 5245bb932..f7b7a5e5e 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -536,6 +536,7 @@ export async function init(options: InitOptions = {}) { const nativeRemotePlan = await prepareIOSNativeRemoteSetup({ applicationId: keys.applicationId, instanceId: keys.instanceId, + root: iosLocalSetup.nativeReadiness.root, target: iosLocalSetup.nativeReadiness.target, appIdPrefix: options.appIdPrefix, ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion 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 3df564cf2..12212a6b5 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 @@ -10,6 +10,8 @@ import { type IOSNativeRemoteAPI, type IOSNativeRemotePlan, type IOSNativeRemotePrompts, + type IOSNativeRemoteTargetReader, + type IOSNativeRemoteTargetSnapshot, } from "./native-remote.ts"; import type { IOSApplication, NativeSettings } from "../../../lib/plapi.ts"; @@ -18,6 +20,7 @@ const INSTANCE_ID = "ins_native_development"; const BUNDLE_IDENTIFIER = "com.example.NativeApp"; const LOCAL_PREFIX = "LEGACY1234"; const EXPLICIT_PREFIX = "EXPLICIT12"; +const IOS_ROOT = "/tmp/NativeApp"; const captured = useCaptureLog(); @@ -45,13 +48,15 @@ function selectedTarget( bundleIdentifier?: string; appIdPrefix?: string | null; appIdPrefixCandidates?: string[]; + projectPath?: string; + targetId?: string; } = {}, ): IOSNativeReadinessTarget { const appIdPrefix = options.appIdPrefix === undefined ? LOCAL_PREFIX : options.appIdPrefix; return { status: "selected", - projectPath: "NativeApp.xcodeproj", - targetId: "TARGET_NATIVE_APP", + projectPath: options.projectPath ?? "NativeApp.xcodeproj", + targetId: options.targetId ?? "TARGET_NATIVE_APP", targetName: "NativeApp", bundleIdentifier: { status: "resolved", @@ -68,12 +73,38 @@ function selectedTarget( }; } +function targetSnapshot( + target: IOSNativeReadinessTarget = selectedTarget(), +): IOSNativeRemoteTargetSnapshot { + if (target.status !== "selected") throw new Error("test target must be selected"); + return { + root: IOS_ROOT, + projectPath: target.projectPath, + targetId: target.targetId, + bundleIdentifier: target.bundleIdentifier, + appIdPrefix: target.appIdPrefix, + }; +} + +const approvedTargetReader: IOSNativeRemoteTargetReader = async (snapshot) => ({ + status: "selected", + projectPath: snapshot.projectPath, + targetId: snapshot.targetId, + targetName: "NativeApp", + bundleIdentifier: snapshot.bundleIdentifier, + appIdPrefix: snapshot.appIdPrefix, +}); + function plan(options: { nativeApi: "required" | "satisfied"; registration: "required" | "satisfied"; appIdPrefix?: string; + localAppIdPrefix?: string | null; }): IOSNativeRemotePlan { const appIdPrefix = options.appIdPrefix ?? LOCAL_PREFIX; + const localTarget = selectedTarget({ + appIdPrefix: options.localAppIdPrefix === undefined ? appIdPrefix : options.localAppIdPrefix, + }); return { schemaVersion: 1, kind: "clerk-ios-native-remote-setup", @@ -83,6 +114,7 @@ function plan(options: { : "ready", applicationId: APPLICATION_ID, instanceId: INSTANCE_ID, + localTarget: targetSnapshot(localTarget), bundleIdentifier: BUNDLE_IDENTIFIER, appIdPrefix, nativeApi: options.nativeApi, @@ -173,6 +205,7 @@ function prepareOptions( return { applicationId: APPLICATION_ID, instanceId: INSTANCE_ID, + root: IOS_ROOT, target: selectedTarget(), agent: false, yes: true, @@ -223,6 +256,17 @@ describe("Clerk Native Application remote setup", () => { status: "satisfied", nativeApi: "satisfied", registration: "satisfied", + localTarget: { + root: IOS_ROOT, + projectPath: "NativeApp.xcodeproj", + targetId: "TARGET_NATIVE_APP", + bundleIdentifier: { status: "resolved", value: BUNDLE_IDENTIFIER }, + appIdPrefix: { + status: "resolved", + source: "literal-entitlements", + value: LOCAL_PREFIX, + }, + }, bundleIdentifier: BUNDLE_IDENTIFIER, appIdPrefix: LOCAL_PREFIX, actions: [], @@ -584,7 +628,11 @@ describe("Clerk Native Application remote setup", () => { registrationReads: [[], [exactRegistration]], }); - await applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api); + await applyIOSNativeRemoteSetup( + plan({ nativeApi: "required", registration: "required" }), + api, + approvedTargetReader, + ); expect(calls).toContain("POST iOS registration"); expect(calls).not.toContain("PATCH native settings"); @@ -597,13 +645,140 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup(plan({ nativeApi: "satisfied", registration: "required" }), api), + applyIOSNativeRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), ).rejects.toThrow(); expect(calls).not.toContain("POST iOS registration"); expect(calls).not.toContain("PATCH native settings"); }); + test.each([ + { + name: "the Bundle ID changes", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ bundleIdentifier: "com.example.Changed" }), + }, + { + name: "the proven App ID Prefix changes", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ appIdPrefix: EXPLICIT_PREFIX }), + }, + { + name: "the proven App ID Prefix disappears", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ appIdPrefix: null }), + }, + { + name: "the target changes", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ targetId: "TARGET_CHANGED" }), + }, + { + name: "the project changes", + approved: plan({ nativeApi: "satisfied", registration: "required" }), + current: selectedTarget({ projectPath: "Changed.xcodeproj" }), + }, + { + name: "new evidence conflicts with a user-confirmed prefix", + approved: plan({ + nativeApi: "satisfied", + registration: "required", + appIdPrefix: EXPLICIT_PREFIX, + localAppIdPrefix: null, + }), + current: selectedTarget({ appIdPrefix: LOCAL_PREFIX }), + }, + ])("fails closed before mutation when $name", async ({ approved, current }) => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[]], + expectedAppIdPrefix: approved.appIdPrefix, + }); + + await expect( + applyIOSNativeRemoteSetup(approved, api, async () => current), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining("Xcode target identity changed"), + }); + + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("fails closed before mutation when the Xcode identity cannot be inspected", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[]], + }); + + await expect( + applyIOSNativeRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + async () => { + throw new Error("xcconfig unreadable"); + }, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("Xcode target identity could not be rechecked"), + }); + + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("revalidates identity before a Native API-only mutation", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[registration()]], + }); + + await expect( + applyIOSNativeRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + async () => selectedTarget({ bundleIdentifier: "com.example.Changed" }), + ), + ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE }); + + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + + test("accepts unchanged identity and newly proven evidence matching a confirmed prefix", async () => { + const exactRegistration = registration(EXPLICIT_PREFIX); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + expectedAppIdPrefix: EXPLICIT_PREFIX, + }); + let inspections = 0; + const approved = plan({ + nativeApi: "satisfied", + registration: "required", + appIdPrefix: EXPLICIT_PREFIX, + localAppIdPrefix: null, + }); + + await applyIOSNativeRemoteSetup(approved, api, async () => { + inspections += 1; + return selectedTarget({ appIdPrefix: EXPLICIT_PREFIX }); + }); + + expect(inspections).toBe(1); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + expect(calls).not.toContain("PATCH native settings"); + }); + test("creates the iOS registration before enabling Native API", async () => { const exactRegistration = registration(); const { api, calls } = scriptedAPI({ @@ -611,7 +786,11 @@ describe("Clerk Native Application remote setup", () => { registrationReads: [[], [exactRegistration]], }); - await applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api); + await applyIOSNativeRemoteSetup( + plan({ nativeApi: "required", registration: "required" }), + api, + approvedTargetReader, + ); expect(calls.indexOf("POST iOS registration")).toBeGreaterThan(-1); expect(calls.indexOf("POST iOS registration")).toBeLessThan( @@ -631,7 +810,11 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup(plan({ nativeApi: "satisfied", registration: "required" }), api), + applyIOSNativeRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), ).resolves.toBeUndefined(); expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); }); @@ -648,7 +831,11 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "satisfied" }), api), + applyIOSNativeRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), ).resolves.toBeUndefined(); expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); }); @@ -660,7 +847,11 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup(plan({ nativeApi: "required", registration: "required" }), api), + applyIOSNativeRemoteSetup( + plan({ nativeApi: "required", registration: "required" }), + api, + approvedTargetReader, + ), ).rejects.toMatchObject({ code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, message: expect.stringContaining("did not pass the final verification"), @@ -700,6 +891,7 @@ describe("Clerk Native Application remote setup", () => { await applyIOSNativeRemoteSetup( plan({ nativeApi: "satisfied", registration: "required" }), api, + approvedTargetReader, ); } catch (error) { thrown = error; 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 ce2920964..5a080e5fd 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -21,10 +21,12 @@ import { } from "../../../lib/plapi.ts"; import { confirm, text } from "../../../lib/prompts.ts"; import { withSpinner } from "../../../lib/spinner.ts"; +import { inspectIOSProject } from "./inspect.ts"; import type { IOSNativeReadinessTarget, IOSUnverifiedAppIdPrefixSuggestion, } from "./native-readiness.ts"; +import { buildIOSNativeReadinessAudit } from "./native-readiness.ts"; const APP_ID_PREFIX_MAX_LENGTH = 255; @@ -51,12 +53,23 @@ export interface IOSNativeRemoteBlocker { message: string; } +type IOSSelectedNativeReadinessTarget = Extract; + +export interface IOSNativeRemoteTargetSnapshot { + root: string; + projectPath: string; + targetId: string; + bundleIdentifier: IOSSelectedNativeReadinessTarget["bundleIdentifier"]; + appIdPrefix: IOSSelectedNativeReadinessTarget["appIdPrefix"]; +} + export type IOSNativeRemotePlan = { schemaVersion: 1; kind: "clerk-ios-native-remote-setup"; status: "ready" | "satisfied" | "blocked"; applicationId: string; instanceId: string; + localTarget?: IOSNativeRemoteTargetSnapshot; bundleIdentifier?: string; appIdPrefix?: string; nativeApi: "required" | "satisfied"; @@ -91,6 +104,7 @@ const defaultAPI: IOSNativeRemoteAPI = { export interface PrepareIOSNativeRemoteSetupOptions { applicationId: string; instanceId: string; + root: string; target: IOSNativeReadinessTarget; appIdPrefix?: string; unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; @@ -104,6 +118,10 @@ export type IOSNativeRemoteAppIdPrefixSuggestion = | IOSUnverifiedAppIdPrefixSuggestion | { source: "partial-literal-entitlements"; value: string }; +export type IOSNativeRemoteTargetReader = ( + snapshot: IOSNativeRemoteTargetSnapshot, +) => Promise; + export interface IOSNativeRemotePrompts { appIdPrefix( bundleIdentifier: string, @@ -156,6 +174,36 @@ export function validateAppIdPrefix(value: string | undefined): string | undefin return normalized && normalized.length <= APP_ID_PREFIX_MAX_LENGTH ? normalized : undefined; } +function copyTargetSnapshot( + root: string | undefined, + target: IOSNativeReadinessTarget, +): IOSNativeRemoteTargetSnapshot | undefined { + if (!root || target.status !== "selected") return undefined; + return { + root, + projectPath: target.projectPath, + targetId: target.targetId, + bundleIdentifier: + target.bundleIdentifier.status === "conflicting" + ? { ...target.bundleIdentifier, candidates: [...target.bundleIdentifier.candidates] } + : { ...target.bundleIdentifier }, + appIdPrefix: + target.appIdPrefix.status === "resolved" + ? { ...target.appIdPrefix } + : { + ...target.appIdPrefix, + ...(target.appIdPrefix.candidates + ? { candidates: [...target.appIdPrefix.candidates] } + : {}), + }, + }; +} + +const defaultTargetReader: IOSNativeRemoteTargetReader = async (snapshot) => { + const inspection = await inspectIOSProject(snapshot.root, { target: snapshot.targetId }); + return buildIOSNativeReadinessAudit(inspection).target; +}; + function localIdentity(target: IOSNativeReadinessTarget): { bundleIdentifier?: string; appIdPrefix?: string; @@ -213,6 +261,7 @@ function localIdentity(target: IOSNativeReadinessTarget): { export function buildIOSNativeRemotePlan(options: { applicationId: string; instanceId: string; + root?: string; target: IOSNativeReadinessTarget; requestedAppIdPrefix?: string; nativeSettings: NativeSettings; @@ -315,6 +364,7 @@ export function buildIOSNativeRemotePlan(options: { status, applicationId: options.applicationId, instanceId: options.instanceId, + localTarget: copyTargetSnapshot(options.root, options.target), bundleIdentifier, appIdPrefix, nativeApi, @@ -452,6 +502,7 @@ export async function prepareIOSNativeRemoteSetup( let plan = buildIOSNativeRemotePlan({ applicationId: options.applicationId, instanceId: options.instanceId, + root: options.root, target: options.target, requestedAppIdPrefix: options.appIdPrefix, ...state, @@ -481,6 +532,7 @@ export async function prepareIOSNativeRemoteSetup( plan = buildIOSNativeRemotePlan({ applicationId: options.applicationId, instanceId: options.instanceId, + root: options.root, target: options.target, requestedAppIdPrefix: appIdPrefix, ...state, @@ -522,7 +574,7 @@ async function reconciledPlan( api: IOSNativeRemoteAPI, ): Promise { const state = await readRemoteState(plan.applicationId, plan.instanceId, api); - return buildIOSNativeRemotePlan({ + const reconciled = buildIOSNativeRemotePlan({ applicationId: plan.applicationId, instanceId: plan.instanceId, target: { @@ -538,6 +590,88 @@ async function reconciledPlan( requestedAppIdPrefix: plan.appIdPrefix, ...state, }); + return { ...reconciled, localTarget: plan.localTarget }; +} + +function prefixEvidenceMatchesApprovedIdentity( + approved: IOSNativeRemoteTargetSnapshot["appIdPrefix"], + current: IOSSelectedNativeReadinessTarget["appIdPrefix"], + appIdPrefix: string, +): boolean { + if (approved.status === "conflicting") return false; + if (approved.status === "resolved") { + return ( + approved.value === appIdPrefix && + current.status === "resolved" && + current.value === appIdPrefix + ); + } + + // A prefix explicitly confirmed by the user or inherited from an existing + // Clerk registration need not become literal Xcode evidence. If evidence + // appears after approval, however, it may only prove that same prefix. + if (approved.candidates?.some((candidate) => candidate !== appIdPrefix)) return false; + if (current.status === "conflicting") return false; + if (current.status === "resolved") return current.value === appIdPrefix; + return !(current.candidates?.some((candidate) => candidate !== appIdPrefix) ?? false); +} + +function localTargetStillMatchesApprovedIdentity( + plan: IOSNativeRemotePlan, + current: IOSNativeReadinessTarget, +): boolean { + const approved = plan.localTarget; + if ( + !approved || + !plan.bundleIdentifier || + !plan.appIdPrefix || + approved.bundleIdentifier.status !== "resolved" || + approved.bundleIdentifier.value !== plan.bundleIdentifier || + current.status !== "selected" || + current.projectPath !== approved.projectPath || + current.targetId !== approved.targetId || + current.bundleIdentifier.status !== "resolved" || + current.bundleIdentifier.value !== plan.bundleIdentifier + ) { + return false; + } + return prefixEvidenceMatchesApprovedIdentity( + approved.appIdPrefix, + current.appIdPrefix, + plan.appIdPrefix, + ); +} + +async function revalidateLocalTargetBeforeRemoteMutation( + plan: IOSNativeRemotePlan, + targetReader: IOSNativeRemoteTargetReader, +): Promise { + if (!plan.localTarget) { + throw iosRemoteError( + "The approved Clerk Native Application plan does not identify the inspected Xcode target. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + + let current: IOSNativeReadinessTarget; + try { + current = await withSpinner("Rechecking the selected Xcode target identity...", async () => + targetReader(plan.localTarget!), + ); + } catch (error) { + log.debug(`Could not recheck the selected Xcode target identity: ${errorMessage(error)}`); + throw iosRemoteError( + "The selected Xcode target identity could not be rechecked. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + + if (!localTargetStillMatchesApprovedIdentity(plan, current)) { + throw iosRemoteError( + "The selected Xcode target identity changed after the approved preview. No remote changes were made; rerun clerk init to review the current Bundle ID and App ID Prefix.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } } function revalidatedActionSetIsAuthorized( @@ -565,6 +699,7 @@ function revalidatedActionSetIsAuthorized( export async function applyIOSNativeRemoteSetup( plan: IOSNativeRemotePlan, api: IOSNativeRemoteAPI = defaultAPI, + targetReader: IOSNativeRemoteTargetReader = defaultTargetReader, ): Promise { if (plan.status === "blocked" || !plan.bundleIdentifier || !plan.appIdPrefix) { throw iosRemoteError( @@ -593,6 +728,10 @@ export async function applyIOSNativeRemoteSetup( ); } + if (currentPlan.registration === "required" || currentPlan.nativeApi === "required") { + await revalidateLocalTargetBeforeRemoteMutation(plan, targetReader); + } + const registrationIdempotencyKey = `clerk-init-ios-registration-${randomUUID()}`; const nativeAPIIdempotencyKey = `clerk-init-ios-native-api-${randomUUID()}`; From 7160170ea65b24fafe8b18ebf7f25f2c8316fb4f Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 08:29:07 -0400 Subject: [PATCH 07/55] fix(deploy): preserve disabled native Apple setup --- .../cli-core/src/commands/deploy/index.test.ts | 5 +++-- .../src/commands/deploy/providers.test.ts | 16 +++++++++++++++- .../cli-core/src/commands/deploy/providers.ts | 4 ++-- .../cli-core/src/commands/deploy/status.test.ts | 5 +++-- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index b41372a3b..b8d520fcd 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1541,7 +1541,7 @@ describe("deploy", () => { expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); }); - test("refuses native-only Apple that is not explicitly authenticatable", async () => { + test("refuses disabled native-only Apple without requesting hosted credentials", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, }); @@ -1556,7 +1556,8 @@ describe("deploy", () => { }, productionConfig: { connection_oauth_apple: { - enabled: true, + enabled: false, + authenticatable: false, bundle_id: "com.example.native", }, }, diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index 04cec92d6..c48ddbd9e 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -306,6 +306,20 @@ describe("deploy OAuth provider descriptors", () => { { object: "native_settings", api_enabled: true }, ), ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration( + { + connection_oauth_apple: { + enabled: false, + authenticatable: false, + bundle_id: "com.example.app", + }, + }, + apple, + iosApplications, + { object: "native_settings", api_enabled: true }, + ), + ).toEqual({ status: "authentication-disabled", bundleId: "com.example.app" }); expect( inspectNativeAppleConfiguration( { @@ -332,7 +346,7 @@ describe("deploy OAuth provider descriptors", () => { inspectNativeAppleConfiguration( { connection_oauth_apple: { - enabled: true, + enabled: false, bundle_id: "com.example.app", client_id: "com.example.web", }, diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 937603fcf..45816332b 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -249,14 +249,14 @@ export function inspectNativeAppleConfiguration( return { status: "hosted-or-unconfigured" }; } const providerConfig = value as Record; - if (providerConfig.enabled !== true || hasAppleHostedIdentifier(providerConfig)) { + if (hasAppleHostedIdentifier(providerConfig)) { return { status: "hosted-or-unconfigured" }; } const rawBundleId = providerConfig.bundle_id; const bundleId = typeof rawBundleId === "string" ? rawBundleId.trim() : ""; if (!bundleId) return { status: "hosted-or-unconfigured" }; - if (providerConfig.authenticatable !== true) { + if (providerConfig.enabled !== true || providerConfig.authenticatable !== true) { return { status: "authentication-disabled", bundleId }; } diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 305f960b6..86f4c43fe 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -447,13 +447,14 @@ describe("resolveDeployState", () => { } }); - test("requires Apple to be explicitly authenticatable without reading native endpoints", async () => { + test("reports disabled native Apple without reading native endpoints", async () => { mockActiveProductionEnvironment(); mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => instanceId === "ins_prod" ? { connection_oauth_apple: { - enabled: true, + enabled: false, + authenticatable: false, bundle_id: "com.example.native", }, } From aeac74cc886c4c55b543e9b849d0c4ea9e186aa9 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 09:31:15 -0400 Subject: [PATCH 08/55] fix(init): defer associated domain to linked key --- .../src/commands/init/frameworks/ios.test.ts | 27 ++++++++++++++++++- .../src/commands/init/frameworks/ios.ts | 3 +-- packages/cli-core/src/commands/init/index.ts | 3 +-- .../src/commands/init/ios/apply-cli.test.ts | 9 +++++-- .../cli-core/src/commands/init/ios/apply.ts | 3 +-- .../src/commands/init/ios/dry-run.test.ts | 3 +++ .../src/commands/init/ios/native-readiness.ts | 6 ++++- 7 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index 4b7878503..d70cc9128 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -1,10 +1,11 @@ -import { afterAll, afterEach, test, expect } from "bun:test"; +import { afterAll, afterEach, test, expect, spyOn } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ios } from "./ios.ts"; import type { ProjectContext } from "./types.ts"; import { createIOSFixture } from "../ios/test-helpers.ts"; +import * as associatedDomain from "../ios/associated-domain.ts"; const temporaryRoots: string[] = []; const emptyRoot = await mkdtemp(join(tmpdir(), "clerk-ios-framework-empty-")); @@ -90,6 +91,30 @@ test("uses direct @main configuration as the fresh-project default", async () => ).toBe(false); }); +test("defers the Associated Domain host to ready direct configuration", async () => { + const root = await makeIOSFixture(false); + const unrelatedKey = `pk_test_${Buffer.from("unrelated-framework.clerk.example$").toString("base64")}`; + await Bun.write(join(root, ".env"), `CLERK_PUBLISHABLE_KEY=${unrelatedKey}\n`); + const planner = spyOn(associatedDomain, "planIOSAssociatedDomain"); + + try { + const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); + + expect(planner).toHaveBeenCalledWith( + expect.objectContaining({ + root, + deferToPublishableKey: true, + }), + ); + expect( + plan.postInstructions.some((instruction) => instruction.includes("Associated Domains")), + ).toBe(true); + expect(plan.postInstructions.join("\n")).not.toContain("unrelated-framework.clerk.example"); + } finally { + planner.mockRestore(); + } +}); + test("omits manual Native Applications guidance after authenticated remote verification", async () => { const plan = await ios.scaffold({ ...makeCtx(), iosNativeRemoteReady: true }); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 25fbd6531..e6f6f6dd0 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -78,8 +78,7 @@ export const ios: FrameworkScaffold = { root: ctx.cwd, projectPath: selection.projectPath, targetId: selection.targetId, - deferToPublishableKey: - directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + deferToPublishableKey: directConfigPlan?.status === "ready", allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", }) : undefined; diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index f7b7a5e5e..198c11ac3 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -308,8 +308,7 @@ export async function init(options: InitOptions = {}) { root: ctx.cwd, projectPath: dryRunSelection.projectPath, targetId: dryRunSelection.targetId, - deferToPublishableKey: - directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + deferToPublishableKey: directConfigPlan?.status === "ready", allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", }) : undefined; diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index 0bc0c8988..8c378bb7f 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -464,9 +464,12 @@ struct MyApp: App { ); }); - test("links ClerkKit and ClerkKitUI to a clean target and is byte-idempotent", async () => { + test("uses the linked key host over an unrelated root env during aggregate setup", async () => { const root = await createUnconfiguredFixture(); const configDir = await createIsolatedCLIState(); + const unrelatedKey = `pk_test_${Buffer.from("unrelated-root.clerk.example$").toString("base64")}`; + const existingEnv = `CLERK_PUBLISHABLE_KEY=${unrelatedKey}\n`; + await Bun.write(join(root, ".env"), existingEnv); const result = await runCLI( root, @@ -479,6 +482,7 @@ struct MyApp: App { "ClerkKit and ClerkKitUI linked to MyApp", ); expect(`${result.stdout}\n${result.stderr}`).not.toContain(authFixtureKey); + expect(`${result.stdout}\n${result.stderr}`).not.toContain(unrelatedKey); const inspection = await inspectIOSProject(root, { target: "MyApp" }); const target = inspection.appTargets.find((candidate) => candidate.name === "MyApp"); expect(target?.packages).toEqual({ @@ -498,8 +502,9 @@ struct MyApp: App { expect(source).toContain(".environment(Clerk.shared)"); const entitlements = await Bun.file(join(root, "MyApp", "MyApp.entitlements")).text(); expect(entitlements).toContain("webcredentials:ios-apply.clerk.example"); + expect(entitlements).not.toContain("webcredentials:unrelated-root.clerk.example"); expect(`${result.stdout}\n${result.stderr}`).toContain("Clerk Associated Domain added"); - expect(await Bun.file(join(root, ".env")).exists()).toBe(false); + expect(await Bun.file(join(root, ".env")).text()).toBe(existingEnv); expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).exists()).toBe(false); const afterFirstRun = await treeDigest(root); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 58ab61570..c93b56d4e 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -454,8 +454,7 @@ export async function applyIOSLocalSetup( root: options.root, projectPath: selection.projectPath, targetId: selection.targetId, - deferToPublishableKey: - directConfigPlan != null && inspection.localPublishableKey.frontendApiHost == null, + deferToPublishableKey: directConfigPlan?.status === "ready", // A LocalSecrets write is a specialized secret transaction that cannot // yet share rollback ownership with a newly created entitlements file. allowMissingEntitlementsCreation: runtimeKeyPlan == null, diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts index 467c276c8..09ad8e328 100644 --- a/packages/cli-core/src/commands/init/ios/dry-run.test.ts +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -154,6 +154,8 @@ describe("clerk init --dry-run", () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-direct-")); temporaryDirectories.push(root); await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + const unrelatedKey = `pk_test_${Buffer.from("unrelated-dry-run.clerk.example$").toString("base64")}`; + await Bun.write(join(root, ".env"), `CLERK_PUBLISHABLE_KEY=${unrelatedKey}\n`); const configDir = await createIsolatedCLIState(); const before = await treeDigest(root); @@ -182,6 +184,7 @@ describe("clerk init --dry-run", () => { automatable: true, files: ["MyApp/MyApp.entitlements"], }); + expect(output.nativeReadiness.associatedDomain.expectedDomain).toBeUndefined(); expect(configure.description).toContain("directly"); expect(result.stdout).not.toContain("LocalSecrets"); expect(result.stdout).not.toContain("pk_test_"); diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.ts b/packages/cli-core/src/commands/init/ios/native-readiness.ts index b02a3390c..fdc51a1ce 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -328,9 +328,13 @@ function associatedDomainReadiness( code: "manual-review-required" as const, message: item.message, })) ?? []; + const plannedExpectedDomain = + associatedDomainPlan?.requiresPublishableKey === true + ? undefined + : (associatedDomainPlan?.expectedDomain ?? expectedDomain); return { status, - expectedDomain: associatedDomainPlan?.expectedDomain ?? expectedDomain, + expectedDomain: plannedExpectedDomain, files, automatable: associatedDomainPlan != null From dae3d9002aae2280718bc2e51a406bde6b2a0d9e Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 09:31:21 -0400 Subject: [PATCH 09/55] fix(init): verify preserved Apple configuration --- .../commands/init/ios/native-apple.test.ts | 107 +++++++++++++++++- .../src/commands/init/ios/native-apple.ts | 66 ++++++++++- 2 files changed, 168 insertions(+), 5 deletions(-) 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 4d1fa891b..0f4dff30b 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 @@ -110,6 +110,9 @@ function statefulAPI( failActual?: unknown; malformedDryRun?: boolean; replaceProjection?: boolean; + dryRunProjectionOverride?: Record; + actualProjectionOverride?: Record; + persistedActualState?: AppleConnection; persistActual?: boolean; } = {}, ): { @@ -173,12 +176,20 @@ function statefulAPI( ? { ...(update as Record) } : { ...current, ...(update as Record) } ) as AppleConnection; + const projectionOverride = patchOptions.dryRun + ? options.dryRunProjectionOverride + : options.actualProjectionOverride; + if (projectionOverride) Object.assign(after, structuredClone(projectionOverride)); if (patchOptions.dryRun && options.malformedDryRun) { return { config_version: version, dry_run: true, before: {}, after: {} }; } if (!patchOptions.dryRun) { writes += 1; - if (options.persistActual !== false) current = after; + if (options.persistActual !== false) { + current = options.persistedActualState + ? structuredClone(options.persistedActualState) + : after; + } version = NEXT_CONFIG_VERSION; } return { @@ -590,6 +601,100 @@ describe("native Sign in with Apple remote setup", () => { expect(captured.err).not.toContain(PRIVATE_KEY); }); + test("rejects a dry-run projection that changes a nested preserved field", async () => { + const harness = statefulAPI({ + initial: connection(false, false, { + unrelated_provider_setting: { + nested: { mode: "keep", secret: PRIVATE_KEY }, + }, + }), + dryRunProjectionOverride: { + unrelated_provider_setting: { + nested: { mode: "changed", secret: PRIVATE_KEY }, + }, + }, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + await expect(applyIOSNativeAppleConnection(prepared, harness.api)).rejects.toThrow( + "could not safely validate native Sign in with Apple", + ); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true]); + expect(harness.actualWrites()).toBe(0); + expect(captured.err).not.toContain(PRIVATE_KEY); + }); + + test("rejects an actual-write projection that changes a preserved credential value", async () => { + const changedSecret = `${PRIVATE_KEY}_CHANGED`; + const harness = statefulAPI({ + initial: connection(false, false, { client_secret: PRIVATE_KEY }), + actualProjectionOverride: { client_secret: changedSecret }, + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + let thrown: unknown; + try { + await applyIOSNativeAppleConnection(prepared, harness.api); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + message: expect.stringContaining("removed or changed existing fields"), + }); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]); + expect(harness.actualWrites()).toBe(1); + expect(String(thrown)).not.toContain(PRIVATE_KEY); + expect(String(thrown)).not.toContain(changedSecret); + expect(captured.err).not.toContain(PRIVATE_KEY); + expect(captured.err).not.toContain(changedSecret); + }); + + test("rejects a final state that drops a secret despite preserving projections", async () => { + const initial = connection(false, true, { + client_id: SERVICES_ID, + client_secret: PRIVATE_KEY, + unrelated_provider_setting: { nested: { mode: "keep" } }, + }); + const harness = statefulAPI({ + initial, + persistedActualState: connection(true, true, { + bundle_id: BUNDLE_IDENTIFIER, + client_id: SERVICES_ID, + unrelated_provider_setting: { nested: { mode: "keep" } }, + }), + }); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + + let thrown: unknown; + try { + await applyIOSNativeAppleConnection(prepared, harness.api); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("did not pass final verification"), + }); + expect(harness.patchCalls.map((call) => call.options.dryRun)).toEqual([true, false]); + expect(harness.actualWrites()).toBe(1); + expect(String(thrown)).not.toContain(PRIVATE_KEY); + expect(JSON.stringify(prepared)).not.toContain(PRIVATE_KEY); + expect(captured.err).not.toContain(PRIVATE_KEY); + }); + test("rereads final state and rejects a write that did not persist", async () => { const harness = statefulAPI({ persistActual: false }); const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { 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 b491ea81c..50916cc8d 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { dim, yellow } from "../../../lib/color.ts"; import { ApiError, @@ -19,6 +20,7 @@ import { withSpinner } from "../../../lib/spinner.ts"; const APPLE_CONNECTION_KEY = "connection_oauth_apple"; const CONFIG_VERSION_PATTERN = /^v1_[0-9a-f]{8}$/; +const NATIVE_APPLE_PATCH_FIELDS = new Set(["enabled", "authenticatable", "bundle_id"]); function iosAppleError( message: string, @@ -78,6 +80,11 @@ export type IOSNativeAppleSkipped = { export type IOSNativeApplePreparation = IOSNativeApplePlan | IOSNativeAppleSkipped; +const preservedAppleFieldFingerprints = new WeakMap< + IOSNativeApplePlan, + ReadonlyMap +>(); + export interface IOSNativeApplePatchOptions { dryRun: boolean; /** Forwarded only by clients which explicitly advertise support. */ @@ -163,6 +170,50 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function canonicalConfigValue(value: unknown): string | undefined { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value); + if (typeof value === "number") return Number.isFinite(value) ? JSON.stringify(value) : undefined; + if (Array.isArray(value)) { + const items = value.map(canonicalConfigValue); + return items.some((item) => item == null) ? undefined : `[${items.join(",")}]`; + } + if (!isRecord(value)) return undefined; + + const entries: string[] = []; + for (const key of Object.keys(value).sort()) { + const item = canonicalConfigValue(value[key]); + if (item == null) return undefined; + entries.push(`${JSON.stringify(key)}:${item}`); + } + return `{${entries.join(",")}}`; +} + +function preservedFieldFingerprints( + container: Record, +): ReadonlyMap | undefined { + const connection = container[APPLE_CONNECTION_KEY]; + if (!isRecord(connection)) return undefined; + + const fingerprints = new Map(); + for (const [key, value] of Object.entries(connection)) { + if (NATIVE_APPLE_PATCH_FIELDS.has(key)) continue; + const canonical = canonicalConfigValue(value); + if (canonical == null) return undefined; + fingerprints.set(key, new Bun.CryptoHasher("sha256").update(canonical).digest("hex")); + } + return fingerprints; +} + +function preservedFieldsMatch(before: IOSNativeApplePlan, after: IOSNativeApplePlan): boolean { + const beforeFingerprints = preservedAppleFieldFingerprints.get(before); + const afterFingerprints = preservedAppleFieldFingerprints.get(after); + if (!beforeFingerprints || !afterFingerprints) return false; + return [...beforeFingerprints].every( + ([key, fingerprint]) => afterFingerprints.get(key) === fingerprint, + ); +} + function blocker(code: IOSNativeAppleBlockerCode, message: string): IOSNativeAppleBlocker { return { code, message }; } @@ -319,7 +370,7 @@ export function buildIOSNativeApplePlan( ] : []; - return { + const plan: IOSNativeApplePlan = { schemaVersion: 1, kind: "clerk-ios-native-apple-connection", status, @@ -334,6 +385,9 @@ export function buildIOSNativeApplePlan( actions, blockers, }; + const fingerprints = preservedFieldFingerprints(options.config); + if (fingerprints) preservedAppleFieldFingerprints.set(plan, fingerprints); + return plan; } export async function auditIOSNativeAppleConnection( @@ -422,10 +476,14 @@ function validatePatchProjection( if ( !isRecord(beforeConnection) || !isRecord(afterConnection) || - Object.keys(beforeConnection).some((key) => !Object.hasOwn(afterConnection, key)) + Object.entries(beforeConnection).some( + ([key, value]) => + !Object.hasOwn(afterConnection, key) || + (!NATIVE_APPLE_PATCH_FIELDS.has(key) && !isDeepStrictEqual(afterConnection[key], value)), + ) ) { throw iosAppleError( - "Clerk returned an Apple configuration projection that removed existing fields.", + "Clerk returned an Apple configuration projection that removed or changed existing fields.", ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, ); } @@ -659,7 +717,7 @@ export async function applyIOSNativeAppleConnection( ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, ); } - if (finalPlan.status !== "satisfied") { + if (finalPlan.status !== "satisfied" || !preservedFieldsMatch(current, finalPlan)) { throw iosAppleError( "Native Sign in with Apple did not pass final verification. Rerun clerk init to reconcile the remote state safely.", ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, From 481df263f14f59d8533e8500920823fb001979e3 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 15:10:18 -0400 Subject: [PATCH 10/55] fix(init): prove SwiftUI root auth wiring --- .../src/commands/init/frameworks/ios.test.ts | 35 ++ .../src/commands/init/frameworks/ios.ts | 17 +- .../src/commands/init/ios/apply-cli.test.ts | 13 +- .../commands/init/ios/build-settings.test.ts | 4 + .../src/commands/init/ios/direct-config.ts | 264 +----------- .../cli-core/src/commands/init/ios/inspect.ts | 4 + .../src/commands/init/ios/plan.test.ts | 130 +++++- .../cli-core/src/commands/init/ios/plan.ts | 103 +++-- .../src/commands/init/ios/products.test.ts | 4 + .../src/commands/init/ios/swift-app-root.ts | 403 ++++++++++++++++++ .../src/commands/init/ios/swift.test.ts | 125 ++++++ .../cli-core/src/commands/init/ios/swift.ts | 35 +- .../cli-core/src/commands/init/ios/types.ts | 10 + 13 files changed, 825 insertions(+), 322 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/swift-app-root.ts diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index d70cc9128..60a0fa576 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -230,3 +230,38 @@ test("omits locally satisfied setup instructions for the selected target", async plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), ).toBe(true); }); + +test("only recommends callback wiring for a custom native email-link flow", async () => { + const root = await makeIOSFixture(false); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } + } + func begin(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, + ); + + const magicLinkPlan = await ios.scaffold({ ...makeCtx(), cwd: root }); + expect( + magicLinkPlan.postInstructions.some( + (instruction) => + instruction.includes("custom native email-link flow") && instruction.includes("onOpenURL"), + ), + ).toBe(true); + + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } + } + func begin() async throws { try await Clerk.shared.auth.signInWithApple() }`, + ); + const applePlan = await ios.scaffold({ ...makeCtx(), cwd: root }); + expect(applePlan.postInstructions.some((instruction) => instruction.includes("onOpenURL"))).toBe( + false, + ); +}); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index e6f6f6dd0..581bb5f72 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -91,8 +91,10 @@ export const ios: FrameworkScaffold = { associatedDomainPlan, }); const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); - const needsAttention = (id: string) => - setupPlan.steps.find((step) => step.id === id)?.status !== "satisfied"; + const needsAttention = (id: string) => { + const setupStep = setupPlan.steps.find((step) => step.id === id); + return setupStep != null && setupStep.status !== "satisfied"; + }; const packageIsVerified = target?.packages.package === "remote" || target?.packages.package === "local"; const requiredProductsLinked = @@ -174,12 +176,11 @@ export const ios: FrameworkScaffold = { : "Native Sign in with Apple is ready; AuthView displays Apple automatically, while custom flows can call `try await Clerk.shared.auth.signInWithApple()`", ] : []; - const callbackInstructions = - needsAttention("wire-auth-callbacks") && productDecision !== "prebuilt" - ? [ - "For redirect-based authentication launched outside AuthView, verify that the app forwards incoming URLs to Clerk", - ] - : []; + const callbackInstructions = needsAttention("wire-auth-callbacks") + ? [ + "For a custom native email-link flow, attach an onOpenURL handler to the shipping SwiftUI root and forward incoming URLs to Clerk", + ] + : []; return { actions: [], diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index 8c378bb7f..be5cc2ace 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -239,14 +239,15 @@ struct MyApp: App { await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); await addStarterContentViewToFixture(root); const appPath = join(root, "MyApp", "MyAppApp.swift"); - await Bun.write( - appPath, - (await Bun.file(appPath).text()).replace("import ClerkKitUI\n", "").replace( - `AuthView() + const appSource = (await Bun.file(appPath).text()).replace("import ClerkKitUI\n", "").replace( + `AuthView() .environment(Clerk.shared) .onOpenURL { url in Task { try await Clerk.shared.handle(url) } }`, - "ContentView()", - ), + "ContentView()", + ); + await Bun.write( + appPath, + `${appSource}\nstruct UnusedClerkEnvironment: View {\n var body: some View { Text("Unused").environment(Clerk.shared) }\n}\n`, ); await Bun.write( join(root, "MyApp", "LocalSecrets.plist"), 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 66aa12e9e..f5909df85 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 @@ -735,10 +735,14 @@ describe("inspectTargetBuildConfigurations", () => { importsClerkKitUI: [], configureCalls: [], localSecretsRuntimeBindings: [], + appRootEvidence: [], environmentInjections: [], + rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], + magicLinkAuthReferences: [], openURLHandlers: [], + rootOpenURLHandlers: [], status: "absent", }, }, diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts index 9f903b127..9e5527244 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -10,6 +10,12 @@ import { type IOSFileMutationBoundary, } from "./file-transaction.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; +import { + inspectSwiftUIAppRoot, + inspectSwiftUIAppRootWithStatus, + type SwiftUIAppRootStructure, + type SwiftUIRootExpression, +} from "./swift-app-root.ts"; import { sanitizeSwiftSourceWithStatus } from "./swift.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -145,10 +151,10 @@ interface AppStructure { source: string; sanitized: string; newline: "\n" | "\r\n"; - appType: Range & { openingBrace: number; closingBrace: number; declarationStart: number }; + appType: SwiftUIAppRootStructure["appType"]; initializer?: Range & { openingBrace: number; closingBrace: number }; - body: Range & { openingBrace: number; closingBrace: number; declarationStart: number }; - root: Range & { modifierStarts: number[] }; + body: SwiftUIAppRootStructure["body"]; + root: SwiftUIAppRootStructure["root"]; hasClerkKitImport: boolean; importInsertion: number; existingPublishableKey?: string; @@ -229,12 +235,6 @@ function skipWhitespace(source: string, start: number, end = source.length): num return cursor; } -function trimWhitespaceEnd(source: string, start: number, end: number): number { - let cursor = end; - while (cursor > start && /\s/.test(source[cursor - 1] ?? "")) cursor -= 1; - return cursor; -} - function matchingDelimiter( source: string, opening: number, @@ -581,64 +581,6 @@ function importInsertionPosition( return last?.end; } -function appTypeRange( - sanitized: string, - index: SwiftStructuralIndex, -): AppStructure["appType"] | undefined { - const mainMatches = [...sanitized.matchAll(/@main\b/g)]; - if (mainMatches.length !== 1 || mainMatches[0]?.index == null) return undefined; - const mainIndex = mainMatches[0].index; - if (isInsideConditionalCompilation(index, mainIndex) || braceDepthAt(index, 0, mainIndex) !== 0) { - return undefined; - } - - let cursor = mainIndex + mainMatches[0][0].length; - while (true) { - cursor = skipWhitespace(sanitized, cursor); - const attribute = /^@[A-Za-z_][A-Za-z0-9_.]*/.exec(sanitized.slice(cursor)); - if (attribute) { - cursor += attribute[0].length; - cursor = skipWhitespace(sanitized, cursor); - if (sanitized[cursor] === "(") { - const closing = matchingParenthesis(sanitized, cursor); - if (closing == null) return undefined; - cursor = closing + 1; - } - continue; - } - const modifier = /^(?:public|internal|private|fileprivate|final|nonisolated)\b/.exec( - sanitized.slice(cursor), - ); - if (!modifier) break; - cursor += modifier[0].length; - } - - cursor = skipWhitespace(sanitized, cursor); - const declaration = /^struct\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(sanitized.slice(cursor)); - if (!declaration) return undefined; - const headerStart = cursor + declaration[0].length; - const openingBrace = sanitized.indexOf("{", headerStart); - if (openingBrace === -1) return undefined; - const header = sanitized.slice(headerStart, openingBrace); - if (/[;{}<>]/.test(header) || /\bwhere\b/.test(header)) return undefined; - const inheritance = /^\s*:\s*([A-Za-z0-9_.,\s]+)\s*$/.exec(header)?.[1]; - if (!inheritance || !inheritance.split(",").some((item) => item.trim() === "App")) { - return undefined; - } - const closingBrace = matchingBrace(sanitized, openingBrace); - if (closingBrace == null) return undefined; - if (/^[\t ]*#(?:if|elseif|else|endif)\b/m.test(sanitized.slice(openingBrace, closingBrace))) { - return undefined; - } - return { - start: mainIndex, - end: closingBrace + 1, - declarationStart: cursor, - openingBrace, - closingBrace, - }; -} - interface InitializerCandidate { start: number; end: number; @@ -693,145 +635,6 @@ function initializerCandidates( return candidates; } -function bodyRange( - sanitized: string, - appType: AppStructure["appType"], - index: SwiftStructuralIndex, -): AppStructure["body"] | undefined { - const candidates: AppStructure["body"][] = []; - const pattern = /\bvar\s+body\s*:\s*some\s+Scene\b/g; - pattern.lastIndex = appType.openingBrace + 1; - let match: RegExpExecArray | null; - while ((match = pattern.exec(sanitized)) !== null && match.index < appType.closingBrace) { - if (braceDepthAt(index, appType.openingBrace, match.index) !== 1) continue; - const openingBrace = skipWhitespace(sanitized, match.index + match[0].length); - if (sanitized[openingBrace] !== "{") continue; - const closingBrace = matchingBrace(sanitized, openingBrace); - if (closingBrace == null || closingBrace > appType.closingBrace) continue; - const declarationLineStart = lineStart(sanitized, match.index); - if (sanitized.slice(declarationLineStart, match.index).trim() !== "") continue; - candidates.push({ - start: match.index, - end: closingBrace + 1, - declarationStart: declarationLineStart, - openingBrace, - closingBrace, - }); - pattern.lastIndex = closingBrace + 1; - } - return candidates.length === 1 ? candidates[0] : undefined; -} - -interface RootExpression { - start: number; - end: number; - containerStart: number; - modifierStarts: number[]; -} - -function identifierEnd(source: string, start: number): number | undefined { - const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(start)); - return match ? start + match[0].length : undefined; -} - -function consumeBalancedSuffix(source: string, cursor: number, limit: number): number | undefined { - if (source[cursor] === "(") { - const closing = matchingParenthesis(source, cursor); - if (closing == null || closing >= limit) return undefined; - cursor = closing + 1; - cursor = skipWhitespace(source, cursor, limit); - if (source[cursor] === "{") { - const closureEnd = matchingBrace(source, cursor); - if (closureEnd == null || closureEnd >= limit) return undefined; - cursor = closureEnd + 1; - } - return cursor; - } - if (source[cursor] === "{") { - const closureEnd = matchingBrace(source, cursor); - if (closureEnd == null || closureEnd >= limit) return undefined; - return closureEnd + 1; - } - return undefined; -} - -function rootExpression( - sanitized: string, - start: number, - end: number, - containerStart: number, -): RootExpression | undefined { - let cursor = skipWhitespace(sanitized, start, end); - const expressionStart = cursor; - let identifier = identifierEnd(sanitized, cursor); - if (identifier == null) return undefined; - cursor = identifier; - while (true) { - const beforeDot = skipWhitespace(sanitized, cursor, end); - if (sanitized[beforeDot] !== ".") break; - const memberStart = skipWhitespace(sanitized, beforeDot + 1, end); - identifier = identifierEnd(sanitized, memberStart); - if (identifier == null) return undefined; - const afterMember = skipWhitespace(sanitized, identifier, end); - if (sanitized[afterMember] === "(" || sanitized[afterMember] === "{") break; - cursor = identifier; - } - cursor = skipWhitespace(sanitized, cursor, end); - const primaryEnd = consumeBalancedSuffix(sanitized, cursor, end); - if (primaryEnd == null) return undefined; - cursor = primaryEnd; - - const modifierStarts: number[] = []; - while (true) { - cursor = skipWhitespace(sanitized, cursor, end); - if (sanitized[cursor] !== ".") break; - const modifierStart = cursor; - const nameStart = skipWhitespace(sanitized, cursor + 1, end); - const nameEnd = identifierEnd(sanitized, nameStart); - if (nameEnd == null) return undefined; - cursor = skipWhitespace(sanitized, nameEnd, end); - const suffixEnd = consumeBalancedSuffix(sanitized, cursor, end); - if (suffixEnd == null) return undefined; - modifierStarts.push(modifierStart); - cursor = suffixEnd; - } - cursor = skipWhitespace(sanitized, cursor, end); - if (cursor !== end) return undefined; - return { - start: expressionStart, - end: trimWhitespaceEnd(sanitized, expressionStart, end), - containerStart, - modifierStarts, - }; -} - -function windowGroupRoot( - sanitized: string, - body: AppStructure["body"], -): RootExpression | undefined { - let cursor = skipWhitespace(sanitized, body.openingBrace + 1, body.closingBrace); - const windowGroupStart = cursor; - if (!sanitized.slice(cursor).startsWith("WindowGroup")) return undefined; - const wordEnd = cursor + "WindowGroup".length; - if (/[A-Za-z0-9_]/.test(sanitized[wordEnd] ?? "")) return undefined; - cursor = skipWhitespace(sanitized, wordEnd, body.closingBrace); - if (sanitized[cursor] === "(") { - const closingParenthesis = matchingParenthesis(sanitized, cursor); - if (closingParenthesis == null || closingParenthesis >= body.closingBrace) return undefined; - cursor = skipWhitespace(sanitized, closingParenthesis + 1, body.closingBrace); - } - if (sanitized[cursor] !== "{") return undefined; - const groupClosingBrace = matchingBrace(sanitized, cursor); - if (groupClosingBrace == null || groupClosingBrace >= body.closingBrace) return undefined; - if (skipWhitespace(sanitized, groupClosingBrace + 1, body.closingBrace) !== body.closingBrace) { - return undefined; - } - const expressionStart = skipWhitespace(sanitized, cursor + 1, groupClosingBrace); - const expressionEnd = trimWhitespaceEnd(sanitized, expressionStart, groupClosingBrace); - if (expressionStart === expressionEnd) return undefined; - return rootExpression(sanitized, expressionStart, expressionEnd, windowGroupStart); -} - /** * Proves the narrow SwiftUI starter root used by the optional AuthView * scaffold. This deliberately shares the direct-config parser's structural @@ -843,12 +646,7 @@ export function hasExactIOSSwiftUIAppContentRoot(source: string): boolean { const sanitization = sanitizeSwiftSourceWithStatus(source); if (!sanitization.complete) return false; const sanitized = sanitization.sanitizedSource; - const structuralIndex = buildSwiftStructuralIndex(sanitized); - const appType = appTypeRange(sanitized, structuralIndex); - if (!appType) return false; - const body = bodyRange(sanitized, appType, structuralIndex); - if (!body) return false; - const root = windowGroupRoot(sanitized, body); + const root = inspectSwiftUIAppRoot(sanitized)?.root; if (!root) return false; const groupOpeningBrace = sanitized.lastIndexOf("{", root.start); @@ -860,32 +658,6 @@ export function hasExactIOSSwiftUIAppContentRoot(source: string): boolean { return expression === "ContentView()" || expression === "ContentView().environment(Clerk.shared)"; } -function exactEnvironmentModifier( - sanitized: string, - root: RootExpression, -): { found: boolean; conflicting: boolean } { - let found = false; - let conflicting = false; - for (const modifierStart of root.modifierStarts) { - const remainder = sanitized.slice(modifierStart, root.end); - const name = /^\.\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(remainder)?.[1]; - if (name !== "environment") continue; - const openingParenthesis = sanitized.indexOf("(", modifierStart); - const closingParenthesis = matchingParenthesis(sanitized, openingParenthesis); - if (closingParenthesis == null || closingParenthesis > root.end) { - conflicting = true; - continue; - } - const argumentsSource = sanitized.slice(openingParenthesis + 1, closingParenthesis); - if (/^\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*$/.test(argumentsSource)) { - found = true; - } else if (/\bClerk\s*\.\s*shared\b/.test(argumentsSource)) { - conflicting = true; - } - } - return { found, conflicting }; -} - function exactConfigureCall( source: string, sanitized: string, @@ -994,7 +766,7 @@ function hasPreinitializationClerkSharedAccess( function environmentInsertion( source: string, newline: "\n" | "\r\n", - root: RootExpression, + root: SwiftUIRootExpression, ): AppStructure["environmentInsertion"] { const trailingLine = source.slice(root.end, lineEnd(source, root.end)); const sharesLineWithComment = /\/\*|\/\//.test(trailingLine); @@ -1037,8 +809,8 @@ function parseAppStructure( } const sanitized = sanitization.sanitizedSource; const structuralIndex = buildSwiftStructuralIndex(sanitized); - const appType = appTypeRange(sanitized, structuralIndex); - if (!appType) { + const appRootInspection = inspectSwiftUIAppRootWithStatus(sanitized); + if (appRootInspection.status === "unsupported-app") { return { blocker: { code: "unsupported-app-structure", @@ -1046,8 +818,7 @@ function parseAppStructure( }, }; } - const body = bodyRange(sanitized, appType, structuralIndex); - if (!body) { + if (appRootInspection.status === "unsupported-body") { return { blocker: { code: "unsupported-scene", @@ -1055,8 +826,7 @@ function parseAppStructure( }, }; } - const root = windowGroupRoot(sanitized, body); - if (!root) { + if (appRootInspection.status === "unsupported-scene") { return { blocker: { code: "unsupported-scene", @@ -1065,6 +835,8 @@ function parseAppStructure( }, }; } + const appRoot = appRootInspection.structure; + const { appType, body, root } = appRoot; const initializerMatches = initializerCandidates(sanitized, appType, structuralIndex); if ( @@ -1166,7 +938,7 @@ function parseAppStructure( }; } - const environment = exactEnvironmentModifier(sanitized, root); + const environment = appRoot.clerkEnvironment; if (environment.conflicting) { return { blocker: { diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index 14f777d82..aeb510d77 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -93,10 +93,14 @@ function emptySwiftInspection() { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], + appRootEvidence: [], environmentInjections: [], + rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], + magicLinkAuthReferences: [], openURLHandlers: [], + rootOpenURLHandlers: [], status: "absent" as const, }; } diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 82b67d4fe..67f24f5d3 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -33,7 +33,6 @@ describe("buildIOSSetupPlan", () => { "install-clerk-sdk", "configure-publishable-key", "inject-clerk-environment", - "wire-auth-callbacks", "register-native-application", "add-associated-domain", "add-authentication-flow", @@ -153,6 +152,38 @@ struct MyApp: App { expect(JSON.stringify(plan)).not.toContain("pk_test_"); }); + test("does not satisfy root environment setup from an unused same-file helper", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-root-environment-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { WindowGroup { ContentView() } } + } + struct UnusedHelper: View { + var body: some View { Text("Unused").environment(Clerk.shared) } + }`, + ); + + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.environmentInjections).toEqual([ + { path: "MyApp/MyAppApp.swift" }, + ]); + expect(inspection.appTargets[0]?.swift.rootEnvironmentInjections).toEqual([]); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "review", + automatable: false, + }); + expect( + plan.steps.find((step) => step.id === "inject-clerk-environment")?.description, + ).toContain("not proven on the shipping WindowGroup root"); + }); + test("advertises a proven prebuilt AuthView scaffold without selecting it", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-prebuilt-auth-")); temporaryDirectories.push(root); @@ -175,10 +206,7 @@ struct MyApp: App { expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( "--prebuilt-auth-ui", ); - expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ - status: "review", - automatable: false, - }); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toBeUndefined(); }); test("uses the documented AuthView sheet without generating app-level callback code", async () => { @@ -201,13 +229,7 @@ struct MyApp: App { status: "required", automatable: true, }); - expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ - status: "satisfied", - automatable: false, - }); - expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")?.description).toContain( - "does not need generated app-level callback code", - ); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toBeUndefined(); expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( "network-free local plan", ); @@ -216,6 +238,85 @@ struct MyApp: App { ); }); + test("scopes callback review to custom email-link flows on the proven root", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-magic-link-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + appPath, + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + } + } + func send(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, + ); + + const provenPlan = buildIOSSetupPlan(await inspectIOSProject(root)); + expect(provenPlan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ + title: "Wire custom email-link callbacks", + status: "satisfied", + automatable: false, + }); + + await Bun.write( + appPath, + `import ClerkKit + import SwiftUI + @main struct MyApp: App { + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } + } + struct UnusedHelper: View { + var body: some View { + Text("Unused").onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + } + func send(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, + ); + + const offRootPlan = buildIOSSetupPlan(await inspectIOSProject(root)); + expect(offRootPlan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ + status: "review", + automatable: false, + }); + expect( + offRootPlan.steps.find((step) => step.id === "wire-auth-callbacks")?.description, + ).toContain("not proven on the shipping WindowGroup root"); + }); + + test("omits callback setup for AuthView and non-magic custom authentication", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-non-magic-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: false, includeKey: false }); + const appPath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + appPath, + `import ClerkKit + import ClerkKitUI + import SwiftUI + @main struct MyApp: App { + var body: some Scene { WindowGroup { AuthView().environment(Clerk.shared) } } + } + func otherFlows() async throws { + _ = try await Clerk.shared.auth.signInWithPassword(identifier: "a", password: "b") + _ = try await Clerk.shared.auth.signInWithEmailCode(emailAddress: "a") + _ = try await Clerk.shared.auth.signInWithOAuth(provider: .google) + _ = try await Clerk.shared.auth.signInWithApple() + _ = try await Clerk.shared.auth.startHostedAuth() + }`, + ); + + const plan = buildIOSSetupPlan(await inspectIOSProject(root)); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toBeUndefined(); + }); + test("blocks a selected AuthView scaffold when the SDK compatibility proof fails", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-old-prebuilt-sdk-")); temporaryDirectories.push(root); @@ -248,10 +349,7 @@ struct MyApp: App { status: "blocked", automatable: false, }); - expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ - status: "review", - automatable: false, - }); + expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toBeUndefined(); }); test("blocks an explicitly requested scaffold over a partial existing auth flow", async () => { diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index a6d55b872..e0791a344 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -168,7 +168,6 @@ export function buildIOSSetupPlan( ["install-clerk-sdk", "Install Clerk's iOS SDK"], ["configure-publishable-key", "Configure Clerk"], ["inject-clerk-environment", "Inject Clerk into SwiftUI"], - ["wire-auth-callbacks", "Wire authentication callbacks"], ["register-native-application", "Register the native application"], ["add-associated-domain", "Add the associated domain"], ["add-authentication-flow", "Add an authentication flow"], @@ -358,7 +357,17 @@ export function buildIOSSetupPlan( ), ); - const injected = target.swift.environmentInjections.length > 0; + const provenAppRoot = + !sourceEntryPointIsAmbiguous && + target.swift.entryPoints.length === 1 && + target.swift.appRootEvidence.length === 1 && + target.swift.appRootEvidence[0]?.path === target.swift.entryPoints[0]?.path; + const injected = + provenAppRoot && + target.swift.rootEnvironmentInjections.some( + (evidence) => evidence.path === target.swift.appRootEvidence[0]?.path, + ); + const hasUnprovenInjection = target.swift.environmentInjections.length > 0 && !injected; const requiresSwiftUIEnvironment = target.swift.environmentConsumers.length > 0 || includeClerkKitUI || directConfigPlanApplies; const directEnvironmentAutomationReady = @@ -367,13 +376,13 @@ export function buildIOSSetupPlan( options.directConfigPlan.changes?.environment === "insert"; const directEnvironmentBlocked = !injected && requiresSwiftUIEnvironment && directConfigBlocked; const injectedStatus: IOSSetupStepStatus = injected - ? sourceEntryPointIsAmbiguous - ? "review" - : "satisfied" + ? "satisfied" : directEnvironmentBlocked ? "blocked" - : target.swift.evidenceComplete && requiresSwiftUIEnvironment - ? "required" + : requiresSwiftUIEnvironment + ? target.swift.evidenceComplete && provenAppRoot && !hasUnprovenInjection + ? "required" + : "review" : "review"; steps.push( step( @@ -381,50 +390,56 @@ export function buildIOSSetupPlan( "Inject Clerk into the SwiftUI environment", injectedStatus, injected - ? sourceEntryPointIsAmbiguous - ? "Clerk.shared is injected, but multiple @main entry points make the shipping root ambiguous." - : "Clerk.shared is injected into SwiftUI." + ? "Clerk.shared is injected into the proven shipping WindowGroup root." : directEnvironmentBlocked ? `Automatic SwiftUI environment injection stopped because the selected startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's WindowGroup root manually."}` - : target.swift.evidenceComplete && requiresSwiftUIEnvironment - ? directEnvironmentAutomationReady - ? `clerk init can add \`.environment(Clerk.shared)\` to the proven WindowGroup root in ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App source"}.` - : "At the app's root view, add `.environment(Clerk.shared)` so Clerk-aware views receive the configured client." - : requiresSwiftUIEnvironment - ? "Clerk.shared injection was not found in the safely inspected source subset. Confirm the shipping root manually." - : "No target source was found consuming Clerk from SwiftUI's environment. Add `.environment(Clerk.shared)` only if AuthView or an `@Environment(Clerk.self)` view needs it.", - target.swift.environmentInjections, + : hasUnprovenInjection + ? "A Clerk.shared environment modifier exists in target source, but it is not proven on the shipping WindowGroup root. Confirm the mounted root manually." + : requiresSwiftUIEnvironment && !provenAppRoot + ? "The shipping SwiftUI root could not be proven structurally. Confirm that its mounted root injects Clerk.shared." + : target.swift.evidenceComplete && requiresSwiftUIEnvironment + ? directEnvironmentAutomationReady + ? `clerk init can add \`.environment(Clerk.shared)\` to the proven WindowGroup root in ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App source"}.` + : "At the app's root view, add `.environment(Clerk.shared)` so Clerk-aware views receive the configured client." + : requiresSwiftUIEnvironment + ? "Clerk.shared injection was not found in the safely inspected source subset. Confirm the shipping root manually." + : "No target source was found consuming Clerk from SwiftUI's environment. Add `.environment(Clerk.shared)` only if AuthView or an `@Environment(Clerk.self)` view needs it.", + injected ? target.swift.rootEnvironmentInjections : target.swift.environmentInjections, undefined, directEnvironmentAutomationReady, ), ); - const handlesURLs = target.swift.openURLHandlers.length > 0; - const selectedPrebuiltAuthReady = - options.prebuiltAuthSelected === true && - options.prebuiltAuthPlan?.status === "ready" && - !strictSDKBlocked; - const prebuiltAuthHandlesItsOwnCallbacks = - selectedPrebuiltAuthReady || options.prebuiltAuthPlan?.status === "satisfied"; - steps.push( - step( - "wire-auth-callbacks", - "Wire authentication callbacks", - handlesURLs && !sourceEntryPointIsAmbiguous - ? "satisfied" - : prebuiltAuthHandlesItsOwnCallbacks && !sourceEntryPointIsAmbiguous - ? "satisfied" - : "review", - handlesURLs - ? "An onOpenURL handler forwards redirect URLs to Clerk." - : prebuiltAuthHandlesItsOwnCallbacks - ? "ClerkKitUI's AuthView handles its callback lifecycle while presented, so this quickstart flow does not need generated app-level callback code." - : "For redirect-based authentication launched outside AuthView, verify that the app forwards incoming URLs to Clerk.", - target.swift.openURLHandlers, - undefined, - false, - ), - ); + if (target.swift.magicLinkAuthReferences.length > 0) { + const rootHandlesMagicLinks = + provenAppRoot && + target.swift.rootOpenURLHandlers.some( + (evidence) => evidence.path === target.swift.appRootEvidence[0]?.path, + ); + const hasUnprovenHandler = target.swift.openURLHandlers.length > 0 && !rootHandlesMagicLinks; + steps.push( + step( + "wire-auth-callbacks", + "Wire custom email-link callbacks", + rootHandlesMagicLinks ? "satisfied" : "review", + rootHandlesMagicLinks + ? "The proven shipping WindowGroup root forwards custom email-link callbacks to Clerk." + : hasUnprovenHandler + ? "A Clerk onOpenURL handler exists in target source, but it is not proven on the shipping WindowGroup root. Confirm that custom email-link callbacks reach Clerk." + : provenAppRoot + ? "A custom email-link flow is referenced, but the proven shipping WindowGroup root does not forward incoming URLs to Clerk. Review the flow's callback wiring." + : "A custom email-link flow is referenced, but the shipping root and its callback wiring could not be proven structurally. Review the flow manually.", + [ + ...target.swift.magicLinkAuthReferences, + ...(rootHandlesMagicLinks + ? target.swift.rootOpenURLHandlers + : target.swift.openURLHandlers), + ], + undefined, + false, + ), + ); + } const bundleIdentifiers = distinctResolved( target, 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 4f89e80de..906de42a4 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -21,10 +21,14 @@ function target(): IOSAppTarget { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], + appRootEvidence: [], environmentInjections: [], + rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], + magicLinkAuthReferences: [], openURLHandlers: [], + rootOpenURLHandlers: [], status: "absent", }, }; diff --git a/packages/cli-core/src/commands/init/ios/swift-app-root.ts b/packages/cli-core/src/commands/init/ios/swift-app-root.ts new file mode 100644 index 000000000..7e059bc3d --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/swift-app-root.ts @@ -0,0 +1,403 @@ +export interface SwiftSourceRange { + start: number; + end: number; +} + +export interface SwiftUIAppTypeRange extends SwiftSourceRange { + declarationStart: number; + openingBrace: number; + closingBrace: number; +} + +export interface SwiftUISceneBodyRange extends SwiftSourceRange { + declarationStart: number; + openingBrace: number; + closingBrace: number; +} + +export interface SwiftUIRootExpression extends SwiftSourceRange { + containerStart: number; + modifierStarts: number[]; +} + +export interface SwiftUIAppRootStructure { + appType: SwiftUIAppTypeRange; + body: SwiftUISceneBodyRange; + root: SwiftUIRootExpression; + clerkEnvironment: { found: boolean; conflicting: boolean }; + clerkOpenURLHandler: boolean; +} + +export type SwiftUIAppRootInspection = + | { status: "proven"; structure: SwiftUIAppRootStructure } + | { status: "unsupported-app" } + | { status: "unsupported-body" } + | { status: "unsupported-scene" }; + +interface StructuralIndex { + braceDepth: Int32Array; + conditionalRanges: SwiftSourceRange[]; +} + +function skipWhitespace(source: string, start: number, end = source.length): number { + let cursor = start; + while (cursor < end && /\s/.test(source[cursor] ?? "")) cursor += 1; + return cursor; +} + +function trimWhitespaceEnd(source: string, start: number, end: number): number { + let cursor = end; + while (cursor > start && /\s/.test(source[cursor - 1] ?? "")) cursor -= 1; + return cursor; +} + +function matchingDelimiter( + source: string, + opening: number, + openCharacter: "(" | "{" | "[", + closeCharacter: ")" | "}" | "]", +): number | undefined { + if (source[opening] !== openCharacter) return undefined; + let depth = 0; + for (let index = opening; index < source.length; index += 1) { + if (source[index] === openCharacter) depth += 1; + if (source[index] !== closeCharacter) continue; + depth -= 1; + if (depth === 0) return index; + } + return undefined; +} + +function matchingBrace(source: string, opening: number): number | undefined { + return matchingDelimiter(source, opening, "{", "}"); +} + +function matchingParenthesis(source: string, opening: number): number | undefined { + return matchingDelimiter(source, opening, "(", ")"); +} + +function structuralIndex(source: string): StructuralIndex { + const braceDepth = new Int32Array(source.length + 1); + for (let position = 0; position < source.length; position += 1) { + braceDepth[position + 1] = + braceDepth[position]! + (source[position] === "{" ? 1 : source[position] === "}" ? -1 : 0); + } + + const conditionalRanges: SwiftSourceRange[] = []; + const directive = /^[\t ]*#(if|elseif|else|endif)\b/gm; + let depth = 0; + let rangeStart: number | undefined; + let match: RegExpExecArray | null; + while ((match = directive.exec(source)) !== null) { + if (match[1] === "if") { + if (depth === 0) rangeStart = match.index; + depth += 1; + } + if (match[1] === "endif" && depth > 0) { + depth -= 1; + if (depth === 0 && rangeStart != null) { + conditionalRanges.push({ start: rangeStart, end: match.index }); + rangeStart = undefined; + } + } + } + if (rangeStart != null) conditionalRanges.push({ start: rangeStart, end: source.length }); + return { braceDepth, conditionalRanges }; +} + +function braceDepthAt(index: StructuralIndex, openingBrace: number, position: number): number { + return index.braceDepth[position]! - index.braceDepth[openingBrace]!; +} + +function isInsideConditionalCompilation(index: StructuralIndex, position: number): boolean { + return index.conditionalRanges.some((range) => position >= range.start && position < range.end); +} + +function appTypeRange(source: string, index: StructuralIndex): SwiftUIAppTypeRange | undefined { + const mainMatches = [...source.matchAll(/@main\b/g)]; + if (mainMatches.length !== 1 || mainMatches[0]?.index == null) return undefined; + const mainIndex = mainMatches[0].index; + if (isInsideConditionalCompilation(index, mainIndex) || braceDepthAt(index, 0, mainIndex) !== 0) { + return undefined; + } + + let cursor = mainIndex + mainMatches[0][0].length; + while (true) { + cursor = skipWhitespace(source, cursor); + const attribute = /^@[A-Za-z_][A-Za-z0-9_.]*/.exec(source.slice(cursor)); + if (attribute) { + cursor += attribute[0].length; + cursor = skipWhitespace(source, cursor); + if (source[cursor] === "(") { + const closing = matchingParenthesis(source, cursor); + if (closing == null) return undefined; + cursor = closing + 1; + } + continue; + } + const modifier = /^(?:public|internal|private|fileprivate|final|nonisolated)\b/.exec( + source.slice(cursor), + ); + if (!modifier) break; + cursor += modifier[0].length; + } + + cursor = skipWhitespace(source, cursor); + const declaration = /^struct\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(source.slice(cursor)); + if (!declaration) return undefined; + const headerStart = cursor + declaration[0].length; + const openingBrace = source.indexOf("{", headerStart); + if (openingBrace === -1) return undefined; + const header = source.slice(headerStart, openingBrace); + if (/[;{}<>]/.test(header) || /\bwhere\b/.test(header)) return undefined; + const inheritance = /^\s*:\s*([A-Za-z0-9_.,\s]+)\s*$/.exec(header)?.[1]; + if (!inheritance || !inheritance.split(",").some((item) => item.trim() === "App")) { + return undefined; + } + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null) return undefined; + if (/^[\t ]*#(?:if|elseif|else|endif)\b/m.test(source.slice(openingBrace, closingBrace))) { + return undefined; + } + return { + start: mainIndex, + end: closingBrace + 1, + declarationStart: cursor, + openingBrace, + closingBrace, + }; +} + +function bodyRange( + source: string, + appType: SwiftUIAppTypeRange, + index: StructuralIndex, +): SwiftUISceneBodyRange | undefined { + const candidates: SwiftUISceneBodyRange[] = []; + const pattern = /\bvar\s+body\s*:\s*some\s+Scene\b/g; + pattern.lastIndex = appType.openingBrace + 1; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source)) !== null && match.index < appType.closingBrace) { + if (braceDepthAt(index, appType.openingBrace, match.index) !== 1) continue; + const openingBrace = skipWhitespace(source, match.index + match[0].length); + if (source[openingBrace] !== "{") continue; + const closingBrace = matchingBrace(source, openingBrace); + if (closingBrace == null || closingBrace > appType.closingBrace) continue; + const declarationLineStart = source.lastIndexOf("\n", Math.max(0, match.index - 1)) + 1; + if (source.slice(declarationLineStart, match.index).trim() !== "") continue; + candidates.push({ + start: match.index, + end: closingBrace + 1, + declarationStart: declarationLineStart, + openingBrace, + closingBrace, + }); + pattern.lastIndex = closingBrace + 1; + } + return candidates.length === 1 ? candidates[0] : undefined; +} + +function identifierEnd(source: string, start: number): number | undefined { + const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(start)); + return match ? start + match[0].length : undefined; +} + +function consumeBalancedSuffix(source: string, cursor: number, limit: number): number | undefined { + if (source[cursor] === "(") { + const closing = matchingParenthesis(source, cursor); + if (closing == null || closing >= limit) return undefined; + cursor = skipWhitespace(source, closing + 1, limit); + if (source[cursor] === "{") { + const closureEnd = matchingBrace(source, cursor); + if (closureEnd == null || closureEnd >= limit) return undefined; + cursor = closureEnd + 1; + } + return cursor; + } + if (source[cursor] === "{") { + const closureEnd = matchingBrace(source, cursor); + if (closureEnd == null || closureEnd >= limit) return undefined; + return closureEnd + 1; + } + return undefined; +} + +function rootExpression( + source: string, + start: number, + end: number, + containerStart: number, +): SwiftUIRootExpression | undefined { + let cursor = skipWhitespace(source, start, end); + const expressionStart = cursor; + let identifier = identifierEnd(source, cursor); + if (identifier == null) return undefined; + cursor = identifier; + while (true) { + const beforeDot = skipWhitespace(source, cursor, end); + if (source[beforeDot] !== ".") break; + const memberStart = skipWhitespace(source, beforeDot + 1, end); + identifier = identifierEnd(source, memberStart); + if (identifier == null) return undefined; + const afterMember = skipWhitespace(source, identifier, end); + if (source[afterMember] === "(" || source[afterMember] === "{") break; + cursor = identifier; + } + cursor = skipWhitespace(source, cursor, end); + const primaryEnd = consumeBalancedSuffix(source, cursor, end); + if (primaryEnd == null) return undefined; + cursor = primaryEnd; + + const modifierStarts: number[] = []; + while (true) { + cursor = skipWhitespace(source, cursor, end); + if (source[cursor] !== ".") break; + const modifierStart = cursor; + const nameStart = skipWhitespace(source, cursor + 1, end); + const nameEnd = identifierEnd(source, nameStart); + if (nameEnd == null) return undefined; + cursor = skipWhitespace(source, nameEnd, end); + const suffixEnd = consumeBalancedSuffix(source, cursor, end); + if (suffixEnd == null) return undefined; + modifierStarts.push(modifierStart); + cursor = suffixEnd; + } + cursor = skipWhitespace(source, cursor, end); + if (cursor !== end) return undefined; + return { + start: expressionStart, + end: trimWhitespaceEnd(source, expressionStart, end), + containerStart, + modifierStarts, + }; +} + +function windowGroupRoot( + source: string, + body: SwiftUISceneBodyRange, +): SwiftUIRootExpression | undefined { + let cursor = skipWhitespace(source, body.openingBrace + 1, body.closingBrace); + const windowGroupStart = cursor; + if (!source.slice(cursor).startsWith("WindowGroup")) return undefined; + const wordEnd = cursor + "WindowGroup".length; + if (/[A-Za-z0-9_]/.test(source[wordEnd] ?? "")) return undefined; + cursor = skipWhitespace(source, wordEnd, body.closingBrace); + if (source[cursor] === "(") { + const closingParenthesis = matchingParenthesis(source, cursor); + if (closingParenthesis == null || closingParenthesis >= body.closingBrace) return undefined; + cursor = skipWhitespace(source, closingParenthesis + 1, body.closingBrace); + } + if (source[cursor] !== "{") return undefined; + const groupClosingBrace = matchingBrace(source, cursor); + if (groupClosingBrace == null || groupClosingBrace >= body.closingBrace) return undefined; + if (skipWhitespace(source, groupClosingBrace + 1, body.closingBrace) !== body.closingBrace) { + return undefined; + } + const expressionStart = skipWhitespace(source, cursor + 1, groupClosingBrace); + const expressionEnd = trimWhitespaceEnd(source, expressionStart, groupClosingBrace); + if (expressionStart === expressionEnd) return undefined; + return rootExpression(source, expressionStart, expressionEnd, windowGroupStart); +} + +function modifierDetails( + source: string, + root: SwiftUIRootExpression, + modifierStart: number, +): + | { name: string; openingParenthesis?: number; closingParenthesis?: number; body?: string } + | undefined { + const remainder = source.slice(modifierStart, root.end); + const name = /^\.\s*([A-Za-z_][A-Za-z0-9_]*)/.exec(remainder)?.[1]; + if (!name) return undefined; + const nameEnd = modifierStart + (remainder.indexOf(name) + name.length); + const suffixStart = skipWhitespace(source, nameEnd, root.end); + if (source[suffixStart] === "(") { + const closingParenthesis = matchingParenthesis(source, suffixStart); + if (closingParenthesis == null || closingParenthesis > root.end) return { name }; + const closureStart = skipWhitespace(source, closingParenthesis + 1, root.end); + const closureEnd = + source[closureStart] === "{" ? matchingBrace(source, closureStart) : undefined; + return { + name, + openingParenthesis: suffixStart, + closingParenthesis, + body: + closureEnd == null + ? source.slice(suffixStart + 1, closingParenthesis) + : source.slice(closureStart + 1, closureEnd), + }; + } + if (source[suffixStart] === "{") { + const closureEnd = matchingBrace(source, suffixStart); + return closureEnd == null + ? { name } + : { name, body: source.slice(suffixStart + 1, closureEnd) }; + } + return { name }; +} + +function clerkEnvironment( + source: string, + root: SwiftUIRootExpression, +): { found: boolean; conflicting: boolean } { + let found = false; + let conflicting = false; + for (const modifierStart of root.modifierStarts) { + const modifier = modifierDetails(source, root, modifierStart); + if (modifier?.name !== "environment") continue; + if (modifier.openingParenthesis == null || modifier.closingParenthesis == null) { + conflicting = true; + continue; + } + const argumentsSource = source.slice( + modifier.openingParenthesis + 1, + modifier.closingParenthesis, + ); + if (/^\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*$/.test(argumentsSource)) { + found = true; + } else if (/\bClerk\s*\.\s*shared\b/.test(argumentsSource)) { + conflicting = true; + } + } + return { found, conflicting }; +} + +function hasClerkOpenURLHandler(source: string, root: SwiftUIRootExpression): boolean { + const clerkHandler = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; + return root.modifierStarts.some((modifierStart) => { + const modifier = modifierDetails(source, root, modifierStart); + return ( + modifier?.name === "onOpenURL" && modifier.body != null && clerkHandler.test(modifier.body) + ); + }); +} + +/** + * Proves only the narrow shipping SwiftUI root that Clerk can reason about + * deterministically: one unconditional top-level `@main` App, one + * `body: some Scene`, one WindowGroup, and one direct root expression. + */ +export function inspectSwiftUIAppRootWithStatus(source: string): SwiftUIAppRootInspection { + const index = structuralIndex(source); + const appType = appTypeRange(source, index); + if (!appType) return { status: "unsupported-app" }; + const body = bodyRange(source, appType, index); + if (!body) return { status: "unsupported-body" }; + const root = windowGroupRoot(source, body); + if (!root) return { status: "unsupported-scene" }; + return { + status: "proven", + structure: { + appType, + body, + root, + clerkEnvironment: clerkEnvironment(source, root), + clerkOpenURLHandler: hasClerkOpenURLHandler(source, root), + }, + }; +} + +export function inspectSwiftUIAppRoot(source: string): SwiftUIAppRootStructure | undefined { + const result = inspectSwiftUIAppRootWithStatus(source); + return result.status === "proven" ? result.structure : undefined; +} 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 353ee8ace..01e90753e 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -724,6 +724,130 @@ Clerk.configure(publishableKey: key)`, expect(inspection.openURLHandlers).toEqual([{ path: "ClerkCallback.swift" }]); }); + test("distinguishes broad Clerk modifiers from the proven shipping SwiftUI root", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-")); + temporaryDirectories.push(root); + const appPath = join(root, "App.swift"); + const helperPath = join(root, "UnusedHelper.swift"); + await Bun.write( + appPath, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { ContentView() } + } + }`, + ); + await Bun.write( + helperPath, + `import ClerkKit + import SwiftUI + struct UnusedHelper: View { + var body: some View { + Text("Unused") + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: appPath, relativePath: "App.swift" }, + { absolutePath: helperPath, relativePath: "UnusedHelper.swift" }, + ]); + + expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); + expect(inspection.environmentInjections).toEqual([{ path: "UnusedHelper.swift" }]); + expect(inspection.rootEnvironmentInjections).toEqual([]); + expect(inspection.openURLHandlers).toEqual([{ path: "UnusedHelper.swift" }]); + expect(inspection.rootOpenURLHandlers).toEqual([]); + expect(inspection.status).toBe("partial"); + }); + + test("proves Clerk modifiers only when attached to the unique WindowGroup root", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + init() { Clerk.configure(publishableKey: "pk_test_redacted") } + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + } + } + func beginMagicLink(_ signIn: SignIn) async throws { + try await signIn.sendEmailLink() + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); + expect(inspection.rootEnvironmentInjections).toEqual([{ path: "App.swift" }]); + expect(inspection.magicLinkAuthReferences).toEqual([{ path: "App.swift" }]); + expect(inspection.rootOpenURLHandlers).toEqual([{ path: "App.swift" }]); + expect(inspection.status).toBe("complete"); + }); + + test("does not prove an ambiguous, unsupported, or sanitized-decoy app root", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-")); + temporaryDirectories.push(root); + const firstPath = join(root, "First.swift"); + const secondPath = join(root, "Second.swift"); + await Bun.write( + firstPath, + `import ClerkKit + import SwiftUI + // .environment(Clerk.shared).onOpenURL { try await Clerk.shared.handle(url) } + let decoy = #/signIn.sendEmailLink()/# + @main struct First: App { + var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } + }`, + ); + await Bun.write( + secondPath, + `import ClerkKit + import SwiftUI + @main struct Second: App { + var body: some Scene { WindowGroup { ContentView() } } + }`, + ); + + const ambiguous = await inspectSwiftSources([ + { absolutePath: firstPath, relativePath: "First.swift" }, + { absolutePath: secondPath, relativePath: "Second.swift" }, + ]); + expect(ambiguous.status).toBe("ambiguous"); + expect(ambiguous.appRootEvidence).toEqual([]); + expect(ambiguous.rootEnvironmentInjections).toEqual([]); + expect(ambiguous.magicLinkAuthReferences).toEqual([]); + + await Bun.write( + secondPath, + `import ClerkKit + import SwiftUI + @main struct Unsupported: App { + var body: some Scene { WindowGroup { ContentView() }; Settings { Text("Settings") } } + }`, + ); + const unsupported = await inspectSwiftSources([ + { absolutePath: secondPath, relativePath: "Second.swift" }, + ]); + expect(unsupported.appRootEvidence).toEqual([]); + expect(unsupported.rootEnvironmentInjections).toEqual([]); + expect(unsupported.rootOpenURLHandlers).toEqual([]); + }); + test("recognizes native Clerk auth calls without matching unrelated sign-in APIs", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); @@ -798,6 +922,7 @@ Clerk.configure(publishableKey: key)`, { path: "Password.swift" }, { path: "SignUp.swift" }, ]); + expect(inspection.magicLinkAuthReferences).toEqual([]); }); test("marks multiple entry points as ambiguous", async () => { diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 2dce01844..6046967e1 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -7,6 +7,7 @@ import type { IOSSourceEvidence, IOSSwiftInspection, } from "./types.ts"; +import { inspectSwiftUIAppRoot } from "./swift-app-root.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -14,6 +15,7 @@ const CLERK_CONFIGURE_CALL = /\bClerk\s*\.\s*configure\s*\(/; const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; const CLERK_NATIVE_AUTH_FLOW = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; +const CLERK_MAGIC_LINK_AUTH_FLOW = /\.\s*sendEmailLink\s*\(/; const CLERK_ENVIRONMENT_INJECTION = /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*\)/; const CLERK_ENVIRONMENT_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; @@ -96,6 +98,7 @@ const CLERK_EVIDENCE_PATTERNS = [ CLERK_CONFIGURE_CALL, CLERK_URL_HANDLER, CLERK_NATIVE_AUTH_FLOW, + CLERK_MAGIC_LINK_AUTH_FLOW, CLERK_ENVIRONMENT_INJECTION, CLERK_ENVIRONMENT_CONSUMER, CLERK_AUTH_VIEW, @@ -759,10 +762,14 @@ export async function inspectSwiftSources( const importsClerkKit: IOSSourceEvidence[] = []; const importsClerkKitUI: IOSSourceEvidence[] = []; const configureCalls: IOSConfigureCallEvidence[] = []; + const appRootEvidence: IOSSourceEvidence[] = []; const environmentInjections: IOSSourceEvidence[] = []; + const rootEnvironmentInjections: IOSSourceEvidence[] = []; const environmentConsumers: IOSSourceEvidence[] = []; const authFlowReferences: IOSSourceEvidence[] = []; + const magicLinkAuthReferences: IOSSourceEvidence[] = []; const openURLHandlers: IOSSourceEvidence[] = []; + const rootOpenURLHandlers: IOSSourceEvidence[] = []; let sourceFilesScanned = 0; let evidenceComplete = options.membershipComplete ?? true; @@ -799,8 +806,10 @@ export async function inspectSwiftSources( const importsUI = has(sanitized, CLERK_KIT_UI_IMPORT); const importsClerkModule = importsKit || importsUI; if (hasConditionalSetupEvidence(uncertain, importsClerkModule)) evidenceComplete = false; + const appRoot = structuralSource.complete ? inspectSwiftUIAppRoot(sanitized) : undefined; if (has(sanitized, /@main\b/)) entryPoints.push(evidence); + if (appRoot) appRootEvidence.push(evidence); if (importsKit) importsClerkKit.push(evidence); if (importsUI) importsClerkKitUI.push(evidence); if (importsClerkModule) { @@ -809,6 +818,9 @@ export async function inspectSwiftSources( if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_INJECTION)) { environmentInjections.push(evidence); } + if (importsClerkModule && appRoot?.clerkEnvironment.found) { + rootEnvironmentInjections.push(evidence); + } if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_CONSUMER)) { environmentConsumers.push(evidence); } @@ -818,23 +830,38 @@ export async function inspectSwiftSources( ) { authFlowReferences.push(evidence); } + if (importsClerkModule && has(sanitized, CLERK_MAGIC_LINK_AUTH_FLOW)) { + magicLinkAuthReferences.push(evidence); + } if (importsClerkModule && hasClerkOpenURLHandler(sanitized)) { openURLHandlers.push(evidence); } + if (importsClerkModule && appRoot?.clerkOpenURLHandler) { + rootOpenURLHandlers.push(evidence); + } } + const hasUniqueProvenAppRoot = + entryPoints.length === 1 && + appRootEvidence.length === 1 && + appRootEvidence[0]?.path === entryPoints[0]?.path; + const provenAppRootEvidence = hasUniqueProvenAppRoot ? appRootEvidence : []; + const provenRootEnvironmentInjections = hasUniqueProvenAppRoot ? rootEnvironmentInjections : []; + const provenRootOpenURLHandlers = hasUniqueProvenAppRoot ? rootOpenURLHandlers : []; + const anyClerkEvidence = importsClerkKit.length + importsClerkKitUI.length + configureCalls.length + environmentInjections.length + environmentConsumers.length + - authFlowReferences.length > + authFlowReferences.length + + magicLinkAuthReferences.length > 0; const status = entryPoints.length > 1 ? "ambiguous" - : configureCalls.length > 0 && environmentInjections.length > 0 + : configureCalls.length > 0 && provenRootEnvironmentInjections.length > 0 ? "complete" : anyClerkEvidence ? "partial" @@ -847,10 +874,14 @@ export async function inspectSwiftSources( importsClerkKit, importsClerkKitUI, configureCalls, + appRootEvidence: provenAppRootEvidence, environmentInjections, + rootEnvironmentInjections: provenRootEnvironmentInjections, environmentConsumers, authFlowReferences, + magicLinkAuthReferences, openURLHandlers, + rootOpenURLHandlers: provenRootOpenURLHandlers, status, }; } diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index cf60ac50f..d93fde894 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -119,10 +119,20 @@ export interface IOSSwiftInspection { importsClerkKit: IOSSourceEvidence[]; importsClerkKitUI: IOSSourceEvidence[]; configureCalls: IOSConfigureCallEvidence[]; + /** Unique selected-target @main SwiftUI App with a structurally proven WindowGroup root. */ + appRootEvidence: IOSSourceEvidence[]; + /** Broad lexical evidence retained for diagnostics and conflict detection only. */ environmentInjections: IOSSourceEvidence[]; + /** Clerk environment injection directly attached to the proven shipping WindowGroup root. */ + rootEnvironmentInjections: IOSSourceEvidence[]; environmentConsumers: IOSSourceEvidence[]; authFlowReferences: IOSSourceEvidence[]; + /** Lexical selected-target evidence of a custom native email-link flow. */ + magicLinkAuthReferences: IOSSourceEvidence[]; + /** Broad lexical evidence retained for diagnostics and conflict detection only. */ openURLHandlers: IOSSourceEvidence[]; + /** Clerk URL handler directly attached to the proven shipping WindowGroup root. */ + rootOpenURLHandlers: IOSSourceEvidence[]; status: "complete" | "partial" | "absent" | "ambiguous"; } From 8f0cace28e9de3f1bcc4dd348fb8edd0b8a8a646 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 15:13:28 -0400 Subject: [PATCH 11/55] fix(init): detect email-link convenience flow --- .../src/commands/init/ios/swift.test.ts | 19 +++++++++++++++++++ .../cli-core/src/commands/init/ios/swift.ts | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) 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 01e90753e..65e68bca1 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -799,6 +799,25 @@ Clerk.configure(publishableKey: key)`, expect(inspection.status).toBe("complete"); }); + test("recognizes the Auth email-link convenience API as a custom magic-link flow", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-magic-link-")); + temporaryDirectories.push(root); + const path = join(root, "MagicLink.swift"); + await Bun.write( + path, + `import ClerkKit + func beginMagicLink() async throws { + try await Clerk.shared.auth.signInWithEmailLink(emailAddress: "person@example.com") + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "MagicLink.swift" }, + ]); + + expect(inspection.magicLinkAuthReferences).toEqual([{ path: "MagicLink.swift" }]); + }); + test("does not prove an ambiguous, unsupported, or sanitized-decoy app root", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 6046967e1..e35cc6001 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -15,7 +15,8 @@ const CLERK_CONFIGURE_CALL = /\bClerk\s*\.\s*configure\s*\(/; const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; const CLERK_NATIVE_AUTH_FLOW = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; -const CLERK_MAGIC_LINK_AUTH_FLOW = /\.\s*sendEmailLink\s*\(/; +const CLERK_MAGIC_LINK_AUTH_FLOW = + /(?:\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*signInWithEmailLink|\.\s*sendEmailLink)\s*\(/; const CLERK_ENVIRONMENT_INJECTION = /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*\)/; const CLERK_ENVIRONMENT_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; From 1b2c7d096dabaa66be58968af16fa621a54340f2 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 15:20:58 -0400 Subject: [PATCH 12/55] fix(init): tighten SwiftUI root proof --- .../src/commands/init/ios/swift-app-root.ts | 34 ++++++++++- .../src/commands/init/ios/swift.test.ts | 60 +++++++++++++++++++ .../cli-core/src/commands/init/ios/swift.ts | 1 + 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/swift-app-root.ts b/packages/cli-core/src/commands/init/ios/swift-app-root.ts index 7e059bc3d..ebf38603a 100644 --- a/packages/cli-core/src/commands/init/ios/swift-app-root.ts +++ b/packages/cli-core/src/commands/init/ios/swift-app-root.ts @@ -362,13 +362,41 @@ function clerkEnvironment( return { found, conflicting }; } +function regexEscape(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function onOpenURLClosureBody(body: string): string | undefined { + const trimmed = body.trim(); + const wrapper = /^(?:perform\s*:\s*)?\{/.exec(trimmed); + if (!wrapper) return trimmed; + const openingBrace = trimmed.indexOf("{", wrapper.index); + const closingBrace = matchingBrace(trimmed, openingBrace); + if (closingBrace == null || trimmed.slice(closingBrace + 1).trim() !== "") return undefined; + return trimmed.slice(openingBrace + 1, closingBrace); +} + +function closureURLParameter(body: string): string | undefined { + const captureList = /^\s*\[[^\]]*\]\s*/.exec(body)?.[0] ?? ""; + const header = body.slice(captureList.length); + const parenthesized = /^\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^)]*)?\)\s+in\b/.exec(header); + if (parenthesized?.[1]) return parenthesized[1]; + const named = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s+in\b/.exec(header); + return named?.[1]; +} + function hasClerkOpenURLHandler(source: string, root: SwiftUIRootExpression): boolean { - const clerkHandler = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; return root.modifierStarts.some((modifierStart) => { const modifier = modifierDetails(source, root, modifierStart); - return ( - modifier?.name === "onOpenURL" && modifier.body != null && clerkHandler.test(modifier.body) + if (modifier?.name !== "onOpenURL" || modifier.body == null) return false; + const closureBody = onOpenURLClosureBody(modifier.body); + if (!closureBody) return false; + const parameter = closureURLParameter(closureBody); + if (!parameter || parameter === "_") return false; + const clerkHandler = new RegExp( + `\\bClerk\\s*\\.\\s*shared\\s*\\.\\s*handle\\s*\\(\\s*${regexEscape(parameter)}\\s*\\)`, ); + return clerkHandler.test(closureBody); }); } 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 65e68bca1..9246a84f4 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -799,6 +799,66 @@ Clerk.configure(publishableKey: key)`, expect(inspection.status).toBe("complete"); }); + test("does not prove a root when selected-target source evidence is incomplete", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-incomplete-")); + temporaryDirectories.push(root); + const appPath = join(root, "App.swift"); + await Bun.write( + appPath, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } + } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: appPath, relativePath: "App.swift" }, + { absolutePath: join(root, "Missing.swift"), relativePath: "Missing.swift" }, + ]); + + expect(inspection.evidenceComplete).toBe(false); + expect(inspection.appRootEvidence).toEqual([]); + expect(inspection.rootEnvironmentInjections).toEqual([]); + expect(inspection.rootOpenURLHandlers).toEqual([]); + }); + + test("proves only an incoming URL forwarded directly to Clerk.shared", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-callback-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + const inspect = async (handler: string) => { + await Bun.write( + path, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { ContentView().onOpenURL { ${handler} } } + } + }`, + ); + return inspectSwiftSources([{ absolutePath: path, relativePath: "App.swift" }]); + }; + + expect( + (await inspect("url in Task { try await Clerk.shared.handle(url) }")).rootOpenURLHandlers, + ).toEqual([{ path: "App.swift" }]); + expect( + (await inspect("_ in Task { try await Clerk.shared.handle(fallbackURL) }")) + .rootOpenURLHandlers, + ).toEqual([]); + expect( + (await inspect("url in Task { try await clerk.handle(url) }")).rootOpenURLHandlers, + ).toEqual([]); + }); + test("recognizes the Auth email-link convenience API as a custom magic-link flow", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-magic-link-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index e35cc6001..68a535d63 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -843,6 +843,7 @@ export async function inspectSwiftSources( } const hasUniqueProvenAppRoot = + evidenceComplete && entryPoints.length === 1 && appRootEvidence.length === 1 && appRootEvidence[0]?.path === entryPoints[0]?.path; From 640d3dedcb03a2ef8d238a0f55877da896287cab Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 15:26:18 -0400 Subject: [PATCH 13/55] fix(init): reject shadowed callback URLs --- .../src/commands/init/ios/plan.test.ts | 5 ++- .../cli-core/src/commands/init/ios/plan.ts | 4 +-- .../src/commands/init/ios/swift-app-root.ts | 31 +++++++++++----- .../src/commands/init/ios/swift.test.ts | 35 +++++++++++++++++++ .../cli-core/src/commands/init/ios/types.ts | 2 +- 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 67f24f5d3..85640329e 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -262,9 +262,12 @@ struct MyApp: App { const provenPlan = buildIOSSetupPlan(await inspectIOSProject(root)); expect(provenPlan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ title: "Wire custom email-link callbacks", - status: "satisfied", + status: "review", automatable: false, }); + expect( + provenPlan.steps.find((step) => step.id === "wire-auth-callbacks")?.description, + ).toContain("Confirm that custom email-link callbacks reach Clerk at runtime"); await Bun.write( appPath, diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index e0791a344..8e35d45b2 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -421,9 +421,9 @@ export function buildIOSSetupPlan( step( "wire-auth-callbacks", "Wire custom email-link callbacks", - rootHandlesMagicLinks ? "satisfied" : "review", + "review", rootHandlesMagicLinks - ? "The proven shipping WindowGroup root forwards custom email-link callbacks to Clerk." + ? "The proven shipping WindowGroup root contains the documented Clerk callback shape. Confirm that custom email-link callbacks reach Clerk at runtime." : hasUnprovenHandler ? "A Clerk onOpenURL handler exists in target source, but it is not proven on the shipping WindowGroup root. Confirm that custom email-link callbacks reach Clerk." : provenAppRoot diff --git a/packages/cli-core/src/commands/init/ios/swift-app-root.ts b/packages/cli-core/src/commands/init/ios/swift-app-root.ts index ebf38603a..30e3ce4bc 100644 --- a/packages/cli-core/src/commands/init/ios/swift-app-root.ts +++ b/packages/cli-core/src/commands/init/ios/swift-app-root.ts @@ -376,13 +376,28 @@ function onOpenURLClosureBody(body: string): string | undefined { return trimmed.slice(openingBrace + 1, closingBrace); } -function closureURLParameter(body: string): string | undefined { +function closureURLBinding(body: string): { parameter: string; bodyStart: number } | undefined { const captureList = /^\s*\[[^\]]*\]\s*/.exec(body)?.[0] ?? ""; const header = body.slice(captureList.length); const parenthesized = /^\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^)]*)?\)\s+in\b/.exec(header); - if (parenthesized?.[1]) return parenthesized[1]; + if (parenthesized?.[1]) { + return { + parameter: parenthesized[1], + bodyStart: captureList.length + parenthesized[0].length, + }; + } const named = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s+in\b/.exec(header); - return named?.[1]; + return named?.[1] + ? { parameter: named[1], bodyStart: captureList.length + named[0].length } + : undefined; +} + +function isExactClerkOpenURLForwarder(body: string, parameter: string): boolean { + const escaped = regexEscape(parameter); + const forwarder = new RegExp( + `^\\s*Task\\s*\\{\\s*(?:_\\s*=\\s*)?try[!?]?\\s+await\\s+Clerk\\s*\\.\\s*shared\\s*\\.\\s*handle\\s*\\(\\s*${escaped}\\s*\\)\\s*;?\\s*\\}\\s*$`, + ); + return forwarder.test(body); } function hasClerkOpenURLHandler(source: string, root: SwiftUIRootExpression): boolean { @@ -391,12 +406,10 @@ function hasClerkOpenURLHandler(source: string, root: SwiftUIRootExpression): bo if (modifier?.name !== "onOpenURL" || modifier.body == null) return false; const closureBody = onOpenURLClosureBody(modifier.body); if (!closureBody) return false; - const parameter = closureURLParameter(closureBody); - if (!parameter || parameter === "_") return false; - const clerkHandler = new RegExp( - `\\bClerk\\s*\\.\\s*shared\\s*\\.\\s*handle\\s*\\(\\s*${regexEscape(parameter)}\\s*\\)`, - ); - return clerkHandler.test(closureBody); + const binding = closureURLBinding(closureBody); + if (!binding || binding.parameter === "_") return false; + const handlerBody = closureBody.slice(binding.bodyStart); + return isExactClerkOpenURLForwarder(handlerBody, binding.parameter); }); } 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 9246a84f4..1e76909c9 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -857,6 +857,41 @@ Clerk.configure(publishableKey: key)`, expect( (await inspect("url in Task { try await clerk.handle(url) }")).rootOpenURLHandlers, ).toEqual([]); + expect( + ( + await inspect( + "url in do { let url = fallbackURL; Task { try await Clerk.shared.handle(url) } }", + ) + ).rootOpenURLHandlers, + ).toEqual([]); + expect( + ( + await inspect( + "url in values.forEach { url in Task { try await Clerk.shared.handle(url) } }", + ) + ).rootOpenURLHandlers, + ).toEqual([]); + expect( + ( + await inspect( + "url in if case let .some(url) = fallbackURL { Task { try await Clerk.shared.handle(url) } }", + ) + ).rootOpenURLHandlers, + ).toEqual([]); + expect( + ( + await inspect( + "url in do { let ((first, second), url) = fallback; Task { try await Clerk.shared.handle(url) } }", + ) + ).rootOpenURLHandlers, + ).toEqual([]); + expect( + ( + await inspect( + "url in struct Local { init(url: URL) { Task { try await Clerk.shared.handle(url) } } }; _ = Local.self", + ) + ).rootOpenURLHandlers, + ).toEqual([]); }); test("recognizes the Auth email-link convenience API as a custom magic-link flow", async () => { diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index d93fde894..b7c033172 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -131,7 +131,7 @@ export interface IOSSwiftInspection { magicLinkAuthReferences: IOSSourceEvidence[]; /** Broad lexical evidence retained for diagnostics and conflict detection only. */ openURLHandlers: IOSSourceEvidence[]; - /** Clerk URL handler directly attached to the proven shipping WindowGroup root. */ + /** Documented-shape Clerk URL handler candidate on the proven shipping WindowGroup root. */ rootOpenURLHandlers: IOSSourceEvidence[]; status: "complete" | "partial" | "absent" | "ambiguous"; } From d5f1cea2b5bcd37e3a15d4b5e4dbdfd8a27e8122 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 16:44:47 -0400 Subject: [PATCH 14/55] fix(init): validate SDK graph during dry run --- packages/cli-core/src/commands/init/index.ts | 19 ++- .../cli-core/src/commands/init/ios/apply.ts | 54 ++++---- .../src/commands/init/ios/dry-run.test.ts | 119 ++++++++++++++++++ 3 files changed, 165 insertions(+), 27 deletions(-) diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 198c11ac3..0eda7a22d 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -73,12 +73,13 @@ import { planIOSRuntimeKey } from "./ios/runtime-key.ts"; import { planIOSAssociatedDomain } from "./ios/associated-domain.ts"; import { planIOSAppleEntitlement } from "./ios/apple-entitlement.ts"; import { planIOSPrebuiltAuth } from "./ios/prebuilt-auth.ts"; +import { planIOSSDKInstall } from "./ios/install-sdk.ts"; import { resolveIOSDevelopmentPublicKey } from "./ios/development-key.ts"; import { createIOSDryRunOutput, formatIOSSetupPlan } from "./ios/output.ts"; import { applyIOSLocalSetup, applyIOSPlannedLocalSetup, - planIOSPrebuiltAuthSDKCompatibility, + normalizeIOSSDKInstallPlanForSetup, planIOSPrebuiltAuthRuntimeBlockers, type IOSLocalSetupResult, } from "./ios/apply.ts"; @@ -325,14 +326,24 @@ export async function init(options: InitOptions = {}) { allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", }) : undefined; - const sdkInstallPlan = - dryRunSelection.state === "selected" && selectedTarget != null && prebuiltAuthActive - ? await planIOSPrebuiltAuthSDKCompatibility({ + const strictSDKInstallPlan = + dryRunSelection.state === "selected" && selectedTarget != null + ? await planIOSSDKInstall({ root: ctx.cwd, projectPath: dryRunSelection.projectPath, targetId: dryRunSelection.targetId, + includeClerkKitUI: productDecision === "prebuilt" || prebuiltAuthActive, + requirePrebuiltAuthCompatibility: prebuiltAuthActive, }) : undefined; + const sdkInstallPlan = + strictSDKInstallPlan && selectedTarget + ? normalizeIOSSDKInstallPlanForSetup({ + installPlan: strictSDKInstallPlan, + selectedTarget, + prebuiltAuthActive, + }).sdkInstallPlan + : undefined; const plan = buildIOSSetupPlan(inspection, { sdkInstallPlan, runtimeKeyPlan: runtimeKeyPlan && { diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index c93b56d4e..d1a810082 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -69,6 +69,7 @@ import { type IOSPrebuiltAuthPlan, type PreparedIOSPrebuiltAuthMutation, } from "./prebuilt-auth.ts"; +import type { IOSAppTarget } from "./types.ts"; function iosSetupError(message: string, code: ErrorCode = ERROR_CODE.IOS_SETUP_BLOCKED): CliError { return new CliError(message, { code }); @@ -86,17 +87,31 @@ export interface ApplyIOSLocalSetupOptions { prebuiltAuthUI?: boolean; } -/** Read-only SDK compatibility planner shared by the AuthView dry-run path. */ -export async function planIOSPrebuiltAuthSDKCompatibility(options: { - root: string; - projectPath: string; - targetId: string; -}): Promise { - return planIOSSDKInstall({ - ...options, - includeClerkKitUI: true, - requirePrebuiltAuthCompatibility: true, - }); +/** Keep legacy, fully linked product graphs review-only while preserving every other SDK blocker. */ +export function normalizeIOSSDKInstallPlanForSetup(options: { + installPlan: IOSSDKInstallPlan; + selectedTarget: IOSAppTarget; + prebuiltAuthActive: boolean; +}): { + sdkInstallPlan?: IOSSDKInstallPlan; + reviewOnlyUnattributedInstall: boolean; +} { + const { installPlan, selectedTarget, prebuiltAuthActive } = options; + const reviewOnlyUnattributedInstall = + !prebuiltAuthActive && + installPlan.requirePrebuiltAuthCompatibility !== true && + installPlan.status === "blocked" && + installPlan.blockers.length > 0 && + installPlan.blockers.every((blocker) => blocker.code === "unattributed-product") && + installPlan.products.every((product) => + product === "ClerkKit" + ? selectedTarget.packages.clerkKit === "linked" + : selectedTarget.packages.clerkKitUI === "linked", + ); + return { + sdkInstallPlan: reviewOnlyUnattributedInstall ? undefined : installPlan, + reviewOnlyUnattributedInstall, + }; } export interface IOSLocalSetupResult { @@ -513,18 +528,11 @@ export async function applyIOSLocalSetup( `Native Sign in with Apple could not be configured safely. No local files were changed:\n${blockerList(appleEntitlementPlan.blockers)}`, ); } - const reviewOnlyUnattributedInstall = - !prebuiltAuthActive && - installPlan.requirePrebuiltAuthCompatibility !== true && - installPlan.status === "blocked" && - installPlan.blockers.length > 0 && - installPlan.blockers.every((blocker) => blocker.code === "unattributed-product") && - installPlan.products.every((product) => - product === "ClerkKit" - ? selectedTarget.packages.clerkKit === "linked" - : selectedTarget.packages.clerkKitUI === "linked", - ); - const sdkInstallPlan = reviewOnlyUnattributedInstall ? undefined : installPlan; + const { sdkInstallPlan, reviewOnlyUnattributedInstall } = normalizeIOSSDKInstallPlanForSetup({ + installPlan, + selectedTarget, + prebuiltAuthActive, + }); if (plannedRuntimeKeyVerification?.status === "blocked") { throw iosSetupError( diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts index 09ad8e328..ac782ad01 100644 --- a/packages/cli-core/src/commands/init/ios/dry-run.test.ts +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -238,6 +238,125 @@ struct MyApp: App { expect(await treeDigest(root)).toEqual(before); }); + test("non-prebuilt dry-run blocks duplicate Clerk package references without writing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-sdk-duplicate-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + clerkSDK: "core-only", + includeKey: false, + }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + Text("Custom Clerk flow") + } + } +} +`, + ); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + const packageReferences = `packageReferences = ( ${IOS_FIXTURE_IDS.clerkPackage}, );`; + expect(project).toContain(packageReferences); + await Bun.write( + projectPath, + project.replace( + packageReferences, + `packageReferences = ( ${IOS_FIXTURE_IDS.clerkPackage}, ${IOS_FIXTURE_IDS.clerkPackage}, );`, + ), + ); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const sdk = output.plan.steps.find((step: { id: string }) => step.id === "install-clerk-sdk"); + expect(output.status).toBe("blocked"); + expect(output.inspection.appTargets[0]?.packages).toMatchObject({ + clerkKit: "linked", + clerkKitUI: "absent", + }); + expect(sdk).toMatchObject({ status: "blocked", automatable: false }); + expect(sdk.description).toContain("duplicate object ID"); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + + test("non-prebuilt dry-run reviews fully linked unattributed products without writing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-sdk-unattributed-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + clerkSDK: "core-only", + includeKey: false, + }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + Text("Custom Clerk flow") + } + } +} +`, + ); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const project = await Bun.file(projectPath).text(); + const attributedProduct = `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKit;`; + const packageReferences = `packageReferences = ( ${IOS_FIXTURE_IDS.clerkPackage}, );`; + const packageObject = ` ${IOS_FIXTURE_IDS.clerkPackage} = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/clerk/clerk-ios.git"; requirement = { kind = upToNextMajorVersion; minimumVersion = 1.0.0; }; };\n`; + expect(project).toContain(attributedProduct); + expect(project).toContain(packageReferences); + expect(project).toContain(packageObject); + await Bun.write( + projectPath, + project + .replace(attributedProduct, "productName = ClerkKit;") + .replace(packageReferences, "packageReferences = ( );") + .replace(packageObject, ""), + ); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const sdk = output.plan.steps.find((step: { id: string }) => step.id === "install-clerk-sdk"); + expect(output.status).not.toBe("blocked"); + expect(output.inspection.appTargets[0]?.packages).toMatchObject({ + package: "unattributed", + clerkKit: "linked", + clerkKitUI: "absent", + }); + expect(sdk).toMatchObject({ status: "review", automatable: false }); + expect(sdk.description).toContain("could not be verified as clerk-ios"); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + test("explicit AuthView dry-run blocks a ProcessInfo runtime without root environment injection", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-auth-process-info-")); temporaryDirectories.push(root); From a90cb2a57a50e7d6533c64a224bb2251ab08f8cf Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 17:02:52 -0400 Subject: [PATCH 15/55] fix(init): preserve prepared mutation boundaries --- .../init/ios/apple-entitlement.test.ts | 12 +++++ .../commands/init/ios/apple-entitlement.ts | 53 ++++++++++++------- .../cli-core/src/commands/init/ios/apply.ts | 3 ++ .../commands/init/ios/prebuilt-auth.test.ts | 6 +++ .../src/commands/init/ios/prebuilt-auth.ts | 19 ++++++- 5 files changed, 72 insertions(+), 21 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts index 7f9746bff..dfc88c4f3 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts @@ -100,6 +100,9 @@ describe("iOS Sign in with Apple entitlement setup", () => { files: [{ path: "MyApp/MyApp.entitlements", operation: "modify" }], }); expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected prepared Apple mutation"); + expect(prepared.mutations[0]?.boundary.rootPath).toBe(root); + expect(prepared.mutations[0]?.boundary.realParentPath.endsWith("/MyApp")).toBe(true); expect(JSON.stringify({ plan, prepared })).not.toContain("candidateBytes"); expect(JSON.stringify({ plan, prepared })).not.toContain(" { ...planOptions(root), allowMissingEntitlementsCreation: true, }); + const prepared = await prepareIOSAppleEntitlementMutation(plan); const result = await applyIOSAppleEntitlement(plan); expect(plan).toMatchObject({ @@ -274,6 +278,11 @@ describe("iOS Sign in with Apple entitlement setup", () => { files: [{ path: "MyApp/MyApp.entitlements", operation: "create" }], missingEntitlementsSettings: { status: "ready" }, }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected prepared Apple create mutation"); + const createMutation = prepared.mutations.find((mutation) => "kind" in mutation); + expect(createMutation?.boundary.rootPath).toBe(root); + expect(createMutation?.boundary.realParentPath.endsWith("/MyApp")).toBe(true); expect(result.status).toBe("applied"); expect(await readFile(path, "utf8")).toContain(appleBlock()); expect((await lstat(path)).mode & 0o7777).toBe(0o644); @@ -316,6 +325,9 @@ struct MyApp: App { expect(prepared.consumedBaseMutationPaths).toEqual( associated.mutations.map((mutation) => mutation.path).sort(), ); + const associatedCreate = associated.mutations.find((mutation) => "kind" in mutation); + const appleCreate = prepared.mutations.find((mutation) => "kind" in mutation); + expect(appleCreate?.boundary).toEqual(associatedCreate?.boundary); expect(JSON.stringify(prepared)).not.toContain("candidateBytes"); const result = await applyIOSFileTransaction(prepared.mutations, [ diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts index ae10fd419..0b43c22ab 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -1,5 +1,6 @@ import { lstat, readFile } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { planIOSAssociatedDomain, type IOSAssociatedDomainBlockerCode, @@ -8,6 +9,7 @@ import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { applyIOSFileTransaction, hashIOSFileBytes, + prepareIOSFileMutationBoundary, type IOSCreateFileMutation, type IOSExistingFileMutation, type IOSFileMutation, @@ -652,14 +654,32 @@ export async function prepareIOSAppleEntitlementMutation( ); } + const expectedParentIdentity = + plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; + const synchronizedRootPath = plan.missingEntitlementsSettings.synchronizedRootPath; + const boundary = await prepareIOSFileMutationBoundary(plan.root, entitlementsPath); + if ( + !expectedParentIdentity || + !synchronizedRootPath || + dirname(entitlementsPath) !== resolve(plan.root, synchronizedRootPath) + ) { + return blockPrepared( + plan, + "invalid-plan", + "The entitlements destination no longer matches its synchronized target root.", + ); + } + if ( + !boundary || + boundary.parentIdentity.device !== expectedParentIdentity.device || + boundary.parentIdentity.inode !== expectedParentIdentity.inode + ) { + return { status: "stale", plan }; + } + let createMutation: IOSCreateFileMutation; if (baseEntitlements) { - const expectedIdentity = plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; - if ( - !expectedIdentity || - baseEntitlements.expectedParentIdentity.device !== expectedIdentity.device || - baseEntitlements.expectedParentIdentity.inode !== expectedIdentity.inode - ) { + if (!isDeepStrictEqual(baseEntitlements.boundary, boundary)) { return { status: "stale", plan }; } const inspected = inspectEntitlementsBytes( @@ -681,28 +701,16 @@ export async function prepareIOSAppleEntitlementMutation( } createMutation = { ...baseEntitlements, + boundary: baseEntitlements.boundary, candidateBytes, candidateHash: hashIOSFileBytes(candidateBytes), }; } else { - const expectedParentIdentity = - plan.missingEntitlementsSettings.expectedSynchronizedRootIdentity; - if ( - !expectedParentIdentity || - dirname(entitlementsPath) !== - resolve(plan.root, plan.missingEntitlementsSettings.synchronizedRootPath ?? "") - ) { - return blockPrepared( - plan, - "invalid-plan", - "The entitlements destination no longer matches its synchronized target root.", - ); - } const candidateBytes = newEntitlementsBytes(); createMutation = { kind: "create", path: entitlementsPath, - expectedParentIdentity: { ...expectedParentIdentity }, + boundary, candidateBytes, candidateHash: hashIOSFileBytes(candidateBytes), mode: 0o644, @@ -732,6 +740,10 @@ export async function prepareIOSAppleEntitlementMutation( } const base = baseByPath.get(absolutePath); if (base && isCreateMutation(base)) return { status: "stale", plan }; + const boundary = await prepareIOSFileMutationBoundary(plan.root, absolutePath); + if (!boundary || (base && !isDeepStrictEqual(base.boundary, boundary))) { + return { status: "stale", plan }; + } if ( base && (base.originalHash !== file.expectedHash || @@ -763,6 +775,7 @@ export async function prepareIOSAppleEntitlementMutation( } mutations.push({ path: absolutePath, + boundary: base?.boundary ?? boundary, originalBytes: base?.originalBytes ?? current.document.bytes, originalHash: base?.originalHash ?? current.document.hash, candidateBytes, diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index d1a810082..f560325ba 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -885,6 +885,7 @@ function directFileMutation( ): IOSExistingFileMutation { return { path: prepared.mutation.absolutePath, + boundary: prepared.mutation.boundary, originalBytes: prepared.mutation.originalBytes, originalHash: prepared.mutation.expectedHash, candidateBytes: prepared.mutation.candidateBytes, @@ -898,6 +899,7 @@ function prebuiltAuthFileMutation( ): IOSExistingFileMutation { return { path: prepared.mutation.absolutePath, + boundary: prepared.mutation.boundary, originalBytes: prepared.mutation.originalBytes, originalHash: prepared.mutation.expectedHash, candidateBytes: prepared.mutation.candidateBytes, @@ -909,6 +911,7 @@ function prebuiltAuthFileMutation( function reverseFileMutation(mutation: IOSExistingFileMutation): IOSExistingFileMutation { return { path: mutation.path, + boundary: mutation.boundary, originalBytes: mutation.candidateBytes, originalHash: mutation.candidateHash, candidateBytes: mutation.originalBytes, diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts index 0bb4da674..7bfac7f3b 100644 --- a/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts @@ -151,6 +151,7 @@ describe("prebuilt AuthView source setup", () => { test("plans only an exact target-owned untouched SwiftUI placeholder", async () => { const root = await createFixture(); const plan = await planIOSPrebuiltAuth(options(root)); + const prepared = await prepareIOSPrebuiltAuthMutation(plan); expect(plan).toMatchObject({ schemaVersion: 1, @@ -162,6 +163,11 @@ describe("prebuilt AuthView source setup", () => { }); expect(JSON.stringify(plan)).not.toContain("AuthView()"); expect(JSON.stringify(plan)).not.toContain("Hello, world!"); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected prepared AuthView mutation"); + expect(prepared.mutation.boundary.rootPath).toBe(root); + expect(prepared.mutation.boundary.realParentPath.endsWith("/MyApp")).toBe(true); + expect(JSON.stringify(prepared)).not.toContain("boundary"); }); test.each([ diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts index 453b22119..febb8bf0b 100644 --- a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts @@ -4,7 +4,9 @@ import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { applyIOSFileTransaction, hashIOSFileBytes, + prepareIOSFileMutationBoundary, type IOSExistingFileMutation, + type IOSFileMutationBoundary, } from "./file-transaction.ts"; import { hasExactIOSSwiftUIAppContentRoot } from "./direct-config.ts"; import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; @@ -63,6 +65,7 @@ export interface IOSPrebuiltAuthPlan { /** @internal Candidate bytes are hidden from ordinary serialization. */ export interface IOSPrebuiltAuthFileMutation { absolutePath: string; + boundary: IOSFileMutationBoundary; expectedHash: string; candidateHash: string; mode: number; @@ -667,6 +670,7 @@ export async function planIOSPrebuiltAuth( function mutationWithHiddenBytes( snapshot: SourceSnapshot, candidateBytes: Uint8Array, + boundary: IOSFileMutationBoundary, ): IOSPrebuiltAuthFileMutation { const mutation = { absolutePath: snapshot.absolutePath, @@ -675,6 +679,7 @@ function mutationWithHiddenBytes( mode: snapshot.mode, } as IOSPrebuiltAuthFileMutation; Object.defineProperties(mutation, { + boundary: { value: boundary, enumerable: false }, originalBytes: { value: snapshot.bytes, enumerable: false }, candidateBytes: { value: candidateBytes, enumerable: false }, }); @@ -745,7 +750,18 @@ export async function prepareIOSPrebuiltAuthMutation( const newline = current.sourceSnapshot.newline; const generated = `${current.sourceHeader ?? ""}${GENERATED_CONTENT_VIEW.replace(/\n/g, newline)}`; const candidateBytes = new TextEncoder().encode(generated); - const mutation = mutationWithHiddenBytes(current.sourceSnapshot, candidateBytes); + const boundary = await prepareIOSFileMutationBoundary( + plan.root, + current.sourceSnapshot.absolutePath, + ); + if (!boundary) { + return { + status: "stale", + plan, + message: "The selected Swift source moved outside its prepared project boundary.", + }; + } + const mutation = mutationWithHiddenBytes(current.sourceSnapshot, candidateBytes, boundary); const candidateHash = mutation.candidateHash; return readyPrepared(plan, mutation, async () => { const verified = await preparePlan({ @@ -771,6 +787,7 @@ export async function validatePreparedIOSPrebuiltAuth( function asExistingMutation(mutation: IOSPrebuiltAuthFileMutation): IOSExistingFileMutation { return { path: mutation.absolutePath, + boundary: mutation.boundary, originalBytes: mutation.originalBytes, originalHash: mutation.expectedHash, candidateBytes: mutation.candidateBytes, From 623d02955e7a014ae01d7a2c1c7f2c0c307fac2e Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 18:52:15 -0400 Subject: [PATCH 16/55] fix(init): account for every configure call --- .../src/commands/init/ios/plan.test.ts | 51 ++++++++++++++++++- .../cli-core/src/commands/init/ios/plan.ts | 33 ++++++------ 2 files changed, 66 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 85640329e..e60dc8657 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -6,7 +6,7 @@ import { planIOSDirectConfig } from "./direct-config.ts"; import { planIOSAssociatedDomain } from "./associated-domain.ts"; import { inspectIOSProject } from "./inspect.ts"; import { formatIOSSetupPlan } from "./output.ts"; -import { buildIOSSetupPlan } from "./plan.ts"; +import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "./plan.ts"; import { planIOSRuntimeKey } from "./runtime-key.ts"; import { createIOSFixture } from "./test-helpers.ts"; @@ -76,6 +76,55 @@ describe("buildIOSSetupPlan", () => { ); }); + test("reviews configuration when an additional configure call is not proven", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + inspection.appTargets[0]!.swift.configureCalls.push({ + path: "MyApp/SecondarySetup.swift", + publishableKeyWiring: "unknown", + startupBinding: "unproven", + }); + + const configureStep = buildIOSSetupPlan(inspection).steps.find( + (step) => step.id === "configure-publishable-key", + ); + + expect(configureStep).toMatchObject({ status: "review", automatable: false }); + expect(configureStep?.description).toContain("More than one Clerk.configure"); + }); + + test("keeps an empty LocalSecrets handoff despite a stale scheme candidate", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + '', + ); + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]!; + const schemePath = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; + inspection.localPublishableKey = { + found: true, + source: schemePath, + frontendApiHost: "stale.clerk.example", + instanceType: "development", + conflict: false, + candidateSources: [schemePath], + invalidSources: [], + }; + + const configureStep = buildIOSSetupPlan(inspection).steps.find( + (step) => step.id === "configure-publishable-key", + ); + + expect(hasIOSRuntimeKeyHandoffShape(inspection, target)).toBe(true); + expect(configureStep).toMatchObject({ status: "required", automatable: false }); + expect(configureStep?.description).toContain("LocalSecrets.plist"); + }); + test("satisfies configuration and derives the domain from a redacted inline literal", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index 8e35d45b2..f060713b0 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -96,9 +96,6 @@ export function hasIOSRuntimeKeyHandoffShape( inspection: IOSProjectInspectionResult, target: IOSAppTarget, ): boolean { - const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => - source.endsWith(".xcscheme"), - ); return ( inspection.generatedProject === null && target.swift.evidenceComplete && @@ -109,8 +106,7 @@ export function hasIOSRuntimeKeyHandoffShape( target.swift.configureCalls[0]?.startupBinding === "app-init" && target.swift.configureCalls[0]?.path === target.swift.entryPoints[0]?.path && target.swift.localSecretsRuntimeBindings.length === 1 && - target.runtimeKeySinks.length === 1 && - !hasEnabledSchemeKey + target.runtimeKeySinks.length === 1 ); } @@ -259,7 +255,8 @@ export function buildIOSSetupPlan( usablePublishableKey && runtimeKeySource != null && runtimeKeySource !== "available-only" && - target.swift.configureCalls.some( + target.swift.configureCalls.length === 1 && + target.swift.configureCalls.every( (call) => call.startupBinding === "app-init" && (runtimeKeySource === "inline-literal" @@ -329,17 +326,19 @@ export function buildIOSSetupPlan( : directConfigBlocked ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's @main App initializer and root Scene manually."}` : configured - ? sourceEntryPointIsAmbiguous - ? "A Clerk.configure(...) call is present, but multiple @main entry points make startup ownership ambiguous. Confirm which entry point ships." - : configureCallConnectedToRuntime - ? runtimeKeySource === "inline-literal" - ? "Clerk is configured directly in the selected target's @main initializer with a valid publishable key. The value is intentionally redacted from this plan." - : "A Clerk.configure(...) call is connected to a recognized selected-target runtime key loader. The key expression and value are intentionally redacted from this plan." - : usablePublishableKey - ? runtimeKeySource === "available-only" - ? "A usable publishable key is available to copy, but the app is not proven to load it at runtime. Configure Clerk directly in the selected target's @main App initializer, or repair the app's existing runtime loader if it intentionally uses one." - : "A selected-target runtime publishable key is present, but the Clerk.configure(...) expression could not be connected to its loader. Confirm the wiring manually; the expression and value are intentionally redacted." - : "A Clerk.configure(...) call is present, but the inspector could not validate a usable selected-target runtime key source. Confirm the runtime value manually; the expression is intentionally redacted." + ? target.swift.configureCalls.length > 1 + ? "More than one Clerk.configure(...) call is present. Confirm that every call uses the intended selected-target runtime key and runs during app startup." + : sourceEntryPointIsAmbiguous + ? "A Clerk.configure(...) call is present, but multiple @main entry points make startup ownership ambiguous. Confirm which entry point ships." + : configureCallConnectedToRuntime + ? runtimeKeySource === "inline-literal" + ? "Clerk is configured directly in the selected target's @main initializer with a valid publishable key. The value is intentionally redacted from this plan." + : "A Clerk.configure(...) call is connected to a recognized selected-target runtime key loader. The key expression and value are intentionally redacted from this plan." + : usablePublishableKey + ? runtimeKeySource === "available-only" + ? "A usable publishable key is available to copy, but the app is not proven to load it at runtime. Configure Clerk directly in the selected target's @main App initializer, or repair the app's existing runtime loader if it intentionally uses one." + : "A selected-target runtime publishable key is present, but the Clerk.configure(...) expression could not be connected to its loader. Confirm the wiring manually; the expression and value are intentionally redacted." + : "A Clerk.configure(...) call is present, but the inspector could not validate a usable selected-target runtime key source. Confirm the runtime value manually; the expression is intentionally redacted." : !target.swift.evidenceComplete ? "No Clerk.configure(...) call was found in the safely inspected source subset. Complete source membership inspection or confirm startup setup manually." : inspection.localPublishableKey.conflict From 0f592927423dbf59762c6b3c40de31707af2164c Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 18:55:26 -0400 Subject: [PATCH 17/55] test(init): cover wired runtime key sources --- .../init/ios/native-readiness.test.ts | 11 ++++-- .../src/commands/init/ios/plan.test.ts | 35 ++++++++++++++----- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts index 36f6dcca0..3f7f86f73 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -61,10 +61,16 @@ describe("buildIOSNativeReadinessAudit", () => { }, }, associatedDomain: { - status: "review", - expectedDomain: "webcredentials:clerk.example.test", + status: "blocked", files: ["MyApp/MyApp.entitlements"], automatable: false, + blockers: [ + { + code: "expected-domain-unavailable", + message: + "A proven local publishable key is required to derive the webcredentials domain.", + }, + ], }, remote: { status: "not-inspected", @@ -72,6 +78,7 @@ describe("buildIOSNativeReadinessAudit", () => { requirement: IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT, }, }); + expect(audit.associatedDomain.expectedDomain).toBeUndefined(); expect(audit.remote.requirement).toEqual({ applicationId: "linked-application-id", instanceId: "linked-development-instance-id", diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index e60dc8657..4302467f8 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -45,10 +45,12 @@ describe("buildIOSSetupPlan", () => { expect(plan.steps.filter((step) => step.automatable)).toEqual([]); const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); expect(configureStep?.status).toBe("review"); - expect(configureStep?.description).toContain("available to copy"); + expect(configureStep?.description).toContain( + "could not validate a usable selected-target runtime key source", + ); const domainStep = plan.steps.find((step) => step.id === "add-associated-domain"); - expect(domainStep?.status).toBe("review"); - expect(domainStep?.description).toContain("not proven to be the selected target's runtime key"); + expect(domainStep?.status).toBe("blocked"); + expect(domainStep?.description).toContain("valid local publishable key is needed"); expect(plan.steps.find((step) => step.id === "register-native-application")?.status).toBe( "review", ); @@ -612,10 +614,16 @@ struct MyApp: App { projectPath: inspection.selection.projectPath, targetId: inspection.selection.targetId, }); - inspection.localPublishableKey.source = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; - inspection.localPublishableKey.candidateSources = [ - "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - ]; + const schemePath = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; + inspection.localPublishableKey = { + found: true, + source: schemePath, + frontendApiHost: "clerk.example.test", + instanceType: "development", + conflict: false, + candidateSources: [schemePath], + invalidSources: [], + }; inspection.appTargets[0]!.swift.configureCalls = [ { path: "MyApp/MyAppApp.swift", @@ -643,7 +651,15 @@ struct MyApp: App { ".clerk/.tmp/keyless.json", "CLERK_PUBLISHABLE_KEY environment variable", ]) { - inspection.localPublishableKey.source = source; + inspection.localPublishableKey = { + found: true, + source, + frontendApiHost: "clerk.example.test", + instanceType: "development", + conflict: false, + candidateSources: [source], + invalidSources: [], + }; const step = buildIOSSetupPlan(inspection).steps.find( (candidate) => candidate.id === "configure-publishable-key", ); @@ -663,9 +679,10 @@ struct MyApp: App { expect(inspection.localPublishableKey).toMatchObject({ found: false, - source: ".env", + candidateSources: [".env"], invalidSources: [".env"], }); + expect(inspection.localPublishableKey.source).toBeUndefined(); expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( "review", ); From 97b44b70130d0db355c2b8c62dae0ae7c5cd94f1 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 20:13:07 -0400 Subject: [PATCH 18/55] fix(init): require versioned Apple config writes --- .../commands/init/ios/native-apple.test.ts | 119 +++++++++++------- .../src/commands/init/ios/native-apple.ts | 52 +++++--- 2 files changed, 106 insertions(+), 65 deletions(-) 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 0f4dff30b..98ef6c4b5 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 @@ -57,7 +57,7 @@ function connection( return { enabled, authenticatable, ...extras }; } -function config(value: AppleConnection, configVersion: string | undefined = CONFIG_VERSION) { +function config(value: AppleConnection, configVersion: string | null = CONFIG_VERSION) { return { ...(configVersion ? { config_version: configVersion } : {}), connection_oauth_apple: { ...value }, @@ -103,8 +103,7 @@ function statefulAPI( options: { initial?: AppleConnection; schema?: InstanceConfigSchema; - supportsIfMatch?: boolean; - version?: string | undefined; + version?: string | null; failFetch?: unknown; failDryRun?: unknown; failActual?: unknown; @@ -128,20 +127,19 @@ function statefulAPI( ...(options.initial ?? connection()), } as AppleConnection; let version: string | undefined = - options.version === undefined ? CONFIG_VERSION : options.version; + options.version === null ? undefined : (options.version ?? CONFIG_VERSION); let writes = 0; const calls: string[] = []; const patchCalls: PatchCall[] = []; const api: IOSNativeAppleAPI = { - supportsIfMatch: options.supportsIfMatch ?? false, async fetchInstanceConfig(applicationId, instanceId, keys) { expect(applicationId).toBe(APPLICATION_ID); expect(instanceId).toBe(INSTANCE_ID); expect(keys).toEqual(["connection_oauth_apple"]); calls.push("GET config"); if (options.failFetch) throw options.failFetch; - return config(current, version); + return config(current, version ?? null); }, async fetchInstanceConfigSchema(applicationId, instanceId, keys) { expect(applicationId).toBe(APPLICATION_ID); @@ -160,7 +158,7 @@ function statefulAPI( options: { ...patchOptions }, }); - if (patchOptions.ifMatch && patchOptions.ifMatch !== version) { + if (patchOptions.ifMatch !== version) { throw new Error("config version conflict"); } if (patchOptions.dryRun && options.failDryRun) throw options.failDryRun; @@ -269,6 +267,25 @@ describe("native Sign in with Apple remote setup", () => { expect(captured.err).toContain("already enabled"); }); + test("keeps a versionless already-satisfied connection read-only", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + version: null, + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(plan.status).toBe("satisfied"); + if (plan.status !== "satisfied") throw new Error("expected satisfied plan"); + await applyIOSNativeAppleConnection(plan, harness.api); + + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + expect(harness.calls.filter((call) => call === "GET config")).toHaveLength(2); + }); + test("rejects a satisfied plan when the connection changes after prepare", async () => { const harness = statefulAPI({ initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), @@ -311,10 +328,7 @@ describe("native Sign in with Apple remote setup", () => { key_id: KEY_ID, unrelated_provider_setting: "keep-me", }); - const harness = statefulAPI({ - initial, - supportsIfMatch: true, - }); + const harness = statefulAPI({ initial }); const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { api: harness.api, prompts: unexpectedPrompts(), @@ -352,38 +366,6 @@ describe("native Sign in with Apple remote setup", () => { }); }); - test("uses config-version rereads when an injected transport cannot send If-Match", async () => { - const harness = statefulAPI({ supportsIfMatch: false }); - const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { - api: harness.api, - prompts: unexpectedPrompts(), - }); - - expect(prepared.status).toBe("ready"); - expect(harness.patchCalls).toHaveLength(0); - if (prepared.status !== "ready") throw new Error("expected ready plan"); - - await applyIOSNativeAppleConnection(prepared, harness.api); - - expect(harness.actualWrites()).toBe(1); - expect(harness.patchCalls).toHaveLength(2); - for (const call of harness.patchCalls) expect(call.options.ifMatch).toBeUndefined(); - - const staleHarness = statefulAPI({ supportsIfMatch: false }); - const stalePrepared = await prepareIOSNativeAppleConnection(baseOptions(), { - api: staleHarness.api, - prompts: unexpectedPrompts(), - }); - if (stalePrepared.status !== "ready") throw new Error("expected ready plan"); - staleHarness.setVersion(NEXT_CONFIG_VERSION); - - await expect(applyIOSNativeAppleConnection(stalePrepared, staleHarness.api)).rejects.toThrow( - "changed after the approved preview", - ); - expect(staleHarness.patchCalls).toHaveLength(0); - expect(staleHarness.actualWrites()).toBe(0); - }); - test("requires the exact native Bundle ID even when Apple is already authenticatable", async () => { const harness = statefulAPI({ initial: connection(true, true) }); const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { @@ -547,7 +529,7 @@ describe("native Sign in with Apple remote setup", () => { }); test("fails before writing when the approved config version becomes stale", async () => { - const harness = statefulAPI({ supportsIfMatch: true }); + const harness = statefulAPI(); const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { api: harness.api, prompts: unexpectedPrompts(), @@ -563,6 +545,36 @@ describe("native Sign in with Apple remote setup", () => { expect(harness.actualWrites()).toBe(0); }); + test("blocks a remote Apple change when its configuration version is unavailable", async () => { + const harness = statefulAPI({ version: null }); + + await expect( + prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }), + ).rejects.toThrow("version required to protect a remote change"); + + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + + test("rejects a serialized writable plan which is missing its configuration version", async () => { + const harness = statefulAPI(); + const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + if (prepared.status !== "ready") throw new Error("expected ready plan"); + const incomplete = { ...prepared, configVersion: undefined }; + + await expect(applyIOSNativeAppleConnection(incomplete, harness.api)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_PLAN_INVALID, + }); + expect(harness.patchCalls).toHaveLength(0); + expect(harness.actualWrites()).toBe(0); + }); + test("requires a valid server dry-run projection before the actual write", async () => { const harness = statefulAPI({ malformedDryRun: true }); const prepared = await prepareIOSNativeAppleConnection(baseOptions(), { @@ -758,7 +770,7 @@ describe("native Sign in with Apple remote setup", () => { } }); - test("accepts a missing config version but blocks malformed version material", () => { + test("requires a config version for writes but allows a versionless no-op", () => { const withoutVersion = buildIOSNativeApplePlan({ applicationId: APPLICATION_ID, instanceId: INSTANCE_ID, @@ -767,8 +779,23 @@ describe("native Sign in with Apple remote setup", () => { config: { connection_oauth_apple: connection() }, schema: appleSchema(), }); - expect(withoutVersion.status).toBe("ready"); + expect(withoutVersion.status).toBe("blocked"); expect(withoutVersion.configVersion).toBeUndefined(); + expect(withoutVersion.blockers).toContainEqual( + expect.objectContaining({ code: "apple-config-version-unavailable" }), + ); + + const satisfiedWithoutVersion = buildIOSNativeApplePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + nativeApplicationReady: true, + config: { + connection_oauth_apple: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), + }, + schema: appleSchema(), + }); + expect(satisfiedWithoutVersion.status).toBe("satisfied"); const sensitiveVersion = `v1_${PRIVATE_KEY}`; const malformedVersion = buildIOSNativeApplePlan({ 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 50916cc8d..06d493576 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -43,6 +43,7 @@ export type IOSNativeAppleBlockerCode = | "bundle-identifier-unavailable" | "apple-config-unsupported" | "apple-config-invalid" + | "apple-config-version-unavailable" | "apple-authenticatable-conflict" | "apple-bundle-identifier-conflict"; @@ -87,17 +88,11 @@ const preservedAppleFieldFingerprints = new WeakMap< export interface IOSNativeApplePatchOptions { dryRun: boolean; - /** Forwarded only by clients which explicitly advertise support. */ - ifMatch?: string; + /** Required for every mutation attempt, including the server dry run. */ + ifMatch: string; } export interface IOSNativeAppleAPI { - /** - * PLAPI supports both server dry-run and If-Match. Test or alternate - * adapters may opt out of If-Match; config-version revalidation remains - * mandatory either way. - */ - supportsIfMatch?: boolean; fetchInstanceConfig( applicationId: string, instanceId: string, @@ -117,7 +112,6 @@ export interface IOSNativeAppleAPI { } const defaultAPI: IOSNativeAppleAPI = { - supportsIfMatch: true, fetchInstanceConfig, fetchInstanceConfigSchema, patchInstanceConfig: async (applicationId, instanceId, config, options) => @@ -343,6 +337,20 @@ export function buildIOSNativeApplePlan( ); } + const alreadySatisfied = + parsed.status === "valid" && + parsed.value.enabled && + parsed.value.authenticatable && + parsed.bundleIdentifier === bundleIdentifier; + if (blockers.length === 0 && configVersion.status === "missing" && !alreadySatisfied) { + blockers.push( + blocker( + "apple-config-version-unavailable", + "The Apple connection configuration did not include the version required to protect a remote change. Rerun clerk init before continuing.", + ), + ); + } + const current = parsed.status === "valid" ? parsed.value : undefined; const desired: AppleConnectionState = { enabled: true, authenticatable: true }; const bundleIdentifierConfiguration = @@ -433,14 +441,16 @@ function formatBlockers(plan: IOSNativeApplePlan): string { return plan.blockers.map((item) => ` • ${item.message}`).join("\n"); } -function patchOptions( - api: IOSNativeAppleAPI, - plan: IOSNativeApplePlan, - dryRun: boolean, -): IOSNativeApplePatchOptions { +function patchOptions(plan: IOSNativeApplePlan, dryRun: boolean): IOSNativeApplePatchOptions { + if (!plan.configVersion) { + throw iosAppleError( + "The approved native Apple connection plan is missing the configuration version required to protect a remote change.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } return { dryRun, - ...(api.supportsIfMatch && plan.configVersion ? { ifMatch: plan.configVersion } : {}), + ifMatch: plan.configVersion, }; } @@ -535,7 +545,7 @@ async function validateServerPatch( plan.applicationId, plan.instanceId, applePatch(plan.bundleIdentifier), - patchOptions(api, plan, dryRun), + patchOptions(plan, dryRun), ); validatePatchProjection( response, @@ -622,15 +632,19 @@ function planIdentityMatches(approved: IOSNativeApplePlan, current: IOSNativeApp } function planVersionMatches(approved: IOSNativeApplePlan, current: IOSNativeApplePlan): boolean { - if (!approved.configVersion) return true; - return current.configVersion === approved.configVersion; + return approved.configVersion != null && current.configVersion === approved.configVersion; } export async function applyIOSNativeAppleConnection( plan: IOSNativeApplePlan, api: IOSNativeAppleAPI = defaultAPI, ): Promise { - if (plan.status === "blocked" || !plan.current || !plan.bundleIdentifier) { + if ( + plan.status === "blocked" || + !plan.current || + !plan.bundleIdentifier || + (plan.status === "ready" && !plan.configVersion) + ) { throw iosAppleError( "The approved native Apple connection plan is incomplete. No remote Apple connection changes were made; rerun clerk init.", ERROR_CODE.IOS_SETUP_PLAN_INVALID, From 286716db37170bba97d70d283dda928585280992 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 20:41:43 -0400 Subject: [PATCH 19/55] fix(init): preserve registration retry keys --- .../ios/native-registration-retry.test.ts | 109 +++++++++ .../init/ios/native-registration-retry.ts | 215 ++++++++++++++++++ .../commands/init/ios/native-remote.test.ts | 160 +++++++++++-- .../src/commands/init/ios/native-remote.ts | 48 +++- 4 files changed, 513 insertions(+), 19 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/native-registration-retry.ts diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts new file mode 100644 index 000000000..79bbeb0dd --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + createIOSNativeRegistrationRetryStore, + type IOSNativeRegistrationRetryIdentity, +} from "./native-registration-retry.ts"; + +const temporaryDirectories: string[] = []; + +async function temporaryStateDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "clerk-ios-registration-retry-")); + temporaryDirectories.push(directory); + return directory; +} + +function identity( + overrides: Partial = {}, +): IOSNativeRegistrationRetryIdentity { + return { + applicationId: "app_native_test", + instanceId: "ins_native_development", + bundleIdentifier: "com.example.NativeApp", + appIdPrefix: "ABCDE12345", + ...overrides, + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("iOS native registration retry state", () => { + test("atomically reuses one key across concurrent callers and store instances", async () => { + const stateDirectory = await temporaryStateDirectory(); + const firstStore = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const secondStore = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + + const keys = await Promise.all([ + firstStore.getOrCreate(target), + secondStore.getOrCreate(target), + firstStore.getOrCreate(target), + secondStore.getOrCreate(target), + ]); + + expect(new Set(keys).size).toBe(1); + expect(keys[0]).toStartWith("clerk-init-ios-registration-"); + }); + + test("scopes pending operations to the complete remote registration identity", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + const first = await store.getOrCreate(target); + + for (const changed of [ + identity({ applicationId: "app_other" }), + identity({ instanceId: "ins_other" }), + identity({ bundleIdentifier: "com.example.Other" }), + identity({ appIdPrefix: "OTHER12345" }), + ]) { + expect(await store.getOrCreate(changed)).not.toBe(first); + } + }); + + test("clears a verified operation so a later registration receives a new key", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + const first = await store.getOrCreate(target); + + await store.clear(target); + + expect(await store.getOrCreate(target)).not.toBe(first); + }); + + test("retains an old pending operation until remote verification clears it", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + const first = await store.getOrCreate(target); + const directory = join(stateDirectory, "idempotency"); + const [filename] = await readdir(directory); + const path = join(directory, filename!); + const record = JSON.parse(await readFile(path, "utf8")) as Record; + record.createdAt = "2000-01-01T00:00:00.000Z"; + await writeFile(path, `${JSON.stringify(record, null, 2)}\n`); + + expect(await store.getOrCreate(target)).toBe(first); + }); + + test("fails closed instead of replacing a malformed pending record", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + await store.getOrCreate(target); + const directory = join(stateDirectory, "idempotency"); + const [filename] = await readdir(directory); + await writeFile(join(directory, filename!), "{ malformed"); + + await expect(store.getOrCreate(target)).rejects.toThrow("retry record is malformed"); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts new file mode 100644 index 000000000..3d9be3696 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts @@ -0,0 +1,215 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises"; +import { setTimeout as sleep } from "node:timers/promises"; +import { dirname, join } from "node:path"; +import { getConfigFile } from "../../../lib/config.ts"; +import { withHomeFsAccess } from "../../../lib/host-execution.ts"; + +const RETRY_DIRECTORY = "idempotency"; +const RETRY_FILE_PREFIX = "ios-native-registration-"; +const IDEMPOTENCY_KEY_PREFIX = "clerk-init-ios-registration-"; +const IDEMPOTENCY_KEY_PATTERN = + /^clerk-init-ios-registration-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CONCURRENT_WRITE_ATTEMPTS = 20; +const CONCURRENT_WRITE_RETRY_MS = 5; + +export interface IOSNativeRegistrationRetryIdentity { + applicationId: string; + instanceId: string; + bundleIdentifier: string; + appIdPrefix: string; +} + +export interface IOSNativeRegistrationRetryStore { + getOrCreate(identity: IOSNativeRegistrationRetryIdentity): Promise; + clear(identity: IOSNativeRegistrationRetryIdentity): Promise; +} + +interface IOSNativeRegistrationRetryRecord { + schemaVersion: 1; + kind: "clerk-ios-native-registration-retry"; + applicationId: string; + instanceId: string; + bundleIdentifier: string; + appIdPrefix: string; + idempotencyKey: string; + createdAt: string; +} + +function retryFingerprint(identity: IOSNativeRegistrationRetryIdentity): string { + return createHash("sha256") + .update( + JSON.stringify({ + applicationId: identity.applicationId, + instanceId: identity.instanceId, + bundleIdentifier: identity.bundleIdentifier, + appIdPrefix: identity.appIdPrefix, + }), + ) + .digest("hex") + .slice(0, 24); +} + +function retryDirectory(baseDirectory: string): string { + return join(baseDirectory, RETRY_DIRECTORY); +} + +function retryPath(baseDirectory: string, identity: IOSNativeRegistrationRetryIdentity): string { + return join( + retryDirectory(baseDirectory), + `${RETRY_FILE_PREFIX}${retryFingerprint(identity)}.json`, + ); +} + +function isMissingFile(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +function isExistingFile(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "EEXIST"; +} + +function isRetryRecord( + value: unknown, + identity: IOSNativeRegistrationRetryIdentity, +): value is IOSNativeRegistrationRetryRecord { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + return ( + record.schemaVersion === 1 && + record.kind === "clerk-ios-native-registration-retry" && + record.applicationId === identity.applicationId && + record.instanceId === identity.instanceId && + record.bundleIdentifier === identity.bundleIdentifier && + record.appIdPrefix === identity.appIdPrefix && + typeof record.idempotencyKey === "string" && + IDEMPOTENCY_KEY_PATTERN.test(record.idempotencyKey) && + typeof record.createdAt === "string" && + !Number.isNaN(Date.parse(record.createdAt)) + ); +} + +async function readRetryRecordOnce( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, +): Promise { + const path = retryPath(baseDirectory, identity); + let source: string; + try { + source = await readFile(path, "utf8"); + } catch (error) { + if (isMissingFile(error)) return undefined; + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(source); + } catch { + throw new Error(`The Clerk iOS registration retry record is malformed: ${path}`); + } + if (!isRetryRecord(parsed, identity)) { + throw new Error(`The Clerk iOS registration retry record has an unexpected shape: ${path}`); + } + return parsed; +} + +async function readRetryRecord( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, +): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < CONCURRENT_WRITE_ATTEMPTS; attempt += 1) { + try { + return await readRetryRecordOnce(baseDirectory, identity); + } catch (error) { + lastError = error; + if (attempt + 1 < CONCURRENT_WRITE_ATTEMPTS) { + await sleep(CONCURRENT_WRITE_RETRY_MS); + } + } + } + throw lastError; +} + +async function getOrCreateRetryKey( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, +): Promise { + const existing = await readRetryRecord(baseDirectory, identity); + if (existing) return existing.idempotencyKey; + + const directory = retryDirectory(baseDirectory); + const path = retryPath(baseDirectory, identity); + await mkdir(directory, { recursive: true, mode: 0o700 }); + + const record: IOSNativeRegistrationRetryRecord = { + schemaVersion: 1, + kind: "clerk-ios-native-registration-retry", + applicationId: identity.applicationId, + instanceId: identity.instanceId, + bundleIdentifier: identity.bundleIdentifier, + appIdPrefix: identity.appIdPrefix, + idempotencyKey: `${IDEMPOTENCY_KEY_PREFIX}${randomUUID()}`, + createdAt: new Date().toISOString(), + }; + + try { + await writeFile(path, `${JSON.stringify(record, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }); + return record.idempotencyKey; + } catch (error) { + if (!isExistingFile(error)) throw error; + const concurrent = await readRetryRecord(baseDirectory, identity); + if (!concurrent) { + throw new Error("The Clerk iOS registration retry record disappeared during creation."); + } + return concurrent.idempotencyKey; + } +} + +async function clearRetryKey( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, +): Promise { + try { + await unlink(retryPath(baseDirectory, identity)); + } catch (error) { + if (!isMissingFile(error)) throw error; + return; + } + + // Remove only the empty operational directory. Never remove other CLI state. + try { + await rmdir(retryDirectory(baseDirectory)); + } catch { + // Another retry record is present, or another process started a retry. + } +} + +export function createIOSNativeRegistrationRetryStore( + resolveBaseDirectory: () => string = () => dirname(getConfigFile()), +): IOSNativeRegistrationRetryStore { + return { + async getOrCreate(identity) { + const baseDirectory = resolveBaseDirectory(); + const path = retryPath(baseDirectory, identity); + return withHomeFsAccess( + { operation: "write", target: path, label: "CLI idempotency state directory" }, + async () => getOrCreateRetryKey(baseDirectory, identity), + ); + }, + async clear(identity) { + const baseDirectory = resolveBaseDirectory(); + const path = retryPath(baseDirectory, identity); + await withHomeFsAccess( + { operation: "delete", target: path, label: "CLI idempotency state directory" }, + async () => clearRetryKey(baseDirectory, identity), + ); + }, + }; +} + +export const cliStateIOSNativeRegistrationRetryStore = createIOSNativeRegistrationRetryStore(); 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 12212a6b5..09387a1a4 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 @@ -14,6 +14,10 @@ import { type IOSNativeRemoteTargetSnapshot, } from "./native-remote.ts"; import type { IOSApplication, NativeSettings } from "../../../lib/plapi.ts"; +import type { + IOSNativeRegistrationRetryIdentity, + IOSNativeRegistrationRetryStore, +} from "./native-registration-retry.ts"; const APPLICATION_ID = "app_native_test"; const INSTANCE_ID = "ins_native_development"; @@ -95,6 +99,55 @@ const approvedTargetReader: IOSNativeRemoteTargetReader = async (snapshot) => ({ appIdPrefix: snapshot.appIdPrefix, }); +function memoryRegistrationRetryStore(): { + store: IOSNativeRegistrationRetryStore; + pending(identity: IOSNativeRegistrationRetryIdentity): string | undefined; +} { + const entries = new Map(); + let issued = 0; + const scope = (identity: IOSNativeRegistrationRetryIdentity) => JSON.stringify(identity); + return { + store: { + async getOrCreate(identity) { + const key = scope(identity); + const existing = entries.get(key); + if (existing) return existing; + issued += 1; + const created = `clerk-init-ios-registration-test-${issued}`; + entries.set(key, created); + return created; + }, + async clear(identity) { + entries.delete(scope(identity)); + }, + }, + pending(identity) { + return entries.get(scope(identity)); + }, + }; +} + +function registrationRetryIdentity( + overrides: Partial = {}, +): IOSNativeRegistrationRetryIdentity { + return { + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + bundleIdentifier: BUNDLE_IDENTIFIER, + appIdPrefix: LOCAL_PREFIX, + ...overrides, + }; +} + +async function applyRemoteSetup( + approved: IOSNativeRemotePlan, + api: IOSNativeRemoteAPI, + targetReader: IOSNativeRemoteTargetReader = approvedTargetReader, + registrationRetryStore: IOSNativeRegistrationRetryStore = memoryRegistrationRetryStore().store, +): Promise { + await applyIOSNativeRemoteSetup(approved, api, targetReader, registrationRetryStore); +} + function plan(options: { nativeApi: "required" | "satisfied"; registration: "required" | "satisfied"; @@ -142,8 +195,10 @@ interface ScriptedAPIOptions { function scriptedAPI(options: ScriptedAPIOptions = {}): { api: IOSNativeRemoteAPI; calls: string[]; + registrationIdempotencyKeys: string[]; } { const calls: string[] = []; + const registrationIdempotencyKeys: string[] = []; const nativeReads = options.nativeReads ?? [nativeSettings(false)]; const registrationReads = options.registrationReads ?? [[]]; let nativeReadIndex = 0; @@ -158,6 +213,7 @@ function scriptedAPI(options: ScriptedAPIOptions = {}): { return { calls, + registrationIdempotencyKeys, api: { async getNativeSettings(applicationId, instanceId) { expect(applicationId).toBe(APPLICATION_ID); @@ -189,6 +245,7 @@ function scriptedAPI(options: ScriptedAPIOptions = {}): { bundleId: BUNDLE_IDENTIFIER, }); expect(mutationOptions.idempotencyKey).toStartWith("clerk-init-ios-registration-"); + registrationIdempotencyKeys.push(mutationOptions.idempotencyKey); calls.push("POST iOS registration"); if (options.create) { return options.create(applicationId, instanceId, params, mutationOptions); @@ -272,7 +329,7 @@ describe("Clerk Native Application remote setup", () => { actions: [], blockers: [], }); - await applyIOSNativeRemoteSetup(result, api); + await applyRemoteSetup(result, api); expect(calls).toEqual([ "GET native settings", "GET iOS registrations", @@ -312,7 +369,7 @@ describe("Clerk Native Application remote setup", () => { prompts: prompts(), }); - await expect(applyIOSNativeRemoteSetup(approved, api)).rejects.toMatchObject({ + await expect(applyRemoteSetup(approved, api)).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE, message: "Clerk Native Application settings changed after the approved preview. No remote changes were made; rerun clerk init to review the new plan.", @@ -628,7 +685,7 @@ describe("Clerk Native Application remote setup", () => { registrationReads: [[], [exactRegistration]], }); - await applyIOSNativeRemoteSetup( + await applyRemoteSetup( plan({ nativeApi: "required", registration: "required" }), api, approvedTargetReader, @@ -645,7 +702,7 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup( + applyRemoteSetup( plan({ nativeApi: "satisfied", registration: "required" }), api, approvedTargetReader, @@ -699,9 +756,7 @@ describe("Clerk Native Application remote setup", () => { expectedAppIdPrefix: approved.appIdPrefix, }); - await expect( - applyIOSNativeRemoteSetup(approved, api, async () => current), - ).rejects.toMatchObject({ + await expect(applyRemoteSetup(approved, api, async () => current)).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE, message: expect.stringContaining("Xcode target identity changed"), }); @@ -718,7 +773,7 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup( + applyRemoteSetup( plan({ nativeApi: "satisfied", registration: "required" }), api, async () => { @@ -742,10 +797,8 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup( - plan({ nativeApi: "required", registration: "satisfied" }), - api, - async () => selectedTarget({ bundleIdentifier: "com.example.Changed" }), + applyRemoteSetup(plan({ nativeApi: "required", registration: "satisfied" }), api, async () => + selectedTarget({ bundleIdentifier: "com.example.Changed" }), ), ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE }); @@ -769,7 +822,7 @@ describe("Clerk Native Application remote setup", () => { localAppIdPrefix: null, }); - await applyIOSNativeRemoteSetup(approved, api, async () => { + await applyRemoteSetup(approved, api, async () => { inspections += 1; return selectedTarget({ appIdPrefix: EXPLICIT_PREFIX }); }); @@ -786,7 +839,7 @@ describe("Clerk Native Application remote setup", () => { registrationReads: [[], [exactRegistration]], }); - await applyIOSNativeRemoteSetup( + await applyRemoteSetup( plan({ nativeApi: "required", registration: "required" }), api, approvedTargetReader, @@ -810,7 +863,7 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup( + applyRemoteSetup( plan({ nativeApi: "satisfied", registration: "required" }), api, approvedTargetReader, @@ -819,6 +872,77 @@ describe("Clerk Native Application remote setup", () => { expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); }); + test("reuses a pending registration key across invocations until final verification", async () => { + const retry = memoryRegistrationRetryStore(); + const ambiguous = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], []], + create: async () => { + throw new Error("connection reset after unknown outcome"); + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + ambiguous.api, + approvedTargetReader, + retry.store, + ), + ).rejects.toThrow("could not be registered"); + const firstKey = ambiguous.registrationIdempotencyKeys[0]!; + expect(retry.pending(registrationRetryIdentity())).toBe(firstKey); + + const exactRegistration = registration(); + const retried = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + retried.api, + approvedTargetReader, + retry.store, + ); + + expect(retried.registrationIdempotencyKeys).toEqual([firstKey]); + expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); + + const recreated = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + recreated.api, + approvedTargetReader, + retry.store, + ); + expect(recreated.registrationIdempotencyKeys[0]).not.toBe(firstKey); + }); + + test("clears a pending retry when a rerun verifies that registration already exists", async () => { + const retry = memoryRegistrationRetryStore(); + const pendingKey = await retry.store.getOrCreate(registrationRetryIdentity()); + const exactRegistration = registration(); + const { api, calls, registrationIdempotencyKeys } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[exactRegistration], [exactRegistration]], + }); + + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + retry.store, + ); + + expect(pendingKey).toStartWith("clerk-init-ios-registration-"); + expect(registrationIdempotencyKeys).toEqual([]); + expect(calls).not.toContain("POST iOS registration"); + expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); + }); + test("reconciles an ambiguous Native API error when a re-read shows it enabled", async () => { const exactRegistration = registration(); const ambiguousError = new Error("connection reset after enable"); @@ -831,7 +955,7 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup( + applyRemoteSetup( plan({ nativeApi: "required", registration: "satisfied" }), api, approvedTargetReader, @@ -847,7 +971,7 @@ describe("Clerk Native Application remote setup", () => { }); await expect( - applyIOSNativeRemoteSetup( + applyRemoteSetup( plan({ nativeApi: "required", registration: "required" }), api, approvedTargetReader, @@ -888,7 +1012,7 @@ describe("Clerk Native Application remote setup", () => { let thrown: unknown; try { - await applyIOSNativeRemoteSetup( + await applyRemoteSetup( plan({ nativeApi: "satisfied", registration: "required" }), api, approvedTargetReader, 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 5a080e5fd..7123dfd84 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -27,6 +27,11 @@ import type { IOSUnverifiedAppIdPrefixSuggestion, } from "./native-readiness.ts"; import { buildIOSNativeReadinessAudit } from "./native-readiness.ts"; +import { + cliStateIOSNativeRegistrationRetryStore, + type IOSNativeRegistrationRetryIdentity, + type IOSNativeRegistrationRetryStore, +} from "./native-registration-retry.ts"; const APP_ID_PREFIX_MAX_LENGTH = 255; @@ -696,10 +701,23 @@ function revalidatedActionSetIsAuthorized( return true; } +function registrationRetryIdentity( + plan: IOSNativeRemotePlan, +): IOSNativeRegistrationRetryIdentity | undefined { + if (!plan.localTarget || !plan.bundleIdentifier || !plan.appIdPrefix) return undefined; + return { + applicationId: plan.applicationId, + instanceId: plan.instanceId, + bundleIdentifier: plan.bundleIdentifier, + appIdPrefix: plan.appIdPrefix, + }; +} + export async function applyIOSNativeRemoteSetup( plan: IOSNativeRemotePlan, api: IOSNativeRemoteAPI = defaultAPI, targetReader: IOSNativeRemoteTargetReader = defaultTargetReader, + registrationRetryStore: IOSNativeRegistrationRetryStore = cliStateIOSNativeRegistrationRetryStore, ): Promise { if (plan.status === "blocked" || !plan.bundleIdentifier || !plan.appIdPrefix) { throw iosRemoteError( @@ -732,12 +750,27 @@ export async function applyIOSNativeRemoteSetup( await revalidateLocalTargetBeforeRemoteMutation(plan, targetReader); } - const registrationIdempotencyKey = `clerk-init-ios-registration-${randomUUID()}`; const nativeAPIIdempotencyKey = `clerk-init-ios-native-api-${randomUUID()}`; + const retryIdentity = registrationRetryIdentity(plan); // Register first so Native API is never enabled by this command without a // matching iOS application registration already present. if (currentPlan.registration === "required") { + if (!retryIdentity) { + throw iosRemoteError( + "The approved Clerk Native Application plan cannot persist a safe registration retry. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + let registrationIdempotencyKey: string; + try { + registrationIdempotencyKey = await registrationRetryStore.getOrCreate(retryIdentity); + } catch (error) { + log.debug(`Could not preserve the iOS registration retry state: ${errorMessage(error)}`); + throw iosRemoteError( + "The iOS application registration retry could not be preserved safely. The local setup remains intact, and no registration request was sent; verify CLI state directory access and rerun clerk init.", + ); + } try { const created = await withSpinner("Registering the iOS application with Clerk...", async () => api.createIOSApplication( @@ -839,4 +872,17 @@ export async function applyIOSNativeRemoteSetup( ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, ); } + if (retryIdentity) { + try { + await registrationRetryStore.clear(retryIdentity); + } catch (error) { + log.debug( + `Could not clear the verified iOS registration retry state: ${errorMessage(error)}`, + ); + throw iosRemoteError( + "Clerk Native Application settings were verified, but the local registration retry state could not be cleared. No further remote changes are required; verify CLI state directory access and rerun clerk init.", + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + } } From fc1a4b16abd36c23b7257d2b8c085c668495b0ef Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 20:43:02 -0400 Subject: [PATCH 20/55] fix(init): validate present Apple entitlements --- packages/cli-core/src/commands/init/index.ts | 4 ++- .../cli-core/src/commands/init/ios/apply.ts | 4 ++- .../src/commands/init/ios/dry-run.test.ts | 33 +++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 0eda7a22d..87ff7c64a 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -314,7 +314,9 @@ export async function init(options: InitOptions = {}) { }) : undefined; const hasLocalAppleIntent = selectedTarget?.configurations.some( - (configuration) => configuration.entitlements?.signInWithApple === true, + (configuration) => + configuration.entitlements !== undefined && + configuration.entitlements.signInWithAppleState !== "absent", ); const appleEntitlementPlan = dryRunSelection.state === "selected" && diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index f560325ba..d34eb9f16 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -492,7 +492,9 @@ export async function applyIOSLocalSetup( ); } const hasLocalAppleEntitlement = selectedTarget.configurations.some( - (configuration) => configuration.entitlements?.signInWithApple === true, + (configuration) => + configuration.entitlements !== undefined && + configuration.entitlements.signInWithAppleState !== "absent", ); let nativeAppleRequested = options.signInWithApple === true; if (!nativeAppleRequested && options.signInWithApple == null && !options.agent && !options.yes) { diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts index ac782ad01..642b8f241 100644 --- a/packages/cli-core/src/commands/init/ios/dry-run.test.ts +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -459,6 +459,39 @@ struct MyApp: App { } }); + test("surfaces a malformed present Apple entitlement without requiring explicit opt-in", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-invalid-apple-dry-run-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await Bun.file(entitlementsPath).text(); + await Bun.write( + entitlementsPath, + entitlements.replace( + "", + "com.apple.developer.applesignin\n", + ), + ); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + const apple = output.plan.steps.find( + (step: { id: string }) => step.id === "enable-native-apple", + ); + expect(apple).toMatchObject({ status: "blocked", automatable: false }); + expect(apple.description).toContain("Sign in with Apple entitlement"); + expect(output.inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.invalid-apple-entitlement" }), + ); + }); + test("explicit prebuilt AuthView dry-run refuses to overwrite a partial existing flow without network access", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-prebuilt-auth-dry-run-")); temporaryDirectories.push(root); From 003a1c1d8df70e46d36d77b98d817aea510feba5 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 20:49:26 -0400 Subject: [PATCH 21/55] fix(init): block malformed Apple entitlements --- .../src/commands/init/ios/apply-cli.test.ts | 26 +++++++++++++++++++ .../cli-core/src/commands/init/ios/apply.ts | 6 +++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index be5cc2ace..50fda1ee5 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -465,6 +465,32 @@ struct MyApp: App { ); }); + test("blocks a malformed present Apple entitlement without treating it as opt-in", async () => { + const root = await createUnconfiguredFixture(); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const entitlements = await Bun.file(entitlementsPath).text(); + await Bun.write( + entitlementsPath, + entitlements.replace( + "", + "com.apple.developer.applesignin\n", + ), + ); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }), + ).rejects.toThrow("Native Sign in with Apple could not be configured safely"); + + expect(await treeDigest(root)).toEqual(before); + }); + test("uses the linked key host over an unrelated root env during aggregate setup", async () => { const root = await createUnconfiguredFixture(); const configDir = await createIsolatedCLIState(); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index d34eb9f16..215f75a38 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -519,9 +519,11 @@ export async function applyIOSLocalSetup( // this invocation explicitly opted into the strategy. const appleEntitlementPlan = nativeAppleRequested ? inspectedAppleEntitlementPlan - : inspectedAppleEntitlementPlan?.status === "satisfied" + : hasLocalAppleEntitlement && inspectedAppleEntitlementPlan?.status === "blocked" ? inspectedAppleEntitlementPlan - : undefined; + : inspectedAppleEntitlementPlan?.status === "satisfied" + ? inspectedAppleEntitlementPlan + : undefined; const prebuiltAuthAppleEntitlementPlan = prebuiltAuthActive ? inspectedAppleEntitlementPlan : undefined; From 60c2f5cdde1eb341f0e36a31364eecfc26fcd028 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 20:55:48 -0400 Subject: [PATCH 22/55] fix(init): serialize registration retries --- .../ios/native-registration-retry.test.ts | 37 ++++- .../init/ios/native-registration-retry.ts | 132 ++++++++++++++++-- .../commands/init/ios/native-remote.test.ts | 76 +++++++++- .../src/commands/init/ios/native-remote.ts | 57 +++++--- 4 files changed, 264 insertions(+), 38 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts index 79bbeb0dd..5f434b09c 100644 --- a/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -75,11 +75,23 @@ describe("iOS native registration retry state", () => { const target = identity(); const first = await store.getOrCreate(target); - await store.clear(target); + expect(await store.clear(target, first)).toBe(true); expect(await store.getOrCreate(target)).not.toBe(first); }); + test("does not let a delayed clear remove a newer registration generation", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const target = identity(); + const first = await store.getOrCreate(target); + expect(await store.clear(target, first)).toBe(true); + const newer = await store.getOrCreate(target); + + expect(await store.clear(target, first)).toBe(false); + expect(await store.peek(target)).toBe(newer); + }); + test("retains an old pending operation until remote verification clears it", async () => { const stateDirectory = await temporaryStateDirectory(); const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); @@ -106,4 +118,25 @@ describe("iOS native registration retry state", () => { await expect(store.getOrCreate(target)).rejects.toThrow("retry record is malformed"); }); + + test("fails closed without stealing an abandoned stale filesystem lock", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory, { + lockRetryMs: 1, + lockTimeoutMs: 10, + lockStaleMs: 5, + }); + const target = identity(); + const first = await store.getOrCreate(target); + const directory = join(stateDirectory, "idempotency"); + const [filename] = await readdir(directory); + const lock = join(directory, `${filename!}.lock`); + await mkdir(lock); + const stale = new Date(Date.now() - 60_000); + await utimes(lock, stale, stale); + + expect(first).toStartWith("clerk-init-ios-registration-"); + await expect(store.getOrCreate(target)).rejects.toThrow("lock is stale"); + expect(await readdir(lock)).toEqual([]); + }); }); diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts index 3d9be3696..133e77cb4 100644 --- a/packages/cli-core/src/commands/init/ios/native-registration-retry.ts +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises"; import { setTimeout as sleep } from "node:timers/promises"; import { dirname, join } from "node:path"; import { getConfigFile } from "../../../lib/config.ts"; @@ -12,6 +12,15 @@ const IDEMPOTENCY_KEY_PATTERN = /^clerk-init-ios-registration-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const CONCURRENT_WRITE_ATTEMPTS = 20; const CONCURRENT_WRITE_RETRY_MS = 5; +const LOCK_RETRY_MS = 10; +const LOCK_TIMEOUT_MS = 5_000; +const LOCK_STALE_MS = 30_000; + +interface IOSNativeRegistrationRetryStoreOptions { + lockRetryMs?: number; + lockTimeoutMs?: number; + lockStaleMs?: number; +} export interface IOSNativeRegistrationRetryIdentity { applicationId: string; @@ -22,7 +31,8 @@ export interface IOSNativeRegistrationRetryIdentity { export interface IOSNativeRegistrationRetryStore { getOrCreate(identity: IOSNativeRegistrationRetryIdentity): Promise; - clear(identity: IOSNativeRegistrationRetryIdentity): Promise; + peek(identity: IOSNativeRegistrationRetryIdentity): Promise; + clear(identity: IOSNativeRegistrationRetryIdentity, expectedKey: string): Promise; } interface IOSNativeRegistrationRetryRecord { @@ -69,6 +79,74 @@ function isExistingFile(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === "EEXIST"; } +function lockPath(baseDirectory: string, identity: IOSNativeRegistrationRetryIdentity): string { + return `${retryPath(baseDirectory, identity)}.lock`; +} + +async function acquireLock( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, + options: Required, +): Promise { + await mkdir(retryDirectory(baseDirectory), { recursive: true, mode: 0o700 }); + const path = lockPath(baseDirectory, identity); + const deadline = Date.now() + options.lockTimeoutMs; + while (true) { + try { + await mkdir(path, { mode: 0o700 }); + return path; + } catch (error) { + if (!isExistingFile(error)) throw error; + if (Date.now() >= deadline) { + let stale = false; + try { + stale = Date.now() - (await lstat(path)).mtimeMs >= options.lockStaleMs; + } catch (statError) { + if (isMissingFile(statError)) continue; + throw statError; + } + throw new Error( + stale + ? `The Clerk iOS registration retry-state lock is stale and was left in place for safety: ${path}` + : "Timed out waiting for the Clerk iOS registration retry-state lock.", + ); + } + await sleep(options.lockRetryMs); + } + } +} + +async function withIdentityLock( + baseDirectory: string, + identity: IOSNativeRegistrationRetryIdentity, + options: Required, + operation: () => Promise, +): Promise { + const path = await acquireLock(baseDirectory, identity, options); + const release = async () => { + try { + await rmdir(path); + } catch (error) { + if (!isMissingFile(error)) throw error; + } + }; + try { + const result = await operation(); + await release(); + return result; + } catch (operationError) { + try { + await release(); + } catch (releaseError) { + throw new AggregateError( + [operationError, releaseError], + "The Clerk iOS registration retry operation and lock release both failed.", + ); + } + throw operationError; + } +} + function isRetryRecord( value: unknown, identity: IOSNativeRegistrationRetryIdentity, @@ -173,40 +251,64 @@ async function getOrCreateRetryKey( async function clearRetryKey( baseDirectory: string, identity: IOSNativeRegistrationRetryIdentity, -): Promise { + expectedKey: string, +): Promise { + const existing = await readRetryRecord(baseDirectory, identity); + if (!existing) return true; + if (existing.idempotencyKey !== expectedKey) return false; try { await unlink(retryPath(baseDirectory, identity)); } catch (error) { if (!isMissingFile(error)) throw error; - return; - } - - // Remove only the empty operational directory. Never remove other CLI state. - try { - await rmdir(retryDirectory(baseDirectory)); - } catch { - // Another retry record is present, or another process started a retry. + return true; } + return true; } export function createIOSNativeRegistrationRetryStore( resolveBaseDirectory: () => string = () => dirname(getConfigFile()), + options: IOSNativeRegistrationRetryStoreOptions = {}, ): IOSNativeRegistrationRetryStore { + const lockOptions: Required = { + lockRetryMs: options.lockRetryMs ?? LOCK_RETRY_MS, + lockTimeoutMs: options.lockTimeoutMs ?? LOCK_TIMEOUT_MS, + lockStaleMs: options.lockStaleMs ?? LOCK_STALE_MS, + }; return { async getOrCreate(identity) { const baseDirectory = resolveBaseDirectory(); const path = retryPath(baseDirectory, identity); return withHomeFsAccess( { operation: "write", target: path, label: "CLI idempotency state directory" }, - async () => getOrCreateRetryKey(baseDirectory, identity), + async () => + withIdentityLock(baseDirectory, identity, lockOptions, async () => + getOrCreateRetryKey(baseDirectory, identity), + ), ); }, - async clear(identity) { + async peek(identity) { const baseDirectory = resolveBaseDirectory(); const path = retryPath(baseDirectory, identity); - await withHomeFsAccess( + return withHomeFsAccess( + { operation: "read", target: path, label: "CLI idempotency state directory" }, + async () => + withIdentityLock( + baseDirectory, + identity, + lockOptions, + async () => (await readRetryRecord(baseDirectory, identity))?.idempotencyKey, + ), + ); + }, + async clear(identity, expectedKey) { + const baseDirectory = resolveBaseDirectory(); + const path = retryPath(baseDirectory, identity); + return withHomeFsAccess( { operation: "delete", target: path, label: "CLI idempotency state directory" }, - async () => clearRetryKey(baseDirectory, identity), + async () => + withIdentityLock(baseDirectory, identity, lockOptions, async () => + clearRetryKey(baseDirectory, identity, expectedKey), + ), ); }, }; 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 09387a1a4..2615f872d 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 @@ -117,8 +117,15 @@ function memoryRegistrationRetryStore(): { entries.set(key, created); return created; }, - async clear(identity) { - entries.delete(scope(identity)); + async peek(identity) { + return entries.get(scope(identity)); + }, + async clear(identity, expectedKey) { + const key = scope(identity); + const existing = entries.get(key); + if (existing && existing !== expectedKey) return false; + entries.delete(key); + return true; }, }, pending(identity) { @@ -761,7 +768,7 @@ describe("Clerk Native Application remote setup", () => { message: expect.stringContaining("Xcode target identity changed"), }); - expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).toEqual([]); expect(calls).not.toContain("POST iOS registration"); expect(calls).not.toContain("PATCH native settings"); }); @@ -785,7 +792,7 @@ describe("Clerk Native Application remote setup", () => { message: expect.stringContaining("Xcode target identity could not be rechecked"), }); - expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).toEqual([]); expect(calls).not.toContain("POST iOS registration"); expect(calls).not.toContain("PATCH native settings"); }); @@ -802,7 +809,7 @@ describe("Clerk Native Application remote setup", () => { ), ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE }); - expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).toEqual([]); expect(calls).not.toContain("POST iOS registration"); expect(calls).not.toContain("PATCH native settings"); }); @@ -943,6 +950,65 @@ describe("Clerk Native Application remote setup", () => { expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); }); + test("rechecks remote state after a paused invocation acquires a newer retry generation", async () => { + const retry = memoryRegistrationRetryStore(); + let releaseGet!: () => void; + const getGate = new Promise((resolve) => { + releaseGet = resolve; + }); + let reportPaused!: () => void; + const paused = new Promise((resolve) => { + reportPaused = resolve; + }); + const pausedStore: IOSNativeRegistrationRetryStore = { + async getOrCreate(identity) { + reportPaused(); + await getGate; + return retry.store.getOrCreate(identity); + }, + async peek(identity) { + return retry.store.peek(identity); + }, + async clear(identity, expectedKey) { + return retry.store.clear(identity, expectedKey); + }, + }; + const exactRegistration = registration(); + const resumed = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[exactRegistration], [exactRegistration]], + }); + + const resumedApply = applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + resumed.api, + approvedTargetReader, + pausedStore, + ); + await paused; + + const first = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + first.api, + approvedTargetReader, + retry.store, + ); + const completedKey = first.registrationIdempotencyKeys[0]!; + expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); + + releaseGet(); + await resumedApply; + + expect(resumed.registrationIdempotencyKeys).toEqual([]); + expect(resumed.calls).not.toContain("POST iOS registration"); + expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); + expect(completedKey).toStartWith("clerk-init-ios-registration-"); + }); + test("reconciles an ambiguous Native API error when a re-read shows it enabled", async () => { const exactRegistration = registration(); const ambiguousError = new Error("connection reset after enable"); 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 7123dfd84..2421a6abe 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -726,6 +726,33 @@ export async function applyIOSNativeRemoteSetup( ); } + if (plan.registration === "required" || plan.nativeApi === "required") { + await revalidateLocalTargetBeforeRemoteMutation(plan, targetReader); + } + + const nativeAPIIdempotencyKey = `clerk-init-ios-native-api-${randomUUID()}`; + const retryIdentity = registrationRetryIdentity(plan); + let observedRegistrationRetryKey: string | undefined; + + if (retryIdentity) { + try { + observedRegistrationRetryKey = + plan.registration === "required" + ? await registrationRetryStore.getOrCreate(retryIdentity) + : await registrationRetryStore.peek(retryIdentity); + } catch (error) { + log.debug( + `Could not read or preserve the iOS registration retry state: ${errorMessage(error)}`, + ); + throw iosRemoteError( + "The iOS application registration retry state could not be read or preserved safely. The local setup remains intact, and no registration request was sent; verify CLI state directory access and rerun clerk init.", + ); + } + } + + // Acquire the stable registration generation before the authoritative + // remote re-read. A second CLI that resumes after another invocation has + // completed must observe that completion before deciding whether to POST. let currentPlan: IOSNativeRemotePlan; try { currentPlan = await withSpinner("Rechecking Clerk Native Application settings...", async () => @@ -746,13 +773,6 @@ export async function applyIOSNativeRemoteSetup( ); } - if (currentPlan.registration === "required" || currentPlan.nativeApi === "required") { - await revalidateLocalTargetBeforeRemoteMutation(plan, targetReader); - } - - const nativeAPIIdempotencyKey = `clerk-init-ios-native-api-${randomUUID()}`; - const retryIdentity = registrationRetryIdentity(plan); - // Register first so Native API is never enabled by this command without a // matching iOS application registration already present. if (currentPlan.registration === "required") { @@ -762,13 +782,10 @@ export async function applyIOSNativeRemoteSetup( ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } - let registrationIdempotencyKey: string; - try { - registrationIdempotencyKey = await registrationRetryStore.getOrCreate(retryIdentity); - } catch (error) { - log.debug(`Could not preserve the iOS registration retry state: ${errorMessage(error)}`); + if (!observedRegistrationRetryKey) { throw iosRemoteError( - "The iOS application registration retry could not be preserved safely. The local setup remains intact, and no registration request was sent; verify CLI state directory access and rerun clerk init.", + "The approved Clerk Native Application plan did not retain a safe registration retry. No registration request was sent; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } try { @@ -777,7 +794,7 @@ export async function applyIOSNativeRemoteSetup( plan.applicationId, plan.instanceId, { appIdPrefix: plan.appIdPrefix!, bundleId: plan.bundleIdentifier! }, - { idempotencyKey: registrationIdempotencyKey }, + { idempotencyKey: observedRegistrationRetryKey }, ), ); if ( @@ -872,9 +889,17 @@ export async function applyIOSNativeRemoteSetup( ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, ); } - if (retryIdentity) { + if (retryIdentity && observedRegistrationRetryKey) { try { - await registrationRetryStore.clear(retryIdentity); + const cleared = await registrationRetryStore.clear( + retryIdentity, + observedRegistrationRetryKey, + ); + if (!cleared) { + log.debug( + "Preserved a newer iOS registration retry state created after this invocation began.", + ); + } } catch (error) { log.debug( `Could not clear the verified iOS registration retry state: ${errorMessage(error)}`, From fe8ea707b408a980e60427d389fa02e2a391a834 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 21:49:07 -0400 Subject: [PATCH 23/55] test(init): declare complete runtime key evidence --- packages/cli-core/src/commands/init/ios/build-settings.test.ts | 1 + packages/cli-core/src/commands/init/ios/plan.test.ts | 3 +++ 2 files changed, 4 insertions(+) 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 f5909df85..5d80f8337 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 @@ -754,6 +754,7 @@ describe("inspectTargetBuildConfigurations", () => { projectPath: "Example.xcodeproj", }, localPublishableKey: { + evidenceComplete: true, found: false, conflict: false, candidateSources: [], diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 4302467f8..f49200658 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -109,6 +109,7 @@ describe("buildIOSSetupPlan", () => { const target = inspection.appTargets[0]!; const schemePath = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; inspection.localPublishableKey = { + evidenceComplete: true, found: true, source: schemePath, frontendApiHost: "stale.clerk.example", @@ -616,6 +617,7 @@ struct MyApp: App { }); const schemePath = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; inspection.localPublishableKey = { + evidenceComplete: true, found: true, source: schemePath, frontendApiHost: "clerk.example.test", @@ -652,6 +654,7 @@ struct MyApp: App { "CLERK_PUBLISHABLE_KEY environment variable", ]) { inspection.localPublishableKey = { + evidenceComplete: true, found: true, source, frontendApiHost: "clerk.example.test", From 8b2fbf4d42ddf477a6b989f8f16f380a67fd4ad3 Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 27 Aug 2026 23:14:39 -0400 Subject: [PATCH 24/55] fix(init): preserve associated domain service case --- .../init/ios/associated-domain.test.ts | 18 ++++++++++++++++ .../commands/init/ios/associated-domain.ts | 20 +++++++++++++++++- .../init/ios/native-readiness.test.ts | 18 ++++++++++++++++ .../src/commands/init/ios/native-readiness.ts | 6 +++--- .../src/commands/init/ios/plan.test.ts | 21 +++++++++++++++++++ .../cli-core/src/commands/init/ios/plan.ts | 6 +----- 6 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts index f9f04eb74..d7df893aa 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.test.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.test.ts @@ -304,6 +304,24 @@ struct MyApp: App { expect(updated).toContain(`webcredentials:${HOST}`); }); + test("matches only the associated-domain hostname case-insensitively", async () => { + const root = await directFixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const source = await readFile(path, "utf8"); + await writeFile( + path, + source.replace("webcredentials:clerk.example.test", `webcredentials:${HOST.toUpperCase()}`), + ); + + expect((await planIOSAssociatedDomain(planOptions(root))).status).toBe("satisfied"); + + await writeFile( + path, + source.replace("webcredentials:clerk.example.test", `WEBCREDENTIALS:${HOST}`), + ); + expect((await planIOSAssociatedDomain(planOptions(root))).status).toBe("ready"); + }); + test("preserves a comment immediately before a self-closing Associated Domains array", async () => { const root = await directFixture(); const path = join(root, "MyApp", "MyApp.entitlements"); diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index afa700e17..bcc305d64 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -479,8 +479,26 @@ async function ownershipIsExclusive( } } +export function associatedDomainMatches(actual: string, expected: string): boolean { + const actualSeparator = actual.indexOf(":"); + const expectedSeparator = expected.indexOf(":"); + if (actualSeparator < 0 || expectedSeparator < 0) return false; + + const actualService = actual.slice(0, actualSeparator); + const expectedService = expected.slice(0, expectedSeparator); + if (actualService !== expectedService) return false; + + const splitHost = (value: string): [host: string, suffix: string] => { + const suffixStart = value.search(/[/?#]/); + return suffixStart < 0 ? [value, ""] : [value.slice(0, suffixStart), value.slice(suffixStart)]; + }; + const [actualHost, actualSuffix] = splitHost(actual.slice(actualSeparator + 1)); + const [expectedHost, expectedSuffix] = splitHost(expected.slice(expectedSeparator + 1)); + return actualHost.toLowerCase() === expectedHost.toLowerCase() && actualSuffix === expectedSuffix; +} + function exactDomainPresent(domains: readonly string[], expectedDomain: string): boolean { - return domains.includes(expectedDomain); + return domains.some((domain) => associatedDomainMatches(domain, expectedDomain)); } async function generatedProjectKind( diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts index 3f7f86f73..efe891e28 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -187,6 +187,24 @@ describe("buildIOSNativeReadinessAudit", () => { }); }); + test("does not satisfy readiness with a differently cased service token", async () => { + const inspection = await inspectionFor({ + complete: true, + includeKey: false, + localSecrets: true, + }); + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["WEBCREDENTIALS:native.clerk.example"]; + } + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.associatedDomain).toMatchObject({ + status: "required", + expectedDomain: "webcredentials:native.clerk.example", + }); + }); + test("blocks automation when configurations have mixed entitlements evidence", async () => { const inspection = await inspectionFor({ complete: true, diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.ts b/packages/cli-core/src/commands/init/ios/native-readiness.ts index fdc51a1ce..b241a6db4 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -1,3 +1,4 @@ +import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; import { buildIOSSetupPlan } from "./plan.ts"; import type { IOSAppTarget, @@ -5,7 +6,6 @@ import type { IOSSetupStepStatus, IOSValueResolution, } from "./types.ts"; -import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; export const IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT = { applicationId: "linked-application-id", @@ -244,8 +244,8 @@ function associatedDomainReadiness( target != null && target.configurations.length > 0 && target.configurations.every((configuration) => - configuration.entitlements?.associatedDomains.some( - (domain) => domain.toLowerCase() === expectedDomain.toLowerCase(), + configuration.entitlements?.associatedDomains.some((domain) => + associatedDomainMatches(domain, expectedDomain), ), ); // The legacy planner accepts Apple's ?mode=developer suffix. Native setup diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index f49200658..e0f394605 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -944,6 +944,27 @@ struct MyApp: App { expect(plan.steps.find((step) => step.id === "add-associated-domain")?.status).toBe("required"); }); + test("matches only the associated-domain hostname case-insensitively", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const inspection = await inspectIOSProject(root); + + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["webcredentials:NATIVE.CLERK.EXAMPLE"]; + } + expect( + buildIOSSetupPlan(inspection).steps.find((step) => step.id === "add-associated-domain"), + ).toMatchObject({ status: "satisfied" }); + + for (const configuration of inspection.appTargets[0]!.configurations) { + configuration.entitlements!.associatedDomains = ["WEBCREDENTIALS:native.clerk.example"]; + } + expect( + buildIOSSetupPlan(inspection).steps.find((step) => step.id === "add-associated-domain"), + ).toMatchObject({ status: "required" }); + }); + test("blocks all dependent steps when target selection is ambiguous", async () => { const plan = await planFor({ secondTarget: true }); diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index f060713b0..5273166e8 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -11,7 +11,7 @@ import { hasIOSDirectConfigCompatibility } from "./products.ts"; import { clerkKitUIInstallDecision } from "./products.ts"; import type { IOSDirectConfigPlan } from "./direct-config.ts"; import type { IOSRuntimeKeyPlan } from "./runtime-key.ts"; -import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; import type { IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; import type { IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; import type { IOSSDKInstallPlan } from "./install-sdk.ts"; @@ -21,10 +21,6 @@ const QUICKSTART_URL = "https://clerk.com/docs/ios/getting-started/quickstart"; const NATIVE_APPLE_URL = "https://clerk.com/docs/ios/guides/configure/auth-strategies/sign-in-with-apple"; -function associatedDomainMatches(actual: string, expected: string): boolean { - return actual.toLowerCase() === expected.toLowerCase(); -} - function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | undefined { const selection = inspection.selection; if (selection.state !== "selected") return undefined; From c71041374536800e2b4821c3145fb57c46fa0b5d Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 08:50:34 -0400 Subject: [PATCH 25/55] fix(init): exhaustively discover iOS mutation targets --- .../src/commands/init/ios/apply-cli.test.ts | 97 +++++++++++++++++++ .../cli-core/src/commands/init/ios/apply.ts | 34 ++++++- .../commands/init/ios/associated-domain.ts | 4 +- .../commands/init/ios/direct-config.test.ts | 8 ++ .../src/commands/init/ios/direct-config.ts | 20 +++- .../init/ios/entitlements-settings.test.ts | 10 +- .../init/ios/entitlements-settings.ts | 5 +- .../src/commands/init/ios/inspect.test.ts | 7 ++ .../cli-core/src/commands/init/ios/inspect.ts | 25 ++++- .../src/commands/init/ios/install-sdk.ts | 24 ++++- .../src/commands/init/ios/native-remote.ts | 10 +- .../commands/init/ios/prebuilt-auth.test.ts | 7 +- .../src/commands/init/ios/prebuilt-auth.ts | 20 +++- .../cli-core/src/commands/init/ios/types.ts | 1 + 14 files changed, 253 insertions(+), 19 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index 50fda1ee5..88fb7a823 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -3,6 +3,7 @@ import { cp, mkdir, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parse as parsePbxProject } from "@bacons/xcode/json"; +import { ERROR_CODE } from "../../../lib/errors.ts"; import { inspectIOSProject } from "./inspect.ts"; import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; import { @@ -33,6 +34,102 @@ setDefaultTimeout(15_000); describe("clerk init iOS SDK apply", () => { const captured = useCaptureLog(); + test("uses exhaustive project discovery before implicitly selecting a target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-exhaustive-apply-selection-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + await createIOSFixture(join(root, "Level0", "Level1", "Level2", "Level3"), { + complete: true, + }); + const before = await treeDigest(root); + + expect((await inspectIOSProject(root)).selection.state).toBe("selected"); + await expect( + applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: false, + }), + ).rejects.toThrow("More than one iOS application target is eligible"); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("fails closed when exhaustive project discovery reaches its safety bound", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-incomplete-apply-selection-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const nesting = Array.from({ length: 25 }, (_, index) => `Level${index}`); + await mkdir(join(root, ...nesting), { recursive: true }); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_TARGET_UNRESOLVED, + message: expect.stringContaining("Xcode project discovery was incomplete"), + }); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("fails closed when a workspace exposes an incomplete project inventory", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-incomplete-workspace-apply-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, localSecrets: true }); + const workspace = join(root, "Broken.xcworkspace"); + await mkdir(workspace); + await Bun.write( + join(workspace, "contents.xcworkspacedata"), + '', + ); + const before = await treeDigest(root); + + await expect( + applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: false, + }), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_TARGET_UNRESOLVED, + message: expect.stringContaining("Xcode project discovery was incomplete"), + }); + + expect(await treeDigest(root)).toEqual(before); + }); + + test("keeps a single deeply nested project selected through setup planning", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-deep-apply-selection-")); + temporaryDirectories.push(root); + const projectRoot = join(root, "Level0", "Level1", "Level2", "Level3"); + await createIOSFixture(projectRoot, { complete: true, localSecrets: true }); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + yes: true, + agent: true, + allowDirty: false, + }); + + expect(setup.nativeReadiness.target).toMatchObject({ + status: "selected", + projectPath: "Level0/Level1/Level2/Level3/MyApp.xcodeproj", + targetId: IOS_FIXTURE_IDS.appTarget, + targetName: "MyApp", + }); + expect(await treeDigest(root)).toEqual(before); + }); + test("applies the explicit prebuilt AuthView opt-in in the aggregate Swift transaction", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-prebuilt-auth-apply-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 215f75a38..f72576d0f 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -11,7 +11,7 @@ import { import { log } from "../../../lib/log.ts"; import { confirm } from "../../../lib/prompts.ts"; import { withSpinner } from "../../../lib/spinner.ts"; -import { inspectIOSProject } from "./inspect.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { planIOSSDKInstall, prepareIOSSDKInstallMutation, @@ -299,7 +299,16 @@ async function validatePrebuiltAuthRuntimePostcondition( const target = setup.nativeReadiness.target; const inspection = await inspectIOSProject(setup.nativeReadiness.root, { target: target.targetId, + exhaustiveContainerDiscovery: true, }); + if ( + hasIncompleteIOSContainerDiscovery(inspection) || + inspection.selection.state !== "selected" || + inspection.selection.targetId !== target.targetId || + inspection.selection.projectPath !== target.projectPath + ) { + return false; + } const setupPlan = buildIOSSetupPlan(inspection, { runtimeKeyPlan: allowPendingRuntimeKey ? setup.runtimeKeyPlan : undefined, }); @@ -323,8 +332,17 @@ export async function applyIOSLocalSetup( options: ApplyIOSLocalSetupOptions, ): Promise { const inspection = await withSpinner("Inspecting Xcode project...", async () => - inspectIOSProject(options.root, { target: options.target }), + inspectIOSProject(options.root, { + target: options.target, + exhaustiveContainerDiscovery: true, + }), ); + if (hasIncompleteIOSContainerDiscovery(inspection)) { + throw iosSetupError( + "Xcode project discovery was incomplete, so Clerk cannot safely select an iOS application target. Run the command from the intended project's directory, make nested project directories readable, or reduce excessive project nesting or count.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } const selection = inspection.selection; if (selection.state !== "selected") { if (selection.state === "ambiguous") { @@ -1226,7 +1244,19 @@ export async function applyIOSPlannedLocalSetup( } const inspection = await inspectIOSProject(setup.nativeReadiness.root, { target: setup.nativeReadiness.target.targetId, + exhaustiveContainerDiscovery: true, }); + if ( + hasIncompleteIOSContainerDiscovery(inspection) || + inspection.selection.state !== "selected" || + inspection.selection.targetId !== setup.nativeReadiness.target.targetId || + inspection.selection.projectPath !== setup.nativeReadiness.target.projectPath + ) { + throw iosSetupError( + "The approved prebuilt AuthView setup no longer identifies the same exhaustively discovered Xcode target. No local setup changes were written; rerun clerk init.", + ERROR_CODE.IOS_SETUP_STALE, + ); + } const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers( inspection, setup.directConfigPlan, diff --git a/packages/cli-core/src/commands/init/ios/associated-domain.ts b/packages/cli-core/src/commands/init/ios/associated-domain.ts index bcc305d64..8e95f08cd 100644 --- a/packages/cli-core/src/commands/init/ios/associated-domain.ts +++ b/packages/cli-core/src/commands/init/ios/associated-domain.ts @@ -23,7 +23,7 @@ import { validateIOSMissingEntitlementsSettingsPostcondition, type IOSMissingEntitlementsSettingsPlan, } from "./entitlements-settings.ts"; -import { inspectIOSProject } from "./inspect.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { asString, buildPbxParentIndex, isRecord, type PbxObject, type PbxObjects } from "./pbx.ts"; import { parseIOSPlist } from "./plist.ts"; import type { IOSAppTarget, IOSDiagnostic, IOSProjectInspectionResult } from "./types.ts"; @@ -1037,7 +1037,9 @@ export async function validatePreparedIOSAssociatedDomain( } const inspection = await inspectIOSProject(prepared.plan.root, { target: prepared.plan.targetId, + exhaustiveContainerDiscovery: true, }); + if (hasIncompleteIOSContainerDiscovery(inspection)) return false; const target = selectedTarget(inspection, prepared.plan.projectPath, prepared.plan.targetId); if (!target) return false; if ( diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 9f3d5c66e..42064d21c 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -754,6 +754,14 @@ struct MyApp: App { const root = await fixture(); const deepRoot = join(root, "a", "b", "c", "d"); await createIOSFixture(deepRoot, { clerkSDK: false, includeKey: false }); + const deepProjectPath = join(deepRoot, "MyApp.xcodeproj", "project.pbxproj"); + await writeFile( + deepProjectPath, + (await readFile(deepProjectPath, "utf8")).replaceAll( + IOS_FIXTURE_IDS.appTarget, + IOS_FIXTURE_IDS.secondTarget, + ), + ); await updateProject(deepRoot, (objects) => { objects[IOS_FIXTURE_IDS.appFile]!.path = "../../../../../MyApp/MyAppApp.swift"; }); diff --git a/packages/cli-core/src/commands/init/ios/direct-config.ts b/packages/cli-core/src/commands/init/ios/direct-config.ts index 9e5527244..e4cd53e5a 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.ts @@ -9,7 +9,11 @@ import { type IOSExistingFileMutation, type IOSFileMutationBoundary, } from "./file-transaction.ts"; -import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; +import { + hasIncompleteIOSContainerDiscovery, + inspectIOSProject, + inspectIOSSourceMembership, +} from "./inspect.ts"; import { inspectSwiftUIAppRoot, inspectSwiftUIAppRootWithStatus, @@ -1083,7 +1087,19 @@ async function prepareDirectConfig( ); } const projectPath = relativeIOSPath(root, absoluteProjectPath); - const inspection = await inspectIOSProject(root, { target: options.targetId }); + const inspection = await inspectIOSProject(root, { + target: options.targetId, + exhaustiveContainerDiscovery: true, + }); + if (hasIncompleteIOSContainerDiscovery(inspection)) { + return blocked( + options, + root, + projectPath, + "incomplete-source-membership", + "Complete local Xcode container discovery could not be proven.", + ); + } if ( inspection.selection.state !== "selected" || inspection.selection.targetId !== options.targetId || diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts index 5387b09d1..e2498bc16 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.test.ts @@ -392,10 +392,12 @@ describe("missing iOS entitlements build settings", () => { const secondaryRoot = join(root, "a", "b", "c", "d"); await createIOSFixture(secondaryRoot, { includeKey: false }); const secondaryProjectPath = join(secondaryRoot, "MyApp.xcodeproj", "project.pbxproj"); - const secondaryProject = (await readFile(secondaryProjectPath, "utf8")).replaceAll( - "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", - "CODE_SIGN_ENTITLEMENTS = ../../../../MyApp/MyApp.entitlements;", - ); + const secondaryProject = (await readFile(secondaryProjectPath, "utf8")) + .replaceAll(IOS_FIXTURE_IDS.appTarget, IOS_FIXTURE_IDS.secondTarget) + .replaceAll( + "CODE_SIGN_ENTITLEMENTS = MyApp/MyApp.entitlements;", + "CODE_SIGN_ENTITLEMENTS = ../../../../MyApp/MyApp.entitlements;", + ); await writeFile(secondaryProjectPath, secondaryProject); expect(blockerCodes(await planIOSMissingEntitlementsSettings(options(root)))).toContain( diff --git a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts index a00adc3b4..37472eff3 100644 --- a/packages/cli-core/src/commands/init/ios/entitlements-settings.ts +++ b/packages/cli-core/src/commands/init/ios/entitlements-settings.ts @@ -895,7 +895,10 @@ async function inspectSelectedTarget( projectPath: string, targetId: string, ): Promise { - const inspection = await inspectIOSProject(root, { target: targetId }); + const inspection = await inspectIOSProject(root, { + target: targetId, + exhaustiveContainerDiscovery: true, + }); if ( inspection.selection.state !== "selected" || inspection.selection.targetId !== targetId || 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 1a9bc588e..f87ae09d2 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.test.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.test.ts @@ -1368,9 +1368,16 @@ let package = Package( await Bun.write(join(workspace, "contents.xcworkspacedata"), "not an Xcode workspace\n"); const result = await inspectWorkspace(root, workspace); + const inspection = await inspectIOSProject(root, { exhaustiveContainerDiscovery: true }); + const memberships = await inspectIOSSourceMembership(root); expect(result.complete).toBe(false); expect(result.localProjectPaths).toEqual([]); + expect(inspection.diagnostics).toContainEqual( + expect.objectContaining({ code: "xcode.incomplete-container-discovery" }), + ); + expect(memberships.length).toBeGreaterThan(0); + expect(memberships.every((membership) => !membership.complete)).toBe(true); }); test.each(["absolute", "parent-relative", "symlink-escape"] as const)( diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index aeb510d77..e19730fbc 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -1289,17 +1289,32 @@ export async function inspectIOSProject( const discovered = await discoverIOSContainers(invocationPath, { exhaustive: options.exhaustiveContainerDiscovery === true, }); + let discoveryComplete = discovered.complete; const projectPaths = new Set(discovered.projectPaths); const workspaces = []; for (const workspacePath of discovered.workspacePaths) { const workspace = await inspectWorkspace(root, workspacePath); + discoveryComplete &&= workspace.complete; workspaces.push(workspace.inspection); for (const projectPath of workspace.localProjectPaths) projectPaths.add(projectPath); } const referencedProjects = await discoverReferencedIOSProjects(root, projectPaths); for (const projectPath of referencedProjects.projectPaths) projectPaths.add(projectPath); + discoveryComplete &&= referencedProjects.complete; + + if (options.exhaustiveContainerDiscovery === true && !discoveryComplete) { + diagnostics.push({ + code: "xcode.incomplete-container-discovery", + severity: "warning", + message: + "Xcode container discovery was incomplete, so Clerk could not prove that all local application targets were inspected.", + remedy: + "Run the command from the intended project's directory, make nested project directories readable, or reduce excessive project nesting or count.", + evidence: [{ path: "." }], + }); + } if (projectPaths.size === 0) { diagnostics.push({ @@ -1323,7 +1338,7 @@ export async function inspectIOSProject( sourceMemberships.push(...(parsed.sourceMemberships ?? [])); diagnostics.push(...parsed.diagnostics); } - if (options.exhaustiveContainerDiscovery === true && !discovered.complete) { + if (options.exhaustiveContainerDiscovery === true && !discoveryComplete) { for (const membership of sourceMemberships) membership.complete = false; } if (!referencedProjects.complete) { @@ -1385,6 +1400,14 @@ export async function inspectIOSProject( return result; } +export function hasIncompleteIOSContainerDiscovery( + inspection: IOSProjectInspectionResult, +): boolean { + return inspection.diagnostics.some( + (diagnostic) => diagnostic.code === "xcode.incomplete-container-discovery", + ); +} + /** * Returns the exact source-membership result used by the iOS semantic * inspector without adding source paths to the serializable inspection JSON. diff --git a/packages/cli-core/src/commands/init/ios/install-sdk.ts b/packages/cli-core/src/commands/init/ios/install-sdk.ts index 37f05ae5f..3a2031332 100644 --- a/packages/cli-core/src/commands/init/ios/install-sdk.ts +++ b/packages/cli-core/src/commands/init/ios/install-sdk.ts @@ -3,7 +3,7 @@ import { isDeepStrictEqual } from "node:util"; import { dirname, isAbsolute, resolve } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import semver from "semver"; -import { inspectIOSProject } from "./inspect.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { localClerkIOSPackageIsStructurallyValid } from "./local-package.ts"; import { @@ -55,6 +55,7 @@ export type IOSSDKInstallBlockerCode = | "malformed-project" | "target-not-found" | "ambiguous-target" + | "incomplete-container-discovery" | "ambiguous-package" | "duplicate-package" | "unattributed-product" @@ -855,9 +856,22 @@ async function prepareInstall(options: IOSSDKInstallOptions): Promise { - const inspection = await inspectIOSProject(snapshot.root, { target: snapshot.targetId }); + const inspection = await inspectIOSProject(snapshot.root, { + target: snapshot.targetId, + exhaustiveContainerDiscovery: true, + }); + if (hasIncompleteIOSContainerDiscovery(inspection)) { + return { status: "blocked", reason: "target-not-selected" }; + } return buildIOSNativeReadinessAudit(inspection).target; }; diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts index 7bfac7f3b..4abff2c42 100644 --- a/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts @@ -374,7 +374,12 @@ struct DecoyApp: App { }); const projectPath = join(deepRoot, "MyApp.xcodeproj", "project.pbxproj"); - const project = parsePbxProject(await readFile(projectPath, "utf8")); + const project = parsePbxProject( + (await readFile(projectPath, "utf8")).replaceAll( + IOS_FIXTURE_IDS.appTarget, + IOS_FIXTURE_IDS.secondTarget, + ), + ); const objects = (project as unknown as { objects: PbxObjects }).objects; (objects[IOS_FIXTURE_IDS.appGroup]!.children as string[]).push(CONTENT_FILE_ID); (objects[IOS_FIXTURE_IDS.sourcesPhase]!.files as string[]).push(CONTENT_BUILD_FILE_ID); diff --git a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts index febb8bf0b..a4e9c74cc 100644 --- a/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts @@ -9,7 +9,11 @@ import { type IOSFileMutationBoundary, } from "./file-transaction.ts"; import { hasExactIOSSwiftUIAppContentRoot } from "./direct-config.ts"; -import { inspectIOSProject, inspectIOSSourceMembership } from "./inspect.ts"; +import { + hasIncompleteIOSContainerDiscovery, + inspectIOSProject, + inspectIOSSourceMembership, +} from "./inspect.ts"; import type { IOSBuildConfiguration } from "./types.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -429,7 +433,19 @@ async function preparePlan(options: IOSPrebuiltAuthPlanOptions): Promise Date: Fri, 28 Aug 2026 10:34:26 -0400 Subject: [PATCH 26/55] fix(init): revalidate satisfied iOS native plans --- .../commands/init/ios/native-remote.test.ts | 80 ++++++++++++++++++- .../src/commands/init/ios/native-remote.ts | 10 ++- 2 files changed, 85 insertions(+), 5 deletions(-) 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 2615f872d..f0bfe64d9 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 @@ -310,6 +310,7 @@ describe("Clerk Native Application remote setup", () => { nativeReads: [nativeSettings(true)], registrationReads: [[exactRegistration]], }); + let inspections = 0; const result = await prepareIOSNativeRemoteSetup(prepareOptions({ yes: false }), { api, @@ -336,7 +337,11 @@ describe("Clerk Native Application remote setup", () => { actions: [], blockers: [], }); - await applyRemoteSetup(result, api); + await applyRemoteSetup(result, api, async (snapshot) => { + inspections += 1; + return approvedTargetReader(snapshot); + }); + expect(inspections).toBe(1); expect(calls).toEqual([ "GET native settings", "GET iOS registrations", @@ -348,6 +353,79 @@ describe("Clerk Native Application remote setup", () => { expect(captured.err).toContain("already configured"); }); + test.each([ + { + name: "Bundle ID", + current: selectedTarget({ bundleIdentifier: "com.example.Changed" }), + }, + { + name: "App ID Prefix", + current: selectedTarget({ appIdPrefix: EXPLICIT_PREFIX }), + }, + ])("fails a satisfied plan before remote access when its $name changes", async ({ current }) => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[registration()]], + }); + let inspections = 0; + const retryOperations: string[] = []; + const retryStore: IOSNativeRegistrationRetryStore = { + async getOrCreate() { + retryOperations.push("getOrCreate"); + return "unexpected"; + }, + async peek() { + retryOperations.push("peek"); + return undefined; + }, + async clear() { + retryOperations.push("clear"); + return true; + }, + }; + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "satisfied" }), + api, + async () => { + inspections += 1; + return current; + }, + retryStore, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining("Xcode target identity changed"), + }); + + expect(inspections).toBe(1); + expect(retryOperations).toEqual([]); + expect(calls).toEqual([]); + }); + + test("preserves recheck failure semantics for a satisfied plan", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[registration()]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "satisfied" }), + api, + async () => { + throw new Error("xcconfig unreadable"); + }, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining("Xcode target identity could not be rechecked"), + }); + + expect(calls).toEqual([]); + }); + test.each([ { name: "Native API was disabled", 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 a629668d5..0ea668953 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -653,7 +653,7 @@ function localTargetStillMatchesApprovedIdentity( ); } -async function revalidateLocalTargetBeforeRemoteMutation( +async function revalidateLocalTargetBeforeRemoteAccess( plan: IOSNativeRemotePlan, targetReader: IOSNativeRemoteTargetReader, ): Promise { @@ -732,9 +732,11 @@ export async function applyIOSNativeRemoteSetup( ); } - if (plan.registration === "required" || plan.nativeApi === "required") { - await revalidateLocalTargetBeforeRemoteMutation(plan, targetReader); - } + // The approved local identity is the basis for every remote audit, even + // when the preview found no work to perform. Revalidate before reading + // retry state or Clerk state so a satisfied plan cannot report success for + // a Bundle ID or App ID Prefix that changed after approval. + await revalidateLocalTargetBeforeRemoteAccess(plan, targetReader); const nativeAPIIdempotencyKey = `clerk-init-ios-native-api-${randomUUID()}`; const retryIdentity = registrationRetryIdentity(plan); From 52c4b167f81679c6029e95e0dd948ca584a96872 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 11:25:41 -0400 Subject: [PATCH 27/55] refactor(init): keep LocalSecrets integration read-only --- .changeset/calm-apples-inspect.md | 2 +- packages/cli-core/src/commands/init/README.md | 4 +- .../src/commands/init/frameworks/ios.test.ts | 3 +- .../src/commands/init/frameworks/ios.ts | 37 +-- .../src/commands/init/index-ios.test.ts | 56 +--- .../cli-core/src/commands/init/index.test.ts | 2 - packages/cli-core/src/commands/init/index.ts | 46 ++-- .../init/ios/apply-cli-runtime.test.ts | 102 +------ .../src/commands/init/ios/apply-cli.test.ts | 23 +- .../cli-core/src/commands/init/ios/apply.ts | 259 +++--------------- .../commands/init/ios/compiled-cli.test.ts | 7 +- .../src/commands/init/ios/dry-run.test.ts | 7 +- .../src/commands/init/ios/plan.test.ts | 35 +-- .../cli-core/src/commands/init/ios/plan.ts | 56 +--- .../cli-core/src/test/lib/init-harness.ts | 1 - 15 files changed, 121 insertions(+), 519 deletions(-) diff --git a/.changeset/calm-apples-inspect.md b/.changeset/calm-apples-inspect.md index b5a3804fd..f3b84eef1 100644 --- a/.changeset/calm-apples-inspect.md +++ b/.changeset/calm-apples-inspect.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing proven LocalSecrets and ProcessInfo integrations remain compatibility paths and source-proven custom flows remain core-only. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the linked development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and `AuthView`, including redirect, continuation, pending-session-task, preview, and error-handling lifecycle wiring; established or partially integrated application UI is never rewritten. +Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing proven LocalSecrets and ProcessInfo integrations remain read-only compatibility paths and source-proven custom flows remain core-only. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the linked development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and `AuthView`, including redirect, continuation, pending-session-task, preview, and error-handling lifecycle wiring; established or partially integrated application UI is never rewritten. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index bf2614cb4..6628ac27f 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -66,7 +66,7 @@ For a native iOS project, normal `clerk init` re-runs the semantic inspection, b For a safely inspectable fresh SwiftUI target, the same command selects or creates a Clerk application, fetches only its development publishable key, adds `import ClerkKit`, configures Clerk directly in the single shipping `@main` initializer, and adds `.environment(Clerk.shared)` to the proven `WindowGroup` root. The key is public client configuration and is written directly to Swift source, matching the iOS Quickstart. It remains in memory until commit and is never printed, returned in JSON, sent to telemetry, or written through an intermediate `.env` or plist. Existing inline keys are compared with the selected application's key and never replaced on a mismatch. -Existing proven LocalSecrets and ProcessInfo/Run-scheme integrations remain compatibility paths and are never migrated automatically. A proven LocalSecrets placeholder may still receive the linked development key through its target-owned plist; an existing valid value is verified first. Different valid keys, tracked/shared/malformed plists, custom configuration expressions, generated projects, ambiguous targets or startup structures, unsafe paths, and stale inputs are preserved and require review. +Existing proven LocalSecrets and ProcessInfo/Run-scheme integrations remain compatibility paths and are never migrated automatically. An existing valid LocalSecrets value is recognized and verified against the linked application, but `clerk init` never writes the plist or its ignore rules. Missing, different, tracked/shared/malformed, or custom runtime-key sources are preserved and require manual review. The CLI previews every planned local path and asks once before writing. Human users can pass `--yes` to skip that confirmation. Agent/non-TTY mode must pass `--yes` explicitly for iOS mutations; agent mode never implies consent here. A planned file with existing Git changes is refused unless `--allow-dirty` is also explicit, and `--yes` does not imply `--allow-dirty`. @@ -121,7 +121,7 @@ The normal setup flow is: - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled. `-y` does not bypass this — it only suppresses y/n confirmation prompts, not authentication - `--template` and `--fresh` are rejected with a usage error whenever the resolved strategy above isn't keyless — see [Application templates](#application-templates) and [Keyless breadcrumb](#keyless-breadcrumb) 4. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links or creates/selects the project application via `clerk link` -5. **Eligible native iOS only**: resolves the newly linked application by its exact ID, fetches only its public development key, and audits Native API, iOS registration, the selected prebuilt AuthView environment, and any explicitly requested native Apple connection before writing. It then prepares the approved PBX, Swift, and entitlements candidates again. A fresh target commits the eligible files through one rollback-aware transaction and re-inspects the result. This can include creating and attaching one entitlements file for an exclusive filesystem-synchronized target root, replacing only an explicitly selected pristine starter screen with the documented `UserButton` and `AuthView` sheet, and adding the exact native Apple entitlement when required by either explicit Apple setup or an already-enabled Apple button. A proven existing LocalSecrets path uses its compatibility transaction. The key is never resolved through mutable current-directory profile state, printed, or copied through dotenv. Additive remote Native Application changes run after the local commit; native Apple is enabled last and final state is re-read +5. **Eligible native iOS only**: resolves the newly linked application by its exact ID, fetches only its public development key, and audits Native API, iOS registration, the selected prebuilt AuthView environment, and any explicitly requested native Apple connection before writing. It then prepares the approved PBX, Swift, and entitlements candidates again. A fresh target commits the eligible files through one rollback-aware transaction and re-inspects the result. This can include creating and attaching one entitlements file for an exclusive filesystem-synchronized target root, replacing only an explicitly selected pristine starter screen with the documented `UserButton` and `AuthView` sheet, and adding the exact native Apple entitlement when required by either explicit Apple setup or an already-enabled Apple button. A proven existing LocalSecrets path is checked read-only and never rewritten. The key is never resolved through mutable current-directory profile state, printed, or copied through dotenv. Additive remote Native Application changes run after the local commit; native Apple is enabled last and final state is re-read 6. Displays detected framework and variant 7. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance 8. Installs the appropriate Clerk SDK (skips if already present) diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index 60a0fa576..919f5643b 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -155,7 +155,8 @@ test("keeps a proven LocalSecrets loader as a compatibility path", async () => { const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); - expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist loader"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("will not replace it"))).toBe(true); expect( plan.postInstructions.some((i) => i.includes("single shipping `@main` App initializer")), ).toBe(false); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 581bb5f72..071f68a65 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -1,10 +1,10 @@ import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; import { planIOSDirectConfig } from "../ios/direct-config.ts"; import { inspectIOSProject } from "../ios/inspect.ts"; -import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "../ios/plan.ts"; +import { buildIOSSetupPlan } from "../ios/plan.ts"; import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "../ios/products.ts"; -import { planIOSRuntimeKey } from "../ios/runtime-key.ts"; import { planIOSAssociatedDomain } from "../ios/associated-domain.ts"; +import { planIOSRuntimeKeyVerification } from "../ios/runtime-key.ts"; /** * iOS (Swift) support for `clerk init`. @@ -15,7 +15,7 @@ import { planIOSAssociatedDomain } from "../ios/associated-domain.ts"; * product linkage before this scaffolder runs. For a safely inspectable fresh * SwiftUI target, init configures the linked development publishable key * directly in the shipping @main App source. Existing LocalSecrets and - * ProcessInfo integrations remain supported compatibility paths. + * ProcessInfo integrations remain read-only compatibility paths. * * Docs: https://clerk.com/docs/ios/getting-started/quickstart */ @@ -56,22 +56,6 @@ export const ios: FrameworkScaffold = { targetId: selection.targetId, }) : undefined; - const preliminaryPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); - const preliminaryConfigureStep = preliminaryPlan.steps.find( - (step) => step.id === "configure-publishable-key", - ); - const needsRuntimeKeyHandoff = - selection.state === "selected" && - target != null && - preliminaryConfigureStep?.status === "required" && - hasIOSRuntimeKeyHandoffShape(inspection, target); - const runtimeKeyPlan = needsRuntimeKeyHandoff - ? await planIOSRuntimeKey({ - root: ctx.cwd, - projectPath: selection.projectPath, - targetId: selection.targetId, - }) - : undefined; const associatedDomainPlan = selection.state === "selected" ? await planIOSAssociatedDomain({ @@ -79,15 +63,20 @@ export const ios: FrameworkScaffold = { projectPath: selection.projectPath, targetId: selection.targetId, deferToPublishableKey: directConfigPlan?.status === "ready", - allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + allowMissingEntitlementsCreation: true, + }) + : undefined; + const runtimeKeyVerificationPlan = + selection.state === "selected" && hasLocalSecretsConfigure + ? await planIOSRuntimeKeyVerification({ + root: ctx.cwd, + projectPath: selection.projectPath, + targetId: selection.targetId, }) : undefined; const setupPlan = buildIOSSetupPlan(inspection, { - runtimeKeyPlan: runtimeKeyPlan && { - status: runtimeKeyPlan.status, - blockers: runtimeKeyPlan.blockers, - }, directConfigPlan, + runtimeKeyVerificationPlan, associatedDomainPlan, }); const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index 1fa829272..e856c8a65 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -801,7 +801,7 @@ describe("init iOS", () => { expect(`${captured.out}\n${captured.err}`).not.toContain(VALID_DEVELOPMENT_KEY); }); - test("wires a linked development key directly when iOS preflight proves a runtime sink", async () => { + test("passes the linked development key to the approved iOS setup", async () => { setup({ email: "test@test.com" }); const iosCtx = { ...FAKE_CTX, @@ -817,30 +817,14 @@ describe("init iOS", () => { ecosystem: "swift" as const, }, }; - const runtimeKeyPlan = { - schemaVersion: 1 as const, - kind: "clerk-ios-runtime-key" as const, - status: "ready" as const, - root: iosCtx.cwd, - projectPath: "MyApp.xcodeproj", - targetId: "TARGET", - localSecretsPath: "MyApp/LocalSecrets.plist", - gitignorePath: ".gitignore", - gitignoreRule: "/MyApp/LocalSecrets.plist", - expectedLocalSecretsHash: "source-hash", - expectedGitignoreHash: "ignore-hash", - changesGitignore: true, - actions: ["Set the redacted publishable key."], - blockers: [], - }; spyOn(context, "gatherContext").mockResolvedValue(iosCtx); spyOn(config, "resolveProfile") .mockResolvedValueOnce(undefined) .mockResolvedValueOnce(undefined) .mockResolvedValue({ profile: { appId: "app_test" } } as never); const setupResult = iosSetupResult({ - runtimeKeyPlan, requiresLinkedApp: true, + requiresDevelopmentKey: true, }); const preflightSpy = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); const linkSpy = spyOn(linkMod, "link").mockResolvedValue(undefined); @@ -1147,28 +1131,12 @@ describe("init iOS", () => { ecosystem: "swift" as const, }, }; - const runtimeKeyPlan = { - schemaVersion: 1 as const, - kind: "clerk-ios-runtime-key" as const, - status: "ready" as const, - root: iosCtx.cwd, - projectPath: "MyApp.xcodeproj", - targetId: "TARGET", - localSecretsPath: "MyApp/LocalSecrets.plist", - gitignorePath: ".gitignore", - gitignoreRule: "/MyApp/LocalSecrets.plist", - expectedLocalSecretsHash: "source-hash", - expectedGitignoreHash: "ignore-hash", - changesGitignore: true, - actions: ["Set the redacted publishable key."], - blockers: [], - }; spyOn(context, "gatherContext").mockResolvedValue(iosCtx); spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_linked" }, } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( - iosSetupResult({ runtimeKeyPlan, requiresLinkedApp: true }), + iosSetupResult({ requiresLinkedApp: true, requiresDevelopmentKey: true }), ); spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ applicationId: "app_changed", @@ -1369,22 +1337,6 @@ describe("init iOS", () => { const { captured } = setup({ email: "test@test.com" }); const iosCtx = nativeIOSContext(); const key = `pk_test_${Buffer.from("frozen.clerk.example$").toString("base64")}`; - const runtimeKeyPlan = { - schemaVersion: 1 as const, - kind: "clerk-ios-runtime-key" as const, - status: "ready" as const, - root: iosCtx.cwd, - projectPath: "MyApp.xcodeproj", - targetId: "TARGET", - localSecretsPath: "MyApp/LocalSecrets.plist", - gitignorePath: ".gitignore", - gitignoreRule: "/MyApp/LocalSecrets.plist", - expectedLocalSecretsHash: "source-hash", - expectedGitignoreHash: "ignore-hash", - changesGitignore: true, - actions: ["Set the redacted publishable key."], - blockers: [], - }; spyOn(context, "gatherContext").mockResolvedValue(iosCtx); spyOn(config, "resolveProfile") .mockResolvedValueOnce(undefined) @@ -1399,8 +1351,8 @@ describe("init iOS", () => { publishableKey: key, }); const setupResult = iosSetupResult({ - runtimeKeyPlan, requiresLinkedApp: true, + requiresDevelopmentKey: true, }); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 0311e3230..2a2c3607c 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -21,7 +21,6 @@ import { nextStepsMod, mockExistingProject, mockMiddlewareScaffold, - iosApplyMod, iosDevelopmentKeyMod, } from "../../test/lib/init-harness.ts"; import * as telemetryMod from "../../lib/telemetry.ts"; @@ -95,7 +94,6 @@ describe("init", () => { expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); expect(pullMod.pull).not.toHaveBeenCalled(); - expect(iosApplyMod.applyIOSRuntimeKeySetup).not.toHaveBeenCalled(); }); test("agent mode runs existing-project flow without prompts", async () => { diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 87ff7c64a..2e5fb7035 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -66,14 +66,14 @@ import { import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; import { inspectIOSProject } from "./ios/inspect.ts"; -import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "./ios/plan.ts"; +import { buildIOSSetupPlan } from "./ios/plan.ts"; import { planIOSDirectConfig } from "./ios/direct-config.ts"; import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./ios/products.ts"; -import { planIOSRuntimeKey } from "./ios/runtime-key.ts"; import { planIOSAssociatedDomain } from "./ios/associated-domain.ts"; import { planIOSAppleEntitlement } from "./ios/apple-entitlement.ts"; import { planIOSPrebuiltAuth } from "./ios/prebuilt-auth.ts"; import { planIOSSDKInstall } from "./ios/install-sdk.ts"; +import { planIOSRuntimeKeyVerification } from "./ios/runtime-key.ts"; import { resolveIOSDevelopmentPublicKey } from "./ios/development-key.ts"; import { createIOSDryRunOutput, formatIOSSetupPlan } from "./ios/output.ts"; import { @@ -265,28 +265,8 @@ export async function init(options: InitOptions = {}) { targetId: dryRunSelection.targetId, }) : undefined; - const preliminaryPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); - const configureStep = preliminaryPlan.steps.find( - (step) => step.id === "configure-publishable-key", - ); - const needsRuntimeKeyHandoff = - dryRunSelection.state === "selected" && - selectedTarget != null && - configureStep?.status === "required" && - hasIOSRuntimeKeyHandoffShape(inspection, selectedTarget); - const runtimeKeyPlan = needsRuntimeKeyHandoff - ? await planIOSRuntimeKey({ - root: ctx.cwd, - projectPath: dryRunSelection.projectPath, - targetId: dryRunSelection.targetId, - }) - : undefined; const prebuiltRuntimeBlockers = prebuiltAuthActive - ? planIOSPrebuiltAuthRuntimeBlockers( - inspection, - directConfigPlan, - runtimeKeyPlan?.status === "ready" ? runtimeKeyPlan : undefined, - ) + ? planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan) : []; const prebuiltAuthPlan = inspectedPrebuiltAuthPlan && prebuiltRuntimeBlockers.length > 0 @@ -310,7 +290,18 @@ export async function init(options: InitOptions = {}) { projectPath: dryRunSelection.projectPath, targetId: dryRunSelection.targetId, deferToPublishableKey: directConfigPlan?.status === "ready", - allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + allowMissingEntitlementsCreation: true, + }) + : undefined; + const runtimeKeyVerificationPlan = + dryRunSelection.state === "selected" && + selectedTarget?.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "local-secrets-loader", + ) + ? await planIOSRuntimeKeyVerification({ + root: ctx.cwd, + projectPath: dryRunSelection.projectPath, + targetId: dryRunSelection.targetId, }) : undefined; const hasLocalAppleIntent = selectedTarget?.configurations.some( @@ -325,7 +316,7 @@ export async function init(options: InitOptions = {}) { root: ctx.cwd, projectPath: dryRunSelection.projectPath, targetId: dryRunSelection.targetId, - allowMissingEntitlementsCreation: runtimeKeyPlan?.status !== "ready", + allowMissingEntitlementsCreation: true, }) : undefined; const strictSDKInstallPlan = @@ -348,11 +339,8 @@ export async function init(options: InitOptions = {}) { : undefined; const plan = buildIOSSetupPlan(inspection, { sdkInstallPlan, - runtimeKeyPlan: runtimeKeyPlan && { - status: runtimeKeyPlan.status, - blockers: runtimeKeyPlan.blockers, - }, directConfigPlan, + runtimeKeyVerificationPlan, associatedDomainPlan, appleEntitlementPlan, prebuiltAuthPlan, diff --git a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts index e39591659..880ec94cc 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; import { inspectIOSProject } from "./inspect.ts"; -import { applyIOSLocalSetup, applyIOSPlannedLocalSetup, applyIOSRuntimeKeySetup } from "./apply.ts"; +import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; import { convertIOSFixtureToSynchronizedMissingEntitlements, createIOSFixture, @@ -30,69 +30,6 @@ setDefaultTimeout(15_000); describe("clerk init iOS SDK runtime apply", () => { const captured = useCaptureLog(); - test("does not combine LocalSecrets mutation with new entitlements creation", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-missing-entitlements-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); - await convertIOSFixtureToSynchronizedMissingEntitlements(root); - await Bun.write( - join(root, "MyApp", "LocalSecrets.plist"), - 'CLERK_PUBLISHABLE_KEYreplace-me', - ); - const before = await treeDigest(root); - - const setup = await applyIOSLocalSetup({ - root, - target: "MyApp", - yes: true, - agent: false, - allowDirty: false, - }); - - expect(setup.runtimeKeyPlan).toMatchObject({ status: "ready" }); - expect(setup.associatedDomainPlan).toBeUndefined(); - expect(await treeDigest(root)).toEqual(before); - }); - - test("hands off a runtime key without rewriting a fully linked unattributed package graph", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-unattributed-handoff-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); - const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); - const unattributedProject = (await Bun.file(projectFile).text()) - .replace( - `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKit;`, - "productName = ClerkKit;", - ) - .replace( - `package = ${IOS_FIXTURE_IDS.clerkPackage}; productName = ClerkKitUI;`, - "productName = ClerkKitUI;", - ); - await Bun.write(projectFile, unattributedProject); - await Bun.write( - join(root, "MyApp", "LocalSecrets.plist"), - 'CLERK_PUBLISHABLE_KEYreplace-me', - ); - const beforeProjectBytes = await Bun.file(projectFile).bytes(); - - const setup = await applyIOSLocalSetup({ - root, - target: "MyApp", - yes: true, - agent: false, - allowDirty: false, - }); - - expect(setup.runtimeKeyPlan).toMatchObject({ status: "ready" }); - expect(await Bun.file(projectFile).bytes()).toEqual(beforeProjectBytes); - - const key = developmentPublishableKey("unattributed.clerk.example"); - await applyIOSRuntimeKeySetup(setup.runtimeKeyPlan!, key); - - expect(await Bun.file(projectFile).bytes()).toEqual(beforeProjectBytes); - expect(await Bun.file(join(root, "MyApp", "LocalSecrets.plist")).text()).toContain(key); - }); - test("does not bypass AuthView compatibility proof for unattributed Clerk products", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-unattributed-auth-view-")); temporaryDirectories.push(root); @@ -668,43 +605,6 @@ struct MyApp: App { expect(appliedEntitlements).toContain("webcredentials:dirty-entitlements.clerk.example"); }); - test("dirty-checks .gitignore when crash-safe key staging needs a guard", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-dirty-ignore-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); - await Bun.write( - join(root, "MyApp", "LocalSecrets.plist"), - 'CLERK_PUBLISHABLE_KEYreplace-me', - ); - await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n"); - await runCommand(root, ["git", "init"]); - await runCommand(root, ["git", "add", "."]); - await runCommand(root, [ - "git", - "-c", - "user.name=Clerk CLI Tests", - "-c", - "user.email=cli-tests@clerk.invalid", - "commit", - "-m", - "fixture", - ]); - await Bun.write(join(root, ".gitignore"), "/MyApp/LocalSecrets.plist\n# local change\n"); - const before = await treeDigest(root); - - await expect( - applyIOSLocalSetup({ - root, - target: "MyApp", - yes: true, - agent: false, - allowDirty: false, - }), - ).rejects.toThrow(".gitignore already has local changes"); - - expect(await treeDigest(root)).toEqual(before); - }); - test("fails closed when Git cannot determine the selected project file status", async () => { const root = await createUnconfiguredFixture(); const configDir = await createIsolatedCLIState(); diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index 88fb7a823..f79ab2daf 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -869,7 +869,7 @@ import SwiftUI } }); - test("pre-authorizes a proven runtime sink without fetching or writing its key", async () => { + test("preserves a LocalSecrets runtime sink that has no valid key", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-preflight-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -879,19 +879,16 @@ import SwiftUI ); const before = await treeDigest(root); - const result = await applyIOSLocalSetup({ - root, - target: "MyApp", - yes: true, - agent: false, - allowDirty: false, - }); + await expect( + applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }), + ).rejects.toThrow("will not change that compatibility file"); - expect(result.runtimeKeyPlan).toMatchObject({ - status: "ready", - localSecretsPath: "MyApp/LocalSecrets.plist", - }); expect(await treeDigest(root)).toEqual(before); - expect(JSON.stringify(result)).not.toContain("pk_live_"); }); }); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index f72576d0f..6652ecc47 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -19,7 +19,7 @@ import { type IOSSDKInstallPlan, type PreparedIOSSDKInstallMutation, } from "./install-sdk.ts"; -import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "./plan.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./products.ts"; import { planIOSDirectConfig, @@ -29,17 +29,13 @@ import { type IOSDirectConfigPreparedMutation, } from "./direct-config.ts"; import { - applyIOSExistingFileTransaction, applyIOSFileTransaction, type IOSExistingFileMutation, type IOSFileMutation, } from "./file-transaction.ts"; import { - applyIOSRuntimeKey, - planIOSRuntimeKey, planIOSRuntimeKeyVerification, verifyIOSRuntimeKey, - type IOSRuntimeKeyPlan, type IOSRuntimeKeyVerificationPlan, } from "./runtime-key.ts"; import { @@ -123,8 +119,6 @@ export interface IOSLocalSetupResult { sdkInstallPlan?: IOSSDKInstallPlan; /** Fresh/default direct Swift configuration or existing inline verification. */ directConfigPlan?: IOSDirectConfigPlan; - /** Pre-authorized, redacted plan whose key is resolved only after app linking. */ - runtimeKeyPlan?: IOSRuntimeKeyPlan; /** Read-only proof for comparing an already configured sink after app linking. */ runtimeKeyVerificationPlan?: IOSRuntimeKeyVerificationPlan; /** Existing entitlements files that can receive the exact linked webcredentials host. */ @@ -257,26 +251,19 @@ function blockerList(blockers: Array<{ message: string }>): string { export function planIOSPrebuiltAuthRuntimeBlockers( inspection: Awaited>, directConfigPlan: IOSDirectConfigPlan | undefined, - runtimeKeyPlan: IOSRuntimeKeyPlan | undefined, ): string[] { - const setupPlan = buildIOSSetupPlan(inspection, { directConfigPlan, runtimeKeyPlan }); + const setupPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); const directConfigurationReady = directConfigPlan?.status === "ready" && configureStep?.automatable === true; - const runtimeKeyConfigurationReady = - runtimeKeyPlan?.status === "ready" && configureStep?.automatable === true; const directEnvironmentReady = directConfigPlan?.status === "ready" && (directConfigPlan.changes?.environment === "insert" || directConfigPlan.changes?.environment === "satisfied"); const blockers: string[] = []; - if ( - configureStep?.status !== "satisfied" && - !directConfigurationReady && - !runtimeKeyConfigurationReady - ) { + if (configureStep?.status !== "satisfied" && !directConfigurationReady) { blockers.push( "Clerk.configure(publishableKey:) is neither proven at runtime nor included in the safe direct-configuration plan.", ); @@ -292,7 +279,6 @@ export function planIOSPrebuiltAuthRuntimeBlockers( async function validatePrebuiltAuthRuntimePostcondition( setup: IOSLocalSetupResult, - allowPendingRuntimeKey: boolean, ): Promise { if (!setup.prebuiltAuthActive) return true; if (setup.nativeReadiness.target.status !== "selected") return false; @@ -309,18 +295,10 @@ async function validatePrebuiltAuthRuntimePostcondition( ) { return false; } - const setupPlan = buildIOSSetupPlan(inspection, { - runtimeKeyPlan: allowPendingRuntimeKey ? setup.runtimeKeyPlan : undefined, - }); + const setupPlan = buildIOSSetupPlan(inspection); const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); - const configurationReady = - configureStep?.status === "satisfied" || - (allowPendingRuntimeKey && - setup.runtimeKeyPlan?.status === "ready" && - configureStep?.automatable === true); - - return configurationReady && environmentStep?.status === "satisfied"; + return configureStep?.status === "satisfied" && environmentStep?.status === "satisfied"; } /** @@ -428,35 +406,25 @@ export async function applyIOSLocalSetup( const configureStep = buildIOSSetupPlan(inspection).steps.find( (candidate) => candidate.id === "configure-publishable-key", ); - const needsRuntimeKeyHandoff = - configureStep?.status === "required" && - hasIOSRuntimeKeyHandoffShape(inspection, selectedTarget); - const plannedRuntimeKey = needsRuntimeKeyHandoff - ? await planIOSRuntimeKey({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - }) - : undefined; - const runtimeKeyPlan = plannedRuntimeKey?.status === "ready" ? plannedRuntimeKey : undefined; + const hasLocalSecretsConfigure = selectedTarget.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "local-secrets-loader", + ); const hasSatisfiedLocalRuntimeSink = configureStep?.status === "satisfied" && inspection.localPublishableKey.source != null && selectedTarget.runtimeKeySinks.some( (sink) => sink.path === inspection.localPublishableKey.source, ); - const plannedRuntimeKeyVerification = hasSatisfiedLocalRuntimeSink - ? await planIOSRuntimeKeyVerification({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - }) - : undefined; + const plannedRuntimeKeyVerification = + hasLocalSecretsConfigure || hasSatisfiedLocalRuntimeSink + ? await planIOSRuntimeKeyVerification({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; const runtimeKeyVerificationPlan = plannedRuntimeKeyVerification?.status === "ready" ? plannedRuntimeKeyVerification : undefined; - const hasLocalSecretsConfigure = selectedTarget.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "local-secrets-loader", - ); const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => source.endsWith(".xcscheme"), ); @@ -488,9 +456,7 @@ export async function applyIOSLocalSetup( projectPath: selection.projectPath, targetId: selection.targetId, deferToPublishableKey: directConfigPlan?.status === "ready", - // A LocalSecrets write is a specialized secret transaction that cannot - // yet share rollback ownership with a newly created entitlements file. - allowMissingEntitlementsCreation: runtimeKeyPlan == null, + allowMissingEntitlementsCreation: true, }); // Associated Domains is an independent additive improvement. Unsupported // or ambiguous entitlements must not prevent the already-proven SDK/source @@ -527,9 +493,7 @@ export async function applyIOSLocalSetup( root: options.root, projectPath: selection.projectPath, targetId: selection.targetId, - // New entitlements creation cannot be rolled back through the - // specialized LocalSecrets transaction. - allowMissingEntitlementsCreation: runtimeKeyPlan == null, + allowMissingEntitlementsCreation: true, }) : undefined; // Existing entitlement evidence remains available for a read-only satisfied @@ -558,12 +522,7 @@ export async function applyIOSLocalSetup( if (plannedRuntimeKeyVerification?.status === "blocked") { throw iosSetupError( - `The existing iOS runtime publishable key could not be verified safely. No local files were changed:\n${blockerList(plannedRuntimeKeyVerification.blockers)}`, - ); - } - if (plannedRuntimeKey?.status === "blocked") { - throw iosSetupError( - `The development publishable key could not be wired safely. No local files were changed:\n${blockerList(plannedRuntimeKey.blockers)}`, + `The existing iOS runtime publishable key could not be verified safely. clerk init will not change that compatibility file; repair it manually, then rerun the command. No local files were changed:\n${blockerList(plannedRuntimeKeyVerification.blockers)}`, ); } if (directConfigPlan?.status === "blocked") { @@ -574,8 +533,7 @@ export async function applyIOSLocalSetup( if ( (productDecision === "prebuilt" || prebuiltAuthActive) && selectedTarget.swift.configureCalls.length === 0 && - !directConfigPlan && - !runtimeKeyPlan + !directConfigPlan ) { const reason = hasEnabledSchemeKey ? "an enabled Run-scheme publishable key already indicates a custom runtime configuration" @@ -586,17 +544,13 @@ export async function applyIOSLocalSetup( `The fresh SwiftUI target was not edited because ${reason}. Resolve that setup or configure Clerk directly in the @main initializer, then rerun clerk init. No local files were changed.`, ); } - if (hasLocalSecretsConfigure && !runtimeKeyPlan && !runtimeKeyVerificationPlan) { + if (hasLocalSecretsConfigure && !runtimeKeyVerificationPlan) { throw iosSetupError( - "An existing LocalSecrets-based Clerk configuration was found, but its selected-target runtime sink could not be proven. No local files were changed; repair or confirm that compatibility path manually.", + "An existing LocalSecrets-based Clerk configuration was found, but it does not provide one proven development publishable key to the selected target. clerk init preserves custom runtime sources and will not write this plist; add the intended key manually, then rerun the command.", ); } if (prebuiltAuthActive) { - const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers( - inspection, - directConfigPlan, - runtimeKeyPlan, - ); + const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan); if (runtimeBlockers.length > 0) { throw iosSetupError( `The prebuilt AuthView flow requires a proven Clerk runtime and SwiftUI environment before its source can be added. No local files were changed:\n${runtimeBlockers @@ -633,19 +587,6 @@ export async function applyIOSLocalSetup( displayPath: `${selection.projectPath}/project.pbxproj`, }); } - if (runtimeKeyPlan?.localSecretsPath) { - plannedPaths.push({ - absolutePath: resolve(options.root, runtimeKeyPlan.localSecretsPath), - displayPath: runtimeKeyPlan.localSecretsPath, - }); - } - const changesGitignore = runtimeKeyPlan?.changesGitignore === true; - if (changesGitignore && runtimeKeyPlan?.gitignorePath) { - plannedPaths.push({ - absolutePath: resolve(options.root, runtimeKeyPlan.gitignorePath), - displayPath: runtimeKeyPlan.gitignorePath, - }); - } if (directConfigNeedsWrite(directConfigPlan) && directConfigPlan?.sourcePath) { plannedPaths.push({ absolutePath: resolve(options.root, directConfigPlan.sourcePath), @@ -726,7 +667,6 @@ export async function applyIOSLocalSetup( const hasLocalWrites = installPlan.status === "ready" || - runtimeKeyPlan != null || directConfigNeedsWrite(directConfigPlan) || prebuiltAuthPlan?.status === "ready" || associatedDomainNeedsWrite(associatedDomainPlan) || @@ -746,19 +686,6 @@ export async function applyIOSLocalSetup( log.info(` ${yellow("MODIFY")} ${selection.projectPath}/project.pbxproj`); for (const action of installPlan.actions) log.info(` ${action}`); } - if (runtimeKeyPlan) { - if (changesGitignore && runtimeKeyPlan.gitignorePath) { - const operation = runtimeKeyPlan.expectedGitignoreHash == null ? "CREATE" : "MODIFY"; - log.info(` ${yellow(operation)} ${runtimeKeyPlan.gitignorePath}`); - } - log.info(` ${yellow("MODIFY")} ${runtimeKeyPlan.localSecretsPath}`); - for (const action of runtimeKeyPlan.actions) log.info(` ${action}`); - log.info( - dim( - " The linked development publishable key will be fetched after authentication and will never be printed.", - ), - ); - } if (directConfigPlan) { const operation = directConfigNeedsWrite(directConfigPlan) ? "MODIFY" : "VERIFY"; log.info(` ${yellow(operation)} ${directConfigPlan.sourcePath}`); @@ -881,7 +808,6 @@ export async function applyIOSLocalSetup( ...(unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion } : {}), sdkInstallPlan, directConfigPlan, - runtimeKeyPlan, runtimeKeyVerificationPlan, associatedDomainPlan, appleEntitlementPlan, @@ -893,7 +819,6 @@ export async function applyIOSLocalSetup( requiresLinkedApp: true, requiresDevelopmentKey: directConfigPlan != null || - runtimeKeyPlan != null || runtimeKeyVerificationPlan != null || associatedDomainPlan?.requiresPublishableKey === true, verifiesExistingKey: @@ -930,18 +855,6 @@ function prebuiltAuthFileMutation( }; } -function reverseFileMutation(mutation: IOSExistingFileMutation): IOSExistingFileMutation { - return { - path: mutation.path, - boundary: mutation.boundary, - originalBytes: mutation.candidateBytes, - originalHash: mutation.candidateHash, - candidateBytes: mutation.originalBytes, - candidateHash: mutation.originalHash, - mode: mutation.mode, - }; -} - function preparedSDKBlockers(prepared: PreparedIOSSDKInstallMutation): string { return prepared.status === "blocked" ? blockerList(prepared.plan.blockers) : ""; } @@ -1040,16 +953,6 @@ function composeAppleMutations( ]; } -function existingMutationsOnly(mutations: readonly IOSFileMutation[]): IOSExistingFileMutation[] { - if (mutations.some((mutation) => "kind" in mutation && mutation.kind === "create")) { - throw iosSetupError( - "The approved iOS setup attempted to combine incompatible runtime and file-creation transactions. No additional local setup changes were written; rerun clerk init.", - ERROR_CODE.IOS_SETUP_PLAN_INVALID, - ); - } - return mutations as IOSExistingFileMutation[]; -} - function assertUniqueMutationPaths(mutations: readonly IOSFileMutation[]): void { const paths = mutations.map((mutation) => resolve(mutation.path)); if (new Set(paths).size !== paths.length) { @@ -1091,29 +994,12 @@ async function validateSatisfiedPrebuiltAuth(plan: IOSPrebuiltAuthPlan): Promise return current.status === "satisfied" && current.sourcePath === plan.sourcePath; } -async function rollbackPreparedLocalMutations( - mutations: readonly IOSExistingFileMutation[], -): Promise { - if (mutations.length === 0) return; - const result = await applyIOSExistingFileTransaction( - [...mutations].reverse().map(reverseFileMutation), - [], - ); - if (result.status !== "applied") { - throw iosSetupError( - "The publishable-key update failed, and a concurrent local edit prevented the approved iOS setup from being restored completely. Inspect the previewed project and entitlements files before retrying.", - ERROR_CODE.IOS_LOCAL_ROLLBACK_FAILED, - ); - } -} - function requireDevelopmentKey( setup: IOSLocalSetupResult, publishableKey: string | undefined, ): string { const planNeedsKey = Boolean( setup.directConfigPlan || - setup.runtimeKeyPlan || setup.runtimeKeyVerificationPlan || setup.associatedDomainPlan?.requiresPublishableKey, ); @@ -1164,21 +1050,9 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } - if ( - setup.runtimeKeyPlan && - (setup.associatedDomainPlan?.missingEntitlementsSettings || - setup.appleEntitlementPlan?.missingEntitlementsSettings) - ) { - throw iosSetupError( - "The approved iOS setup cannot combine a LocalSecrets write with new entitlements-file creation. No local setup changes were written; rerun clerk init.", - ERROR_CODE.IOS_SETUP_PLAN_INVALID, - ); - } - const runtimePlans = [ - setup.directConfigPlan, - setup.runtimeKeyPlan, - setup.runtimeKeyVerificationPlan, - ].filter((plan) => plan != null); + const runtimePlans = [setup.directConfigPlan, setup.runtimeKeyVerificationPlan].filter( + (plan) => plan != null, + ); if (runtimePlans.length > 1) { throw iosSetupError( "The approved iOS setup contains conflicting runtime configuration routes. No local setup changes were written; rerun clerk init.", @@ -1226,8 +1100,8 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { /** * Commits a previously previewed iOS setup after authentication. Fresh direct * configuration combines project.pbxproj and the Swift entry source in one - * guarded local transaction. Existing LocalSecrets integrations retain their - * specialized compatibility transaction. + * guarded local transaction. Existing LocalSecrets integrations are verified + * read-only and are never rewritten. */ export async function applyIOSPlannedLocalSetup( setup: IOSLocalSetupResult, @@ -1257,11 +1131,7 @@ export async function applyIOSPlannedLocalSetup( ERROR_CODE.IOS_SETUP_STALE, ); } - const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers( - inspection, - setup.directConfigPlan, - setup.runtimeKeyPlan, - ); + const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers(inspection, setup.directConfigPlan); if (runtimeBlockers.length > 0) { throw iosSetupError( `The approved prebuilt AuthView setup no longer proves its Clerk runtime prerequisites. No local setup changes were written:\n${runtimeBlockers @@ -1371,7 +1241,7 @@ export async function applyIOSPlannedLocalSetup( postconditions.push(async () => validateSatisfiedPrebuiltAuth(preparedPrebuiltAuth.plan)); } if (setup.prebuiltAuthActive) { - postconditions.push(async () => validatePrebuiltAuthRuntimePostcondition(setup, false)); + postconditions.push(async () => validatePrebuiltAuthRuntimePostcondition(setup)); } assertUniqueMutationPaths(mutations); @@ -1404,7 +1274,7 @@ export async function applyIOSPlannedLocalSetup( if (preparedPrebuiltAuth?.status === "ready") { log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); } - if (!setup.runtimeKeyPlan && preparedAssociatedDomain?.status === "ready") { + if (preparedAssociatedDomain?.status === "ready") { log.success("Clerk Associated Domain added to the selected target entitlements"); } if (preparedAppleEntitlement?.status === "ready") { @@ -1438,9 +1308,8 @@ export async function applyIOSPlannedLocalSetup( const localMutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); assertUniqueMutationPaths(localMutations); - // SDK-only and LocalSecrets compatibility routes apply the PBX candidate - // after authentication. If the specialized key transaction subsequently - // fails, restore the PBX bytes when they are still untouched. + // SDK-only and read-only compatibility routes apply their local candidates + // together after authentication. if (localMutations.length > 0) { const postconditions: Array<() => boolean | Promise> = [ ...(preparedSDK ? [async () => validateIOSSDKInstallPostcondition(preparedSDK.plan)] : []), @@ -1460,10 +1329,7 @@ export async function applyIOSPlannedLocalSetup( ? [async () => validateSatisfiedPrebuiltAuth(preparedPrebuiltAuth.plan)] : []), ...(setup.prebuiltAuthActive - ? [ - async () => - validatePrebuiltAuthRuntimePostcondition(setup, setup.runtimeKeyPlan != null), - ] + ? [async () => validatePrebuiltAuthRuntimePostcondition(setup)] : []), ]; if (setup.runtimeKeyVerificationPlan) { @@ -1489,27 +1355,6 @@ export async function applyIOSPlannedLocalSetup( ERROR_CODE.IOS_LOCAL_APPLY_FAILED, ); } - if (!setup.runtimeKeyPlan && preparedSDK?.status === "ready") { - log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); - } - if (!setup.runtimeKeyPlan && preparedAssociatedDomain?.status === "ready") { - log.success("Clerk Associated Domain added to the selected target entitlements"); - } - if (!setup.runtimeKeyPlan && preparedAppleEntitlement?.status === "ready") { - log.success("Sign in with Apple entitlement added to the selected target"); - } - if (!setup.runtimeKeyPlan && preparedPrebuiltAuth?.status === "ready") { - log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); - } - } - - if (setup.runtimeKeyPlan) { - try { - await applyIOSRuntimeKeySetup(setup.runtimeKeyPlan, key); - } catch (error) { - await rollbackPreparedLocalMutations(existingMutationsOnly(localMutations)); - throw error; - } if (preparedSDK?.status === "ready") { log.success(`${formatProducts(preparedSDK.plan.products)} linked to ${setup.targetName}`); } @@ -1522,43 +1367,11 @@ export async function applyIOSPlannedLocalSetup( if (preparedPrebuiltAuth?.status === "ready") { log.success(`Prebuilt AuthView added to ${preparedPrebuiltAuth.plan.sourcePath}`); } - } else if (setup.runtimeKeyVerificationPlan) { - log.info(dim("The existing publishable key matches the linked Clerk application.")); } -} -export async function applyIOSRuntimeKeySetup( - plan: IOSRuntimeKeyPlan, - publishableKey: string, -): Promise { - const result = await withSpinner("Wiring the development publishable key...", async () => - applyIOSRuntimeKey(plan, publishableKey), - ); - if (result.status === "applied") { - log.success(`Publishable key wired to ${plan.localSecretsPath}`); - return; - } - if (result.status === "satisfied") { - log.info(dim(`The linked publishable key is already wired to ${plan.localSecretsPath}.`)); - return; - } - if (result.status === "stale") { - throw iosSetupError( - "LocalSecrets.plist or .gitignore changed after the preview. Nothing new was written; rerun clerk init to build a fresh plan.", - ERROR_CODE.IOS_SETUP_STALE, - ); - } - if (result.status === "rolled-back") { - throw iosSetupError( - result.message ?? "The runtime-key update failed validation and was restored.", - ERROR_CODE.IOS_LOCAL_APPLY_FAILED, - ); + if (setup.runtimeKeyVerificationPlan) { + log.info(dim("The existing publishable key matches the linked Clerk application.")); } - const reasons = result.plan.blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); - throw iosSetupError( - result.message ?? `The development publishable key could not be wired safely:\n${reasons}`, - ERROR_CODE.IOS_LOCAL_APPLY_FAILED, - ); } export async function verifyIOSRuntimeKeySetup( diff --git a/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts b/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts index c35994be0..18cd3bdeb 100644 --- a/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts @@ -54,9 +54,10 @@ test("the compiled CLI semantically parses iOS XML plists", async () => { includeKey: false, localSecrets: true, }); + const publishableKey = `pk_test_${Buffer.from("clerk.example.test$").toString("base64")}`; await Bun.write( join(fixtureRoot, "MyApp", "LocalSecrets.plist"), - 'CLERK_PUBLISHABLE_KEYreplace-me', + `CLERK_PUBLISHABLE_KEY${publishableKey}`, ); await Bun.write( join(configDir, "config.json"), @@ -124,8 +125,8 @@ test("the compiled CLI semantically parses iOS XML plists", async () => { expect(output.plan.steps).toContainEqual( expect.objectContaining({ id: "configure-publishable-key", - status: "required", - automatable: true, + status: "satisfied", + automatable: false, }), ); expect(output.plan.steps).toContainEqual( diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts index 642b8f241..4d736c5e2 100644 --- a/packages/cli-core/src/commands/init/ios/dry-run.test.ts +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -75,7 +75,12 @@ describe("clerk init --dry-run", () => { test("non-TTY mode emits JSON without network requests or local/global writes", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true }); + await createIOSFixture(root, { complete: true, localSecrets: true }); + const publishableKey = `pk_test_${Buffer.from("clerk.example.test$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEY${publishableKey}`, + ); const configDir = await createIsolatedCLIState(); const projectBefore = await treeDigest(root); const configBefore = await treeDigest(configDir); diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index e0f394605..6f556ff5a 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -6,8 +6,7 @@ import { planIOSDirectConfig } from "./direct-config.ts"; import { planIOSAssociatedDomain } from "./associated-domain.ts"; import { inspectIOSProject } from "./inspect.ts"; import { formatIOSSetupPlan } from "./output.ts"; -import { buildIOSSetupPlan, hasIOSRuntimeKeyHandoffShape } from "./plan.ts"; -import { planIOSRuntimeKey } from "./runtime-key.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; import { createIOSFixture } from "./test-helpers.ts"; const temporaryDirectories: string[] = []; @@ -97,7 +96,7 @@ describe("buildIOSSetupPlan", () => { expect(configureStep?.description).toContain("More than one Clerk.configure"); }); - test("keeps an empty LocalSecrets handoff despite a stale scheme candidate", async () => { + test("does not replace an empty LocalSecrets source from a stale scheme candidate", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -106,7 +105,6 @@ describe("buildIOSSetupPlan", () => { '', ); const inspection = await inspectIOSProject(root); - const target = inspection.appTargets[0]!; const schemePath = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; inspection.localPublishableKey = { evidenceComplete: true, @@ -123,9 +121,8 @@ describe("buildIOSSetupPlan", () => { (step) => step.id === "configure-publishable-key", ); - expect(hasIOSRuntimeKeyHandoffShape(inspection, target)).toBe(true); - expect(configureStep).toMatchObject({ status: "required", automatable: false }); - expect(configureStep?.description).toContain("LocalSecrets.plist"); + expect(configureStep).toMatchObject({ status: "review", automatable: false }); + expect(configureStep?.description).toContain("could not be connected to its loader"); }); test("satisfies configuration and derives the domain from a redacted inline literal", async () => { @@ -564,14 +561,7 @@ struct MyApp: App { ), ); const inspection = await inspectIOSProject(root); - if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); - const runtimeKeyPlan = await planIOSRuntimeKey({ - root, - projectPath: inspection.selection.projectPath, - targetId: inspection.selection.targetId, - }); - - const plan = buildIOSSetupPlan(inspection, { runtimeKeyPlan }); + const plan = buildIOSSetupPlan(inspection); expect(inspection.appTargets[0]?.swift.configureCalls[0]).toMatchObject({ publishableKeyWiring: "local-secrets-loader", @@ -691,7 +681,7 @@ struct MyApp: App { ); }); - test("offers to replace a malformed key in a proven selected-target runtime sink", async () => { + test("preserves a malformed key in a proven selected-target runtime sink", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -701,18 +691,13 @@ struct MyApp: App { ); const inspection = await inspectIOSProject(root); if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); - const runtimeKeyPlan = await planIOSRuntimeKey({ - root, - projectPath: inspection.selection.projectPath, - targetId: inspection.selection.targetId, - }); const directConfigPlan = await planIOSDirectConfig({ root, projectPath: inspection.selection.projectPath, targetId: inspection.selection.targetId, }); - const plan = buildIOSSetupPlan(inspection, { runtimeKeyPlan, directConfigPlan }); + const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); expect(inspection.localPublishableKey).toMatchObject({ found: false, @@ -721,12 +706,12 @@ struct MyApp: App { }); expect(directConfigPlan.status).toBe("blocked"); expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ - status: "required", - automatable: true, + status: "blocked", + automatable: false, }); expect( plan.steps.find((step) => step.id === "configure-publishable-key")?.description, - ).toContain("LocalSecrets.plist"); + ).toContain("malformed"); expect( plan.steps.find((step) => step.id === "configure-publishable-key")?.description, ).not.toContain("Automatic direct configuration stopped"); diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index 5273166e8..a48bd366e 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -10,11 +10,11 @@ import type { import { hasIOSDirectConfigCompatibility } from "./products.ts"; import { clerkKitUIInstallDecision } from "./products.ts"; import type { IOSDirectConfigPlan } from "./direct-config.ts"; -import type { IOSRuntimeKeyPlan } from "./runtime-key.ts"; import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; import type { IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; import type { IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; import type { IOSSDKInstallPlan } from "./install-sdk.ts"; +import type { IOSRuntimeKeyVerificationPlan } from "./runtime-key.ts"; const NATIVE_APPLICATIONS_URL = "https://dashboard.clerk.com/~/native-applications"; const QUICKSTART_URL = "https://clerk.com/docs/ios/getting-started/quickstart"; @@ -88,31 +88,13 @@ function publishableKeyRuntimeSource( return "available-only"; } -export function hasIOSRuntimeKeyHandoffShape( - inspection: IOSProjectInspectionResult, - target: IOSAppTarget, -): boolean { - return ( - inspection.generatedProject === null && - target.swift.evidenceComplete && - target.swift.entryPoints.length === 1 && - target.swift.configureCalls.length === 1 && - target.swift.configureCalls[0]?.publishableKeyWiring === "local-secrets-loader" && - target.swift.configureCalls[0]?.localSecretsRuntimeBinding === "proven" && - target.swift.configureCalls[0]?.startupBinding === "app-init" && - target.swift.configureCalls[0]?.path === target.swift.entryPoints[0]?.path && - target.swift.localSecretsRuntimeBindings.length === 1 && - target.runtimeKeySinks.length === 1 - ); -} - export interface BuildIOSSetupPlanOptions { /** Strict SDK/package compatibility from the same planner used by apply. */ sdkInstallPlan?: Pick; - /** Strict, redacted file/Git readiness from the same planner used by apply. */ - runtimeKeyPlan?: Pick; /** Strict, publishable-key-redacted Swift source readiness from the apply planner. */ directConfigPlan?: IOSDirectConfigPlan; + /** Read-only validation for the exact supported LocalSecrets compatibility path. */ + runtimeKeyVerificationPlan?: Pick; /** Strict existing-entitlements readiness from the same planner used by apply. */ associatedDomainPlan?: Pick< IOSAssociatedDomainPlan, @@ -268,8 +250,6 @@ export function buildIOSSetupPlan( (inspection.localPublishableKey.conflict || (!inspection.localPublishableKey.found && inspection.localPublishableKey.invalidSources.length > 0)); - const localSecretsHandoff = hasIOSRuntimeKeyHandoffShape(inspection, target); - const needsLocalSecretsHandoff = localSecretsHandoff && !configureCallConnectedToRuntime; const hasDirectConfigCompatibility = hasIOSDirectConfigCompatibility(inspection, target); const directConfigPlanApplies = options.directConfigPlan != null && !hasDirectConfigCompatibility; const directConfigAutomationReady = @@ -278,19 +258,15 @@ export function buildIOSSetupPlan( options.directConfigPlan.changes?.configuration !== "verify-existing"; const directConfigBlocked = directConfigPlanApplies && options.directConfigPlan?.status === "blocked"; + const runtimeKeyVerificationBlocked = options.runtimeKeyVerificationPlan?.status === "blocked"; + const runtimeKeyVerificationBlocker = runtimeKeyVerificationBlocked + ? options.runtimeKeyVerificationPlan?.blockers.map((blocker) => blocker.message).join(" ") + : undefined; const directConfigBlocker = directConfigBlocked ? options.directConfigPlan?.blockers.map((blocker) => blocker.message).join(" ") : undefined; - const runtimeKeyAutomationReady = - needsLocalSecretsHandoff && options.runtimeKeyPlan?.status === "ready"; - const runtimeKeyBlocker = - needsLocalSecretsHandoff && options.runtimeKeyPlan?.status === "blocked" - ? options.runtimeKeyPlan.blockers.map((blocker) => blocker.message).join(" ") - : undefined; - const configuredStatus: IOSSetupStepStatus = needsLocalSecretsHandoff - ? options.runtimeKeyPlan?.status === "blocked" - ? "blocked" - : "required" + const configuredStatus: IOSSetupStepStatus = runtimeKeyVerificationBlocked + ? "blocked" : publishableKeyBlocked ? "blocked" : directConfigBlocked @@ -309,16 +285,14 @@ export function buildIOSSetupPlan( "configure-publishable-key", "Configure Clerk with a publishable key", configuredStatus, - needsLocalSecretsHandoff - ? runtimeKeyAutomationReady - ? `Clerk.configure(publishableKey:) is connected to the selected target's proven LocalSecrets.plist loader, but that runtime source does not contain a usable key. clerk init can fetch the linked development instance's publishable key directly into that plist without printing it or creating an env file.` - : runtimeKeyBlocker - ? `Clerk.configure(publishableKey:) is connected to the selected target's LocalSecrets.plist loader, but automatic key wiring is blocked: ${runtimeKeyBlocker}` - : "Clerk.configure(publishableKey:) is connected to the selected target's LocalSecrets.plist loader, but that source has no usable key. Add the development key manually or run the strict iOS setup preflight before applying it." + runtimeKeyVerificationBlocked + ? `The existing LocalSecrets.plist compatibility path cannot be verified safely. clerk init preserves this file and will not replace it. ${runtimeKeyVerificationBlocker ?? "Repair it manually, then rerun the command."}` : publishableKeyBlocked ? inspection.localPublishableKey.conflict ? "Multiple effective publishable-key sources point at different Clerk instances. Resolve the conflict before configuring the app." - : "The effective publishable-key source is malformed. Replace it before relying on Clerk.configure(...)." + : runtimeKeySource === "local-secrets" + ? "The existing LocalSecrets.plist publishable key is malformed. clerk init preserves this compatibility file and will not replace it; add the intended development key manually." + : "The effective publishable-key source is malformed. Replace it before relying on Clerk.configure(...)." : directConfigBlocked ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's @main App initializer and root Scene manually."}` : configured @@ -348,7 +322,7 @@ export function buildIOSSetupPlan( : "Select a Clerk application and call Clerk.configure(publishableKey:) with its development publishable key directly in the selected target's @main App initializer.", target.swift.configureCalls, undefined, - runtimeKeyAutomationReady || directConfigAutomationReady, + directConfigAutomationReady && !runtimeKeyVerificationBlocked, ), ); diff --git a/packages/cli-core/src/test/lib/init-harness.ts b/packages/cli-core/src/test/lib/init-harness.ts index 72c9587ee..be7784f50 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -223,7 +223,6 @@ export function useInitHarness(): InitHarness { verifiesExistingKey: false, }), spyOn(iosApplyModule, "applyIOSPlannedLocalSetup").mockResolvedValue(undefined), - spyOn(iosApplyModule, "applyIOSRuntimeKeySetup").mockResolvedValue(undefined), spyOn(iosApplyModule, "verifyIOSRuntimeKeySetup").mockResolvedValue(undefined), spyOn(nativeRemoteModule, "prepareIOSNativeRemoteSetup").mockResolvedValue({ schemaVersion: 1, From a3e477a002b238cae855dc849a22b9b78ec8bded Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 12:31:07 -0400 Subject: [PATCH 28/55] fix(init): revalidate iOS application links --- .../src/commands/init/index-ios.test.ts | 84 +++++++++++++++++++ packages/cli-core/src/commands/init/index.ts | 25 ++++++ 2 files changed, 109 insertions(+) diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index e856c8a65..de952bde6 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -1012,6 +1012,46 @@ describe("init iOS", () => { expect(`${captured.out}\n${captured.err}`).not.toContain("pk_test_must_not_be_forwarded"); }); + test("does not mutate Apple state when the application link changes during native setup", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + appleEntitlementPlan: iosAppleEntitlementPlan(), + nativeAppleRequested: true, + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + let linkedApplicationId = "app_test"; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockImplementation( + async () => ({ profile: { appId: linkedApplicationId } }) as never, + ); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue(iosRemotePlan()); + spyOn(nativeAppleMod, "prepareIOSNativeAppleConnection").mockResolvedValue( + iosNativeApplePlan(), + ); + spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue(undefined); + const applyNative = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockImplementation( + async () => { + linkedApplicationId = "app_changed"; + }, + ); + const applyApple = spyOn(nativeAppleMod, "applyIOSNativeAppleConnection").mockResolvedValue( + undefined, + ); + + await expect(init({ yes: true, signInWithApple: true })).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining( + "completed local and Clerk Native Application changes remain intact, but no native Apple connection changes were made", + ), + }); + + expect(applyNative).toHaveBeenCalledTimes(1); + expect(applyApple).not.toHaveBeenCalled(); + }); + test("does not opt into native Apple merely because --yes was supplied", async () => { setup({ email: "test@test.com" }); const iosCtx = nativeIOSContext(); @@ -1085,6 +1125,41 @@ describe("init iOS", () => { expect(stages().at(-1)).toBe("ios_local_setup"); }); + test("does not mutate native state when the application link changes during local commit", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + let linkedApplicationId = "app_test"; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockImplementation( + async () => ({ profile: { appId: linkedApplicationId } }) as never, + ); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue(iosRemotePlan()); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockImplementation( + async () => { + linkedApplicationId = "app_changed"; + }, + ); + const applyRemote = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockResolvedValue( + undefined, + ); + + await expect(init({ yes: true })).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining( + "Local changes remain intact, but no Clerk Native Application changes were made", + ), + }); + + expect(commitLocal).toHaveBeenCalledTimes(1); + expect(applyRemote).not.toHaveBeenCalled(); + expect(nativeAppleMod.applyIOSNativeAppleConnection).not.toHaveBeenCalled(); + }); + test("reports partial remote failure without claiming the local setup was rolled back", async () => { setup({ email: "test@test.com" }); const stages = trackStages(); @@ -1284,6 +1359,9 @@ describe("init iOS", () => { instanceId: "ins_matching", publishableKey: linkedKey, }); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan({ applicationId: "app_matching", instanceId: "ins_matching" }), + ); await init({ yes: true }); expect(resolveKeys).toHaveBeenCalledTimes(1); @@ -1315,6 +1393,9 @@ describe("init iOS", () => { verifiesExistingKey: true, }); const localApply = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan({ applicationId: "app_requested", instanceId: "ins_requested" }), + ); await init({ yes: true, app: "app_requested" }); @@ -1355,6 +1436,9 @@ describe("init iOS", () => { requiresDevelopmentKey: true, }); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan({ applicationId: "app_requested", instanceId: "ins_requested" }), + ); await init({ yes: true, app: "app_requested" }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 2e5fb7035..7ff11c611 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -648,6 +648,11 @@ export async function init(options: InitOptions = {}) { iosSetupForCommit, iosSetupForCommit.requiresDevelopmentKey ? keys.publishableKey : undefined, ); + await assertIOSApplicationLinkStillMatches({ + cwd: ctx.cwd, + applicationId: nativeRemotePlan.applicationId, + phase: "native-application", + }); try { setTelemetryStage("ios_native_setup"); await applyIOSNativeRemoteSetup(nativeRemotePlan); @@ -663,6 +668,11 @@ export async function init(options: InitOptions = {}) { log.success("Clerk Native API and iOS application registration verified"); ctx.iosNativeRemoteReady = true; if (nativeApplePlan) { + await assertIOSApplicationLinkStillMatches({ + cwd: ctx.cwd, + applicationId: nativeApplePlan.applicationId, + phase: "native-apple", + }); try { setTelemetryStage("ios_apple_setup"); await applyIOSNativeAppleConnection(nativeApplePlan); @@ -1161,6 +1171,21 @@ async function authenticateAndLink( }; } +async function assertIOSApplicationLinkStillMatches(options: { + cwd: string; + applicationId: string; + phase: "native-application" | "native-apple"; +}): Promise { + const linked = await resolveProfile(options.cwd); + if (linked?.profile.appId === options.applicationId) return; + + const message = + options.phase === "native-application" + ? "The local Clerk application link changed after the approved iOS setup was committed. Local changes remain intact, but no Clerk Native Application changes were made; rerun clerk init." + : "The local Clerk application link changed after Clerk Native Application setup completed. The completed local and Clerk Native Application changes remain intact, but no native Apple connection changes were made; rerun clerk init."; + throw new CliError(message, { code: ERROR_CODE.IOS_SETUP_STALE }); +} + // --- Keyless app setup --- /** From 83cfb3349e49272dbba75416eb1c22ddfcea807f Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 12:34:06 -0400 Subject: [PATCH 29/55] fix(init): recover before mutating iOS inspection --- packages/cli-core/src/commands/init/index-ios.test.ts | 5 +++++ packages/cli-core/src/commands/init/index.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index de952bde6..a1553f3f8 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -25,6 +25,7 @@ import { FAKE_IOS_NATIVE_READINESS, } from "../../test/lib/init-harness.ts"; import * as telemetryMod from "../../lib/telemetry.ts"; +import * as iosFileTransactionMod from "./ios/file-transaction.ts"; import { init } from "./index.ts"; import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; import type { IOSLocalSetupResult } from "./ios/apply.ts"; @@ -508,6 +509,9 @@ describe("init iOS", () => { }, }; spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + const recover = spyOn(iosFileTransactionMod, "recoverIOSFileTransactions").mockResolvedValue( + undefined, + ); spyOn(scaffoldMod, "scaffold").mockResolvedValue({ actions: [], postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], @@ -515,6 +519,7 @@ describe("init iOS", () => { await init({ yes: true }); + expect(recover).toHaveBeenCalledWith(iosCtx.cwd); expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith({ root: iosCtx.cwd, target: undefined, diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 7ff11c611..485d5c578 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -66,6 +66,7 @@ import { import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; import { inspectIOSProject } from "./ios/inspect.ts"; +import { recoverIOSFileTransactions } from "./ios/file-transaction.ts"; import { buildIOSSetupPlan } from "./ios/plan.ts"; import { planIOSDirectConfig } from "./ios/direct-config.ts"; import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./ios/products.ts"; @@ -363,6 +364,11 @@ export async function init(options: InitOptions = {}) { let iosProfile: Awaited> | undefined; let preauthenticatedIOSLabel: string | undefined; if (ctx.framework.dep === "ios") { + // A normal init is explicitly mutating and may finish a durable file + // transaction left by an interrupted earlier run. Dry-run returns above, + // so read-only inspection only reports recovery as required. + await recoverIOSFileTransactions(ctx.cwd); + // Resolve the local link before the redacted preview. No application key // is fetched and no local file is written until the user has authorized // the complete semantic plan. From 709fb5e04cb8205cc11fbf58316e4bd4a1fb5985 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 15:36:32 -0400 Subject: [PATCH 30/55] test(init): cover unused iOS key artifacts --- .../src/commands/init/ios/apply-cli.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index f79ab2daf..445edb0b3 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -280,6 +280,87 @@ describe("clerk init iOS SDK apply", () => { expect(appSource).toContain(".environment(Clerk.shared)"); }); + test("directly configures a fresh target without changing an unreferenced LocalSecrets plist", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-unused-local-secrets-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { includeKey: false, localSecrets: true }); + const localSecretsPath = join(root, "MyApp", "LocalSecrets.plist"); + const localSecretsBefore = await Bun.file(localSecretsPath).text(); + + const before = await inspectIOSProject(root, { target: "MyApp" }); + expect(before.diagnostics).toContainEqual( + expect.objectContaining({ + code: "clerk.unconsumed-publishable-key-source", + severity: "warning", + }), + ); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }); + + expect(setup.directConfigPlan).toMatchObject({ + status: "ready", + changes: { + configuration: "insert-initializer", + environment: "insert", + }, + }); + expect(setup.runtimeKeyVerificationPlan).toBeUndefined(); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + + const appSource = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(appSource).toContain("Clerk.configure(publishableKey:"); + expect(appSource).toContain(".environment(Clerk.shared)"); + expect(await Bun.file(localSecretsPath).text()).toBe(localSecretsBefore); + }); + + test("directly configures a fresh target without changing a stale Run-scheme key", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-unused-scheme-key-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { includeKey: false }); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + const schemePath = join(schemeDirectory, "MyApp.xcscheme"); + const staleKey = `pk_test_${btoa("stale.clerk.example$")}`; + const schemeSource = ``; + await mkdir(schemeDirectory, { recursive: true }); + await Bun.write(schemePath, schemeSource); + + const before = await inspectIOSProject(root, { target: "MyApp" }); + expect(before.diagnostics).toContainEqual( + expect.objectContaining({ + code: "clerk.unconsumed-publishable-key-source", + severity: "warning", + }), + ); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: true, + allowDirty: false, + }); + + expect(setup.directConfigPlan).toMatchObject({ + status: "ready", + changes: { + configuration: "insert-initializer", + environment: "insert", + }, + }); + await applyIOSPlannedLocalSetup(setup, authFixtureKey); + + const appSource = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); + expect(appSource).toContain("Clerk.configure(publishableKey:"); + expect(appSource).toContain(".environment(Clerk.shared)"); + expect(await Bun.file(schemePath).text()).toBe(schemeSource); + }); + test("refuses a ProcessInfo compatibility path without proven SwiftUI environment injection", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-process-info-auth-view-")); temporaryDirectories.push(root); From 602228178c0535e2a68259f1c6cc9f88b2ceca85 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 16:08:06 -0400 Subject: [PATCH 31/55] docs(changeset): clarify iOS prebuilt scaffold --- .changeset/calm-apples-inspect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/calm-apples-inspect.md b/.changeset/calm-apples-inspect.md index f3b84eef1..965f42cc7 100644 --- a/.changeset/calm-apples-inspect.md +++ b/.changeset/calm-apples-inspect.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing proven LocalSecrets and ProcessInfo integrations remain read-only compatibility paths and source-proven custom flows remain core-only. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the linked development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and `AuthView`, including redirect, continuation, pending-session-task, preview, and error-handling lifecycle wiring; established or partially integrated application UI is never rewritten. +Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing proven LocalSecrets and ProcessInfo integrations remain read-only compatibility paths and source-proven custom flows remain core-only. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the linked development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and an `AuthView` sheet; established or partially integrated application UI is never rewritten. From d0de8c51e36dee67128d4478a0b00e44fada41a5 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 16:47:57 -0400 Subject: [PATCH 32/55] fix(init): validate iOS native identities --- .../src/commands/init/index-ios.test.ts | 8 +- packages/cli-core/src/commands/init/index.ts | 6 +- .../commands/init/ios/native-remote.test.ts | 150 +++++++++++++++++- .../src/commands/init/ios/native-remote.ts | 70 ++++++-- 4 files changed, 216 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index a1553f3f8..82df3942d 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -305,7 +305,7 @@ describe("init iOS", () => { }), ); - await init({ yes: true, appIdPrefix: "CONFIRMED123" }); + await init({ yes: true, appIdPrefix: "CONFIRM123" }); expect(linkMod.link).toHaveBeenCalledWith({ skipIfLinked: true, @@ -317,7 +317,7 @@ describe("init iOS", () => { expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith( expect.objectContaining({ root: FAKE_IOS_NATIVE_READINESS.root, - appIdPrefix: "CONFIRMED123", + appIdPrefix: "CONFIRM123", applicationLinkChange: "created-and-linked", }), ); @@ -351,7 +351,7 @@ describe("init iOS", () => { iosRemotePlan({ applicationId: "app_existing", instanceId: "ins_existing", - appIdPrefix: "REGISTERED123", + appIdPrefix: "REGIST1234", nativeApi: "satisfied", registration: "satisfied", status: "satisfied", @@ -416,7 +416,7 @@ describe("init iOS", () => { setup(); await expect(init({ appIdPrefix: " " })).rejects.toThrow( - "--app-id-prefix must contain between 1 and 255 characters", + "--app-id-prefix must contain exactly 10 ASCII letters or numbers", ); expect(context.gatherContext).not.toHaveBeenCalled(); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 485d5c578..bb2798a69 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -761,7 +761,9 @@ function assertUsableFlags(options: InitOptions): void { ); } if (options.appIdPrefix != null && !validateAppIdPrefix(options.appIdPrefix)) { - throwUsageError("--app-id-prefix must contain between 1 and 255 characters after trimming."); + throwUsageError( + "--app-id-prefix must contain exactly 10 ASCII letters or numbers after trimming.", + ); } if (options.dryRun && options.starter) { throwUsageError( @@ -1392,7 +1394,7 @@ export function registerInit(program: Program): void { .option("--allow-dirty", "Allow an iOS project file with existing local changes to be updated") .option( "--app-id-prefix ", - "Apple App ID Prefix to use when Clerk needs to register the selected iOS Bundle ID", + "10-character Apple App ID Prefix to use when Clerk needs to register the selected iOS Bundle ID", ) .option("--sign-in-with-apple", "Enable native Sign in with Apple for the selected iOS target") .option( 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 f0bfe64d9..732eaec6a 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 @@ -7,6 +7,7 @@ import { buildIOSNativeRemotePlan, prepareIOSNativeRemoteSetup, validateAppIdPrefix, + validateBundleIdentifier, type IOSNativeRemoteAPI, type IOSNativeRemotePlan, type IOSNativeRemotePrompts, @@ -298,10 +299,153 @@ function prompts( } describe("Clerk Native Application remote setup", () => { - test("validates the public App ID Prefix contract without assuming a Team ID shape", () => { - expect(validateAppIdPrefix(" legacy.prefix-value ")).toBe("legacy.prefix-value"); + test("validates Apple identity formats without equating a prefix to the Team ID", () => { + expect(validateAppIdPrefix(" LeGaCy1234 ")).toBe("LeGaCy1234"); + expect(validateAppIdPrefix("legacy.prefix-value")).toBeUndefined(); expect(validateAppIdPrefix(" ")).toBeUndefined(); - expect(validateAppIdPrefix("x".repeat(256))).toBeUndefined(); + expect(validateAppIdPrefix("x")).toBeUndefined(); + expect(validateAppIdPrefix("x".repeat(11))).toBeUndefined(); + expect(validateBundleIdentifier("NativeApp")).toBe("NativeApp"); + expect(validateBundleIdentifier("com.example-NativeApp")).toBe("com.example-NativeApp"); + expect(validateBundleIdentifier("com.example_bad")).toBeUndefined(); + expect(validateBundleIdentifier("x".repeat(256))).toBeUndefined(); + }); + + test("accepts a legacy App ID Prefix that differs from DEVELOPMENT_TEAM", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[registration(LOCAL_PREFIX)]], + }); + + const result = await prepareIOSNativeRemoteSetup( + prepareOptions({ + target: selectedTarget({ appIdPrefix: null }), + unverifiedAppIdPrefixSuggestion: { + source: "xcode-development-team", + value: "ABCDE12345", + }, + }), + { api, prompts: prompts() }, + ); + + expect(result).toMatchObject({ + status: "satisfied", + appIdPrefix: LOCAL_PREFIX, + registration: "satisfied", + blockers: [], + }); + }); + + test("blocks the malformed Bundle ID and App ID Prefix reproduction together", () => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ + bundleIdentifier: "com.example_bad", + appIdPrefix: "x", + }), + nativeSettings: nativeSettings(false), + registrations: [], + }); + + expect(result.status).toBe("blocked"); + expect(result.blockers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: "bundle-identifier-invalid" }), + expect.objectContaining({ code: "app-id-prefix-invalid" }), + ]), + ); + }); + + test.each([ + { + name: "local Bundle ID", + target: selectedTarget({ bundleIdentifier: "com.example_bad" }), + requestedAppIdPrefix: undefined, + registrations: [], + blocker: "bundle-identifier-invalid", + }, + { + name: "local App ID Prefix", + target: selectedTarget({ appIdPrefix: "x" }), + requestedAppIdPrefix: undefined, + registrations: [], + blocker: "app-id-prefix-invalid", + }, + { + name: "partial local App ID Prefix candidate", + target: selectedTarget({ + appIdPrefix: null, + appIdPrefixCandidates: ["invalid-"], + }), + requestedAppIdPrefix: undefined, + registrations: [], + blocker: "app-id-prefix-invalid", + }, + { + name: "explicit App ID Prefix", + target: selectedTarget({ appIdPrefix: null }), + requestedAppIdPrefix: "x", + registrations: [], + blocker: "app-id-prefix-invalid", + }, + { + name: "existing registration App ID Prefix", + target: selectedTarget({ appIdPrefix: null }), + requestedAppIdPrefix: undefined, + registrations: [registration("x")], + blocker: "app-id-prefix-invalid", + }, + ])("blocks an invalid $name before approval", (fixture) => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: fixture.target, + requestedAppIdPrefix: fixture.requestedAppIdPrefix, + nativeSettings: nativeSettings(false), + registrations: [...fixture.registrations], + }); + + expect(result.status).toBe("blocked"); + expect(result.registration).toBe("blocked"); + expect(result.actions).not.toContainEqual(expect.stringContaining("Register iOS Bundle ID")); + expect(result.blockers).toContainEqual(expect.objectContaining({ code: fixture.blocker })); + }); + + test.each([ + { + name: "invalid local identity", + target: selectedTarget({ bundleIdentifier: "com.example_bad" }), + registrations: [] as IOSApplication[], + }, + { + name: "invalid existing registration", + target: selectedTarget({ appIdPrefix: null }), + registrations: [registration("x")], + }, + ])("does not request consent or write for an $name", async ({ target, registrations }) => { + let consentCalls = 0; + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[...registrations]], + }); + + await expect( + prepareIOSNativeRemoteSetup(prepareOptions({ target, yes: false }), { + api, + prompts: prompts({ + confirmChanges: async () => { + consentCalls += 1; + return true; + }, + }), + }), + ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_BLOCKED }); + + expect(consentCalls).toBe(0); + expect(calls).toEqual(["GET native settings", "GET iOS registrations"]); + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); }); test("revalidates a satisfied plan without prompting or writing", async () => { 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 0ea668953..99669479e 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -33,7 +33,10 @@ import { type IOSNativeRegistrationRetryStore, } from "./native-registration-retry.ts"; -const APP_ID_PREFIX_MAX_LENGTH = 255; +const APP_ID_PREFIX_LENGTH = 10; +const BUNDLE_IDENTIFIER_MAX_LENGTH = 255; +const APP_ID_PREFIX_PATTERN = /^[A-Za-z0-9]{10}$/; +const BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9.-]+$/; function iosRemoteError( message: string, @@ -49,7 +52,9 @@ function rethrowKnownRemoteError(error: unknown): void { export type IOSNativeRemoteBlockerCode = | "target-not-selected" | "bundle-identifier-unavailable" + | "bundle-identifier-invalid" | "app-id-prefix-required" + | "app-id-prefix-invalid" | "app-id-prefix-conflict" | "duplicate-bundle-registration"; @@ -162,8 +167,8 @@ const defaultPrompts: IOSNativeRemotePrompts = { default: suggested?.source === "partial-literal-entitlements" ? suggested.value : undefined, placeholder: suggested?.value ?? "ABCDE12345", validate: (value) => - validateAppIdPrefix(value) ?? - `Enter an App ID Prefix between 1 and ${APP_ID_PREFIX_MAX_LENGTH} characters. Verify it in Apple Developer; it can differ from your Team ID.`, + validateAppIdPrefix(value) != null || + `Enter an App ID Prefix containing exactly ${APP_ID_PREFIX_LENGTH} ASCII letters or numbers. Verify it in Apple Developer; it can differ from your Team ID.`, }); }, confirmChanges: async () => @@ -176,7 +181,15 @@ function blocker(code: IOSNativeRemoteBlockerCode, message: string): IOSNativeRe export function validateAppIdPrefix(value: string | undefined): string | undefined { const normalized = value?.trim(); - return normalized && normalized.length <= APP_ID_PREFIX_MAX_LENGTH ? normalized : undefined; + return normalized && APP_ID_PREFIX_PATTERN.test(normalized) ? normalized : undefined; +} + +export function validateBundleIdentifier(value: string | undefined): string | undefined { + return value && + value.length <= BUNDLE_IDENTIFIER_MAX_LENGTH && + BUNDLE_IDENTIFIER_PATTERN.test(value) + ? value + : undefined; } function copyTargetSnapshot( @@ -245,13 +258,33 @@ function localIdentity(target: IOSNativeReadinessTarget): { }; } + const blockers: IOSNativeRemoteBlocker[] = []; + if (!validateBundleIdentifier(target.bundleIdentifier.value)) { + blockers.push( + blocker( + "bundle-identifier-invalid", + `The selected target's Bundle ID must contain between 1 and ${BUNDLE_IDENTIFIER_MAX_LENGTH} ASCII letters, numbers, hyphens, or periods.`, + ), + ); + } + const appIdPrefixCandidates = target.appIdPrefix.status === "resolved" ? [target.appIdPrefix.value] : target.appIdPrefix.status === "conflicting" ? target.appIdPrefix.candidates : (target.appIdPrefix.candidates ?? []); - const blockers: IOSNativeRemoteBlocker[] = []; + const invalidLocalPrefixes = appIdPrefixCandidates.filter( + (candidate) => validateAppIdPrefix(candidate) !== candidate, + ); + if (invalidLocalPrefixes.length > 0) { + blockers.push( + blocker( + "app-id-prefix-invalid", + `The selected target contains an invalid Apple App ID Prefix. App ID Prefixes must contain exactly ${APP_ID_PREFIX_LENGTH} ASCII letters or numbers.`, + ), + ); + } if (target.appIdPrefix.status === "conflicting") { blockers.push( blocker( @@ -263,7 +296,11 @@ function localIdentity(target: IOSNativeReadinessTarget): { return { bundleIdentifier: target.bundleIdentifier.value, - appIdPrefix: target.appIdPrefix.status === "resolved" ? target.appIdPrefix.value : undefined, + appIdPrefix: + target.appIdPrefix.status === "resolved" && + validateAppIdPrefix(target.appIdPrefix.value) === target.appIdPrefix.value + ? target.appIdPrefix.value + : undefined, appIdPrefixCandidates, blockers, }; @@ -285,8 +322,8 @@ export function buildIOSNativeRemotePlan(options: { if (options.requestedAppIdPrefix != null && !explicitPrefix) { blockers.push( blocker( - "app-id-prefix-required", - `The Apple App ID Prefix must contain between 1 and ${APP_ID_PREFIX_MAX_LENGTH} characters after trimming.`, + "app-id-prefix-invalid", + `The supplied Apple App ID Prefix must contain exactly ${APP_ID_PREFIX_LENGTH} ASCII letters or numbers.`, ), ); } @@ -305,12 +342,27 @@ export function buildIOSNativeRemotePlan(options: { const matchingBundle = bundleIdentifier ? options.registrations.filter((registration) => registration.bundle_id === bundleIdentifier) : []; + const invalidRegisteredPrefixes = matchingBundle.filter( + (registration) => + validateAppIdPrefix(registration.app_id_prefix) !== registration.app_id_prefix, + ); + if (invalidRegisteredPrefixes.length > 0) { + blockers.push( + blocker( + "app-id-prefix-invalid", + `An existing Clerk registration for ${bundleIdentifier} contains an invalid Apple App ID Prefix. Review the Native Applications page before continuing.`, + ), + ); + } const registeredPrefixes = [...new Set(matchingBundle.map((item) => item.app_id_prefix))].sort(); const selectedPrefix = explicitPrefix ?? identity.appIdPrefix; let appIdPrefix = selectedPrefix; let registration: IOSNativeRemotePlan["registration"] = "blocked"; - if (bundleIdentifier) { + const hasInvalidIdentity = blockers.some( + (item) => item.code === "bundle-identifier-invalid" || item.code === "app-id-prefix-invalid", + ); + if (bundleIdentifier && !hasInvalidIdentity) { if (selectedPrefix) { const conflicts = registeredPrefixes.filter((prefix) => prefix !== selectedPrefix); if (conflicts.length > 0) { From 8180dcd6909fa06e7f398604dd997ff2dec6c64a Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 17:17:54 -0400 Subject: [PATCH 33/55] fix(cli): suppress sensitive iOS reconciliation errors --- .../src/commands/init/index-ios.test.ts | 66 +++++++++++++++++-- packages/cli-core/src/commands/init/index.ts | 14 ++-- .../commands/init/ios/native-remote.test.ts | 24 +++++-- .../src/commands/init/ios/native-remote.ts | 40 +++++------ 4 files changed, 107 insertions(+), 37 deletions(-) diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index 82df3942d..f0a24d898 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -25,6 +25,7 @@ import { FAKE_IOS_NATIVE_READINESS, } from "../../test/lib/init-harness.ts"; import * as telemetryMod from "../../lib/telemetry.ts"; +import { getLogLevel, setLogLevel } from "../../lib/log.ts"; import * as iosFileTransactionMod from "./ios/file-transaction.ts"; import { init } from "./index.ts"; import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; @@ -806,6 +807,48 @@ describe("init iOS", () => { expect(`${captured.out}\n${captured.err}`).not.toContain(VALID_DEVELOPMENT_KEY); }); + test("omits unexpected AuthView inspection details from debug output", async () => { + const { captured } = setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const setupResult = iosSetupResult({ + prebuiltAuthRequested: true, + prebuiltAuthActive: true, + prebuiltAuthPlan: iosPrebuiltAuthPlan({ status: "satisfied", root: iosCtx.cwd }), + prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), + requiresLinkedApp: true, + requiresDevelopmentKey: false, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_test" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + const sensitiveBearer = "Bearer ak_AUTH_VIEW_TOKEN_MUST_NOT_ESCAPE"; + spyOn(fapiMod, "fetchUserSettings").mockRejectedValue( + new Error(`request failed with ${sensitiveBearer}`), + ); + + const previousLogLevel = getLogLevel(); + try { + setLogLevel("debug"); + await expect(init({ yes: true, prebuiltAuthUI: true })).rejects.toThrow( + "AuthView methods could not be inspected safely", + ); + } finally { + setLogLevel(previousLogLevel); + } + + expect(captured.err).toContain( + "Could not inspect AuthView authentication methods; underlying error details were omitted.", + ); + expect(`${captured.out}\n${captured.err}`).not.toContain(sensitiveBearer); + }); + test("passes the linked development key to the approved iOS setup", async () => { setup({ email: "test@test.com" }); const iosCtx = { @@ -1166,7 +1209,7 @@ describe("init iOS", () => { }); test("reports partial remote failure without claiming the local setup was rolled back", async () => { - setup({ email: "test@test.com" }); + const { captured } = setup({ email: "test@test.com" }); const stages = trackStages(); const iosCtx = nativeIOSContext(); const setupResult = iosSetupResult({ @@ -1182,17 +1225,28 @@ describe("init iOS", () => { const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( undefined, ); + const sensitiveBearer = "Bearer ak_NATIVE_RECONCILIATION_TOKEN_MUST_NOT_ESCAPE"; spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockRejectedValue( - new Error("remote mutation failed"), + new Error(`remote mutation failed with ${sensitiveBearer}`), ); - await expect(init({ yes: true })).rejects.toMatchObject({ - code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED, - message: expect.stringContaining("Local changes remain intact; rerun clerk init"), - }); + const previousLogLevel = getLogLevel(); + try { + setLogLevel("debug"); + await expect(init({ yes: true })).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED, + message: expect.stringContaining("Local changes remain intact; rerun clerk init"), + }); + } finally { + setLogLevel(previousLogLevel); + } expect(commitLocal).toHaveBeenCalledTimes(1); expect(stages().at(-1)).toBe("ios_native_setup"); + expect(captured.err).toContain( + "Could not reconcile Clerk Native Application settings; underlying error details were omitted.", + ); + expect(`${captured.out}\n${captured.err}`).not.toContain(sensitiveBearer); }); test("does not write a key when the linked app changes during resolution", async () => { diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index bb2798a69..a9206dc3c 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -511,7 +511,9 @@ export async function init(options: InitOptions = {}) { } catch (error) { if (interruptedExitCode() !== null) throw error; if (error instanceof ApiError || error instanceof CliError) throw error; - log.debug(`Could not inspect AuthView authentication methods: ${errorMessage(error)}`); + log.debug( + "Could not inspect AuthView authentication methods; underlying error details were omitted.", + ); throw new CliError( "The linked Clerk application's AuthView methods could not be inspected safely. No local setup changes were written; rerun clerk init.", { code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED }, @@ -606,7 +608,7 @@ export async function init(options: InitOptions = {}) { if (interruptedExitCode() !== null) throw error; if (error instanceof ApiError || error instanceof CliError) throw error; log.debug( - `Could not revalidate AuthView authentication methods: ${errorMessage(error)}`, + "Could not revalidate AuthView authentication methods; underlying error details were omitted.", ); throw new CliError( "The linked Clerk application's AuthView methods could not be revalidated safely. No local or remote setup changes were written; rerun clerk init.", @@ -665,7 +667,9 @@ export async function init(options: InitOptions = {}) { } catch (error) { if (interruptedExitCode() !== null) throw error; if (error instanceof ApiError || error instanceof CliError) throw error; - log.debug(`Could not reconcile Clerk Native Application settings: ${errorMessage(error)}`); + log.debug( + "Could not reconcile Clerk Native Application settings; underlying error details were omitted.", + ); throw new CliError( "The local iOS setup completed, but Clerk Native Application settings could not be completed remotely. Local changes remain intact; rerun clerk init to safely reconcile the additive remote steps.", { code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED }, @@ -685,7 +689,9 @@ export async function init(options: InitOptions = {}) { } catch (error) { if (interruptedExitCode() !== null) throw error; if (error instanceof ApiError || error instanceof CliError) throw error; - log.debug(`Could not reconcile the native Apple connection: ${errorMessage(error)}`); + log.debug( + "Could not reconcile the native Apple connection; underlying error details were omitted.", + ); throw new CliError( "The local iOS setup and Clerk Native Application registration completed, but the native Apple connection could not be completed. Those completed changes remain intact; rerun clerk init to reconcile Sign in with Apple safely.", { code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED }, 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 732eaec6a..9d6663928 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 @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { ERROR_CODE, UserAbortError } from "../../../lib/errors.ts"; +import { getLogLevel, setLogLevel } from "../../../lib/log.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import type { IOSNativeReadinessTarget } from "./native-readiness.ts"; import { @@ -1299,19 +1300,28 @@ describe("Clerk Native Application remote setup", () => { }); let thrown: unknown; + const previousLogLevel = getLogLevel(); try { - await applyRemoteSetup( - plan({ nativeApi: "satisfied", registration: "required" }), - api, - approvedTargetReader, - ); - } catch (error) { - thrown = error; + setLogLevel("debug"); + try { + await applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ); + } catch (error) { + thrown = error; + } + } finally { + setLogLevel(previousLogLevel); } expect(thrown).toBeDefined(); expect(String(thrown)).not.toContain(sensitiveBearer); expect(JSON.stringify(thrown)).not.toContain(sensitiveBearer); + expect(captured.err).toContain( + "Could not create the iOS application registration; underlying error details were omitted.", + ); expect(captured.err).not.toContain(sensitiveBearer); }); }); 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 99669479e..4a91d4230 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -5,7 +5,6 @@ import { CliError, ERROR_CODE, type ErrorCode, - errorMessage, throwUsageError, throwUserAbort, } from "../../../lib/errors.ts"; @@ -49,6 +48,13 @@ function rethrowKnownRemoteError(error: unknown): void { if (error instanceof CliError || error instanceof ApiError) throw error; } +function logSuppressedFailure(context: string): void { + // Remote and transport exceptions can contain response bodies, request + // headers, or credentials. Keep verbose diagnostics useful without ever + // interpolating arbitrary exception content. + log.debug(`${context}; underlying error details were omitted.`); +} + export type IOSNativeRemoteBlockerCode = | "target-not-selected" | "bundle-identifier-unavailable" @@ -555,7 +561,7 @@ export async function prepareIOSNativeRemoteSetup( readRemoteState(options.applicationId, options.instanceId, api), ); } catch (error) { - log.debug(`Could not inspect Clerk Native Application settings: ${errorMessage(error)}`); + logSuppressedFailure("Could not inspect Clerk Native Application settings"); rethrowKnownRemoteError(error); throw iosRemoteError( `Clerk Native Application settings could not be inspected. ${nativeSetupOutcome(options.applicationLinkChange)} Verify your application access and rerun clerk init.`, @@ -721,8 +727,8 @@ async function revalidateLocalTargetBeforeRemoteAccess( current = await withSpinner("Rechecking the selected Xcode target identity...", async () => targetReader(plan.localTarget!), ); - } catch (error) { - log.debug(`Could not recheck the selected Xcode target identity: ${errorMessage(error)}`); + } catch { + logSuppressedFailure("Could not recheck the selected Xcode target identity"); throw iosRemoteError( "The selected Xcode target identity could not be rechecked. No remote changes were made; rerun clerk init.", ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, @@ -800,10 +806,8 @@ export async function applyIOSNativeRemoteSetup( plan.registration === "required" ? await registrationRetryStore.getOrCreate(retryIdentity) : await registrationRetryStore.peek(retryIdentity); - } catch (error) { - log.debug( - `Could not read or preserve the iOS registration retry state: ${errorMessage(error)}`, - ); + } catch { + logSuppressedFailure("Could not read or preserve the iOS registration retry state"); throw iosRemoteError( "The iOS application registration retry state could not be read or preserved safely. The local setup remains intact, and no registration request was sent; verify CLI state directory access and rerun clerk init.", ); @@ -819,7 +823,7 @@ export async function applyIOSNativeRemoteSetup( reconciledPlan(plan, api), ); } catch (error) { - log.debug(`Could not recheck Clerk Native Application settings: ${errorMessage(error)}`); + logSuppressedFailure("Could not recheck Clerk Native Application settings"); rethrowKnownRemoteError(error); throw iosRemoteError( "Clerk Native Application settings could not be rechecked after the local setup. No remote changes were made; rerun clerk init.", @@ -867,14 +871,12 @@ export async function applyIOSNativeRemoteSetup( ); } } catch (error) { - log.debug(`Could not create the iOS application registration: ${errorMessage(error)}`); + logSuppressedFailure("Could not create the iOS application registration"); let registrations: IOSApplication[]; try { registrations = await api.listIOSApplications(plan.applicationId, plan.instanceId); } catch (fallbackError) { - log.debug( - `Could not confirm the iOS application registration: ${errorMessage(fallbackError)}`, - ); + logSuppressedFailure("Could not confirm the iOS application registration"); rethrowKnownRemoteError(fallbackError); throw iosRemoteError( "The iOS application registration could not be confirmed. The local setup remains intact; rerun clerk init to reconcile remote state.", @@ -909,12 +911,12 @@ export async function applyIOSNativeRemoteSetup( ); } } catch (error) { - log.debug(`Could not enable the Clerk Native API: ${errorMessage(error)}`); + logSuppressedFailure("Could not enable the Clerk Native API"); let current: NativeSettings; try { current = await api.getNativeSettings(plan.applicationId, plan.instanceId); } catch (fallbackError) { - log.debug(`Could not confirm Clerk Native API state: ${errorMessage(fallbackError)}`); + logSuppressedFailure("Could not confirm Clerk Native API state"); rethrowKnownRemoteError(fallbackError); throw iosRemoteError( "Native API enablement could not be confirmed. The local setup and any completed iOS registration remain intact; rerun clerk init.", @@ -936,7 +938,7 @@ export async function applyIOSNativeRemoteSetup( reconciledPlan(plan, api), ); } catch (error) { - log.debug(`Could not verify Clerk Native Application settings: ${errorMessage(error)}`); + logSuppressedFailure("Could not verify Clerk Native Application settings"); rethrowKnownRemoteError(error); throw iosRemoteError( "Clerk Native Application settings could not be verified. The local setup and any completed remote changes remain intact; rerun clerk init.", @@ -960,10 +962,8 @@ export async function applyIOSNativeRemoteSetup( "Preserved a newer iOS registration retry state created after this invocation began.", ); } - } catch (error) { - log.debug( - `Could not clear the verified iOS registration retry state: ${errorMessage(error)}`, - ); + } catch { + logSuppressedFailure("Could not clear the verified iOS registration retry state"); throw iosRemoteError( "Clerk Native Application settings were verified, but the local registration retry state could not be cleared. No further remote changes are required; verify CLI state directory access and rerun clerk init.", ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, From 0092465d7ba539b36c971072a579a87d877aa0a1 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 17:56:20 -0400 Subject: [PATCH 34/55] fix(init): verify iOS Run scheme keys --- .../src/commands/init/index-ios.test.ts | 77 ++++++++-- packages/cli-core/src/commands/init/index.ts | 40 +++-- .../init/ios/apply-cli-runtime.test.ts | 144 +++++++++++++++++- .../src/commands/init/ios/apply-cli.test.ts | 53 +++++-- .../cli-core/src/commands/init/ios/apply.ts | 81 +++++++--- 5 files changed, 342 insertions(+), 53 deletions(-) diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index f0a24d898..45242857d 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -82,7 +82,13 @@ function iosAppleEntitlementPlan( projectPath: "MyApp.xcodeproj", targetId: "TARGET", targetName: "MyApp", - files: [{ path: "MyApp/MyApp.entitlements", operation: "modify", expectedHash: "hash" }], + files: [ + { + path: "MyApp/MyApp.entitlements", + operation: "modify", + expectedHash: "hash", + }, + ], actions: ["Add the native Sign in with Apple entitlement."], blockers: [], ...overrides, @@ -283,6 +289,28 @@ describe("init iOS", () => { expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); }); + test("does not let agent mode guess an application for an existing runtime key", async () => { + setup({ isAgent: true, email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue(undefined); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: true, + verifiesExistingKey: true, + }), + ); + + await expect(init({ yes: true })).rejects.toThrow( + "Agent mode cannot choose its matching Clerk application safely; rerun with --app ", + ); + + expect(linkMod.link).not.toHaveBeenCalled(); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + }); + test("names an agent-created Clerk application after the selected Xcode target", async () => { setup({ isAgent: true, email: "test@test.com" }); const iosCtx = nativeIOSContext(); @@ -627,7 +655,10 @@ describe("init iOS", () => { const setupResult = iosSetupResult({ prebuiltAuthRequested: true, prebuiltAuthActive: true, - prebuiltAuthPlan: iosPrebuiltAuthPlan({ status: "satisfied", root: iosCtx.cwd }), + prebuiltAuthPlan: iosPrebuiltAuthPlan({ + status: "satisfied", + root: iosCtx.cwd, + }), prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), requiresLinkedApp: true, requiresDevelopmentKey: false, @@ -729,7 +760,12 @@ describe("init iOS", () => { status: "blocked", files: [], actions: [], - blockers: [{ code: "unsupported-entitlements", message: "Review the entitlements file." }], + blockers: [ + { + code: "unsupported-entitlements", + message: "Review the entitlements file.", + }, + ], }), requiresLinkedApp: true, requiresDevelopmentKey: false, @@ -770,7 +806,10 @@ describe("init iOS", () => { const setupResult = iosSetupResult({ prebuiltAuthRequested: true, prebuiltAuthActive: true, - prebuiltAuthPlan: iosPrebuiltAuthPlan({ status: "satisfied", root: iosCtx.cwd }), + prebuiltAuthPlan: iosPrebuiltAuthPlan({ + status: "satisfied", + root: iosCtx.cwd, + }), prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), requiresLinkedApp: true, requiresDevelopmentKey: false, @@ -813,7 +852,10 @@ describe("init iOS", () => { const setupResult = iosSetupResult({ prebuiltAuthRequested: true, prebuiltAuthActive: true, - prebuiltAuthPlan: iosPrebuiltAuthPlan({ status: "satisfied", root: iosCtx.cwd }), + prebuiltAuthPlan: iosPrebuiltAuthPlan({ + status: "satisfied", + root: iosCtx.cwd, + }), prebuiltAuthAppleEntitlementPlan: iosAppleEntitlementPlan(), requiresLinkedApp: true, requiresDevelopmentKey: false, @@ -1040,7 +1082,10 @@ describe("init iOS", () => { applyApple.mock.invocationCallOrder[0]!, ); expect(scaffold).toHaveBeenCalledWith( - expect.objectContaining({ iosNativeRemoteReady: true, iosNativeAppleReady: true }), + expect.objectContaining({ + iosNativeRemoteReady: true, + iosNativeAppleReady: true, + }), ); expect(stages()).toEqual([ "flags", @@ -1109,7 +1154,10 @@ describe("init iOS", () => { } as never); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( iosSetupResult({ - appleEntitlementPlan: iosAppleEntitlementPlan({ status: "satisfied", actions: [] }), + appleEntitlementPlan: iosAppleEntitlementPlan({ + status: "satisfied", + actions: [], + }), nativeAppleRequested: false, requiresLinkedApp: true, requiresDevelopmentKey: false, @@ -1419,7 +1467,10 @@ describe("init iOS", () => { publishableKey: linkedKey, }); spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( - iosRemotePlan({ applicationId: "app_matching", instanceId: "ins_matching" }), + iosRemotePlan({ + applicationId: "app_matching", + instanceId: "ins_matching", + }), ); await init({ yes: true }); @@ -1453,7 +1504,10 @@ describe("init iOS", () => { }); const localApply = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( - iosRemotePlan({ applicationId: "app_requested", instanceId: "ins_requested" }), + iosRemotePlan({ + applicationId: "app_requested", + instanceId: "ins_requested", + }), ); await init({ yes: true, app: "app_requested" }); @@ -1496,7 +1550,10 @@ describe("init iOS", () => { }); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( - iosRemotePlan({ applicationId: "app_requested", instanceId: "ins_requested" }), + iosRemotePlan({ + applicationId: "app_requested", + instanceId: "ins_requested", + }), ); await init({ yes: true, app: "app_requested" }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index a9206dc3c..b543b7894 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -153,7 +153,9 @@ export async function init(options: InitOptions = {}) { agent && (options.login || options.app) ? await validateAgentAuthentication() : undefined; if (validatedAgentAuthLabel === null) { throwUsageError( - `${options.app ? "--app" : "--login"} requires authentication that agent mode cannot complete interactively. Ask the user to run \`clerk auth login\`, then re-run \`clerk init\`.`, + `${ + options.app ? "--app" : "--login" + } requires authentication that agent mode cannot complete interactively. Ask the user to run \`clerk auth login\`, then re-run \`clerk init\`.`, ); } @@ -297,7 +299,9 @@ export async function init(options: InitOptions = {}) { const runtimeKeyVerificationPlan = dryRunSelection.state === "selected" && selectedTarget?.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "local-secrets-loader", + (call) => + call.publishableKeyWiring === "local-secrets-loader" || + call.publishableKeyWiring === "process-info-environment", ) ? await planIOSRuntimeKeyVerification({ root: ctx.cwd, @@ -451,7 +455,9 @@ export async function init(options: InitOptions = {}) { target: iosLocalSetup.nativeReadiness.target, appIdPrefix: options.appIdPrefix, ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion - ? { unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion } + ? { + unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion, + } : {}), }); } @@ -534,7 +540,9 @@ export async function init(options: InitOptions = {}) { .map((blocker) => ` • ${blocker.message}`) .join("\n"); throw new CliError( - `AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be prepared safely. No local setup changes were written${reasons ? `:\n${reasons}` : "."}`, + `AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be prepared safely. No local setup changes were written${ + reasons ? `:\n${reasons}` : "." + }`, { code: ERROR_CODE.IOS_SETUP_BLOCKED }, ); } @@ -548,7 +556,9 @@ export async function init(options: InitOptions = {}) { target: iosLocalSetup.nativeReadiness.target, appIdPrefix: options.appIdPrefix, ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion - ? { unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion } + ? { + unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion, + } : {}), ...(iosApplicationLinkChange ? { applicationLinkChange: iosApplicationLinkChange } : {}), agent, @@ -1410,7 +1420,10 @@ export function registerInit(program: Program): void { .option("-y, --yes", "Skip confirmation prompts") .option("--no-skills", "Skip the optional agent skills install prompt") .setExamples([ - { command: "clerk init", description: "Auto-detect framework and set up Clerk" }, + { + command: "clerk init", + description: "Auto-detect framework and set up Clerk", + }, { command: "clerk init --framework next", description: "Set up for Next.js (skips detection)", @@ -1419,7 +1432,10 @@ export function registerInit(program: Program): void { command: "clerk init --app app_123", description: "Link to a specific Clerk application", }, - { command: "clerk init --starter", description: "Create a new project with Clerk" }, + { + command: "clerk init --starter", + description: "Create a new project with Clerk", + }, { command: "clerk init --starter --framework next --pm bun", description: "Bootstrap with Bun", @@ -1448,8 +1464,14 @@ export function registerInit(program: Program): void { command: "clerk init --dry-run --target MyApp --json", description: "Inspect one iOS app target and emit a machine-readable plan", }, - { command: "clerk init -y", description: "Skip all confirmation prompts" }, - { command: "clerk init --no-skills", description: "Skip the agent skills install prompt" }, + { + command: "clerk init -y", + description: "Skip all confirmation prompts", + }, + { + command: "clerk init --no-skills", + description: "Skip the agent skills install prompt", + }, ]) .action(init); } diff --git a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts index 880ec94cc..bbeb48c09 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, setDefaultTimeout, spyOn, test } from "bun:test"; -import { cp, mkdtemp } from "node:fs/promises"; +import { cp, mkdir, mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { build as buildPbxProject, parse as parsePbxProject } from "@bacons/xcode/json"; @@ -28,6 +28,43 @@ import { ERROR_CODE } from "../../../lib/errors.ts"; setDefaultTimeout(15_000); +function runSchemeSource(key: string): string { + return ``; +} + +async function createProcessInfoFixture( + key: string, + options: { clerkSDK?: boolean } = {}, +): Promise<{ root: string; schemePath: string }> { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-scheme-runtime-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { + includeKey: false, + clerkSDK: options.clerkSDK, + }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { + Clerk.configure(publishableKey: ProcessInfo.processInfo.environment["CLERK_PUBLISHABLE_KEY"] ?? "") + } + var body: some Scene { + WindowGroup { Text("Hello").environment(Clerk.shared) } + } +} +`, + ); + const schemeDirectory = join(root, "MyApp.xcodeproj", "xcshareddata", "xcschemes"); + await mkdir(schemeDirectory, { recursive: true }); + const schemePath = join(schemeDirectory, "MyApp.xcscheme"); + await Bun.write(schemePath, runSchemeSource(key)); + return { root, schemePath }; +} + describe("clerk init iOS SDK runtime apply", () => { const captured = useCaptureLog(); test("does not bypass AuthView compatibility proof for unattributed Clerk products", async () => { @@ -109,7 +146,11 @@ describe("clerk init iOS SDK runtime apply", () => { test("does not bypass a non-attribution package blocker when all required products are linked", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-wrong-package-runtime-")); temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); const wrongPackageId = "919191919191919191919191"; const malformed = (await Bun.file(projectFile).text()) @@ -137,7 +178,11 @@ describe("clerk init iOS SDK runtime apply", () => { test("does not let an unattributed product hide another product's wrong package", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-mixed-package-runtime-")); temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); const projectFile = join(root, "MyApp.xcodeproj", "project.pbxproj"); const wrongPackageId = "919191919191919191919191"; const mixed = (await Bun.file(projectFile).text()) @@ -252,6 +297,99 @@ describe("clerk init iOS SDK runtime apply", () => { expect(await treeDigest(root)).toEqual(before); }); + test("a mismatched selected-target Run-scheme key blocks before local mutation", async () => { + const schemeKey = developmentPublishableKey("scheme-existing.clerk.example"); + const linkedKey = developmentPublishableKey("scheme-linked.clerk.example"); + const { root } = await createProcessInfoFixture(schemeKey, { + clerkSDK: false, + }); + const before = await treeDigest(root); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + + expect(setup).toMatchObject({ + requiresDevelopmentKey: true, + verifiesExistingKey: true, + runtimeKeyVerificationPlan: { + status: "ready", + source: { kind: "run-scheme" }, + }, + }); + await expect(applyIOSPlannedLocalSetup(setup, linkedKey)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_PUBLISHABLE_KEY_MISMATCH, + }); + expect(await treeDigest(root)).toEqual(before); + expect(JSON.stringify(setup)).not.toContain(schemeKey); + expect(`${captured.out}\n${captured.err}`).not.toContain(schemeKey); + expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); + }); + + test("a Run-scheme change during local setup rolls every project edit back", async () => { + const expectedKey = developmentPublishableKey("scheme-verified.clerk.example"); + const concurrentKey = developmentPublishableKey("scheme-concurrent.clerk.example"); + const { root, schemePath } = await createProcessInfoFixture(expectedKey, { + clerkSDK: false, + }); + const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); + const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); + const projectBefore = await Bun.file(projectPath).bytes(); + const entitlementsBefore = await Bun.file(entitlementsPath).bytes(); + + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + await expect( + applyIOSPlannedLocalSetup(setup, expectedKey, { + beforePostWriteValidation: async () => { + await Bun.write(schemePath, runSchemeSource(concurrentKey)); + }, + }), + ).rejects.toThrow("SDK change was restored byte-for-byte"); + + expect(await Bun.file(projectPath).bytes()).toEqual(projectBefore); + expect(await Bun.file(entitlementsPath).bytes()).toEqual(entitlementsBefore); + expect(await Bun.file(schemePath).text()).toBe(runSchemeSource(concurrentKey)); + expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); + expect(`${captured.out}\n${captured.err}`).not.toContain(concurrentKey); + }); + + test("revalidates a satisfied Run-scheme source immediately before remote setup", async () => { + const expectedKey = developmentPublishableKey("clerk.example.test"); + const concurrentKey = developmentPublishableKey("scheme-race.clerk.example"); + const { root, schemePath } = await createProcessInfoFixture(expectedKey); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + const before = await treeDigest(root); + + await expect( + applyIOSPlannedLocalSetup(setup, expectedKey, { + beforePostWriteValidation: async () => { + await Bun.write(schemePath, runSchemeSource(concurrentKey)); + }, + }), + ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE }); + + expect(await Bun.file(schemePath).text()).toBe(runSchemeSource(concurrentKey)); + expect(await treeDigest(root)).not.toEqual(before); + expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); + expect(`${captured.out}\n${captured.err}`).not.toContain(concurrentKey); + }); + test("a matching expected app key permits SDK installation regardless of local profile", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-match-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index 445edb0b3..616af6233 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -414,7 +414,11 @@ struct MyApp: App { test("refuses a LocalSecrets compatibility path without proven SwiftUI environment injection", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-secrets-auth-view-")); temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); await addStarterContentViewToFixture(root); const appPath = join(root, "MyApp", "MyAppApp.swift"); const appSource = (await Bun.file(appPath).text()).replace("import ClerkKitUI\n", "").replace( @@ -625,7 +629,10 @@ struct MyApp: App { configDir, ); expect(optedIn.exitCode).toBe(0); - expect(currentAppleConnection()).toMatchObject({ enabled: true, authenticatable: true }); + expect(currentAppleConnection()).toMatchObject({ + enabled: true, + authenticatable: true, + }); // Keep the local entitlement as detection evidence while simulating a // Clerk connection that has not been opted into for this invocation. @@ -637,7 +644,10 @@ struct MyApp: App { ); expect(withoutOptIn.exitCode).toBe(0); - expect(currentAppleConnection()).toEqual({ enabled: false, authenticatable: true }); + expect(currentAppleConnection()).toEqual({ + enabled: false, + authenticatable: true, + }); expect(`${withoutOptIn.stdout}\n${withoutOptIn.stderr}`).not.toContain( "Native Sign in with Apple enabled in Clerk", ); @@ -672,7 +682,9 @@ struct MyApp: App { test("uses the linked key host over an unrelated root env during aggregate setup", async () => { const root = await createUnconfiguredFixture(); const configDir = await createIsolatedCLIState(); - const unrelatedKey = `pk_test_${Buffer.from("unrelated-root.clerk.example$").toString("base64")}`; + const unrelatedKey = `pk_test_${Buffer.from("unrelated-root.clerk.example$").toString( + "base64", + )}`; const existingEnv = `CLERK_PUBLISHABLE_KEY=${unrelatedKey}\n`; await Bun.write(join(root, ".env"), existingEnv); @@ -820,7 +832,11 @@ import SwiftUI test("links both products only to a fresh explicitly selected second target", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-second-target-")); temporaryDirectories.push(root); - await createIOSFixture(root, { clerkSDK: false, includeKey: false, secondTarget: true }); + await createIOSFixture(root, { + clerkSDK: false, + includeKey: false, + secondTarget: true, + }); const configDir = await createIsolatedCLIState(); const result = await runCLI( @@ -833,8 +849,14 @@ import SwiftUI const inspection = await inspectIOSProject(root); const primary = inspection.appTargets.find((target) => target.name === "MyApp"); const selected = inspection.appTargets.find((target) => target.name === "AdminApp"); - expect(primary?.packages).toMatchObject({ clerkKit: "absent", clerkKitUI: "absent" }); - expect(selected?.packages).toMatchObject({ clerkKit: "linked", clerkKitUI: "linked" }); + expect(primary?.packages).toMatchObject({ + clerkKit: "absent", + clerkKitUI: "absent", + }); + expect(selected?.packages).toMatchObject({ + clerkKit: "linked", + clerkKitUI: "linked", + }); }); test("validates an apparently linked graph before treating it as a no-op", async () => { @@ -918,7 +940,11 @@ import SwiftUI test("an already-linked SDK returns a read-only runtime verification without prompting or writing", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-verification-")); temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); const entitlementsPath = join(root, "MyApp", "MyApp.entitlements"); await Bun.write( entitlementsPath, @@ -941,7 +967,10 @@ import SwiftUI expect(result.runtimeKeyVerificationPlan).toMatchObject({ status: "ready", - localSecretsPath: "MyApp/LocalSecrets.plist", + source: { + kind: "local-secrets-plist", + path: "MyApp/LocalSecrets.plist", + }, }); expect(confirmation).not.toHaveBeenCalled(); expect(await treeDigest(root)).toEqual(before); @@ -953,7 +982,11 @@ import SwiftUI test("preserves a LocalSecrets runtime sink that has no valid key", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-preflight-")); temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await createIOSFixture(root, { + complete: true, + includeKey: false, + localSecrets: true, + }); await Bun.write( join(root, "MyApp", "LocalSecrets.plist"), 'CLERK_PUBLISHABLE_KEYreplace-me', diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 6652ecc47..e7fd14f47 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -336,7 +336,9 @@ export async function applyIOSLocalSetup( } if (selection.state === "not-found") { throwUsageError( - `The iOS target "${selection.requested}" was not found. Available targets: ${selection.candidates.join(", ") || "none"}.`, + `The iOS target "${selection.requested}" was not found. Available targets: ${ + selection.candidates.join(", ") || "none" + }.`, ); } throw iosSetupError( @@ -383,7 +385,9 @@ export async function applyIOSLocalSetup( } if (prebuiltAuthRequested && inspectedPrebuiltAuthPlan.status === "blocked") { throw iosSetupError( - `The prebuilt AuthView flow could not be added safely. No local files were changed:\n${blockerList(inspectedPrebuiltAuthPlan.blockers)}`, + `The prebuilt AuthView flow could not be added safely. No local files were changed:\n${blockerList( + inspectedPrebuiltAuthPlan.blockers, + )}`, ); } const prebuiltAuthActive = @@ -409,6 +413,9 @@ export async function applyIOSLocalSetup( const hasLocalSecretsConfigure = selectedTarget.swift.configureCalls.some( (call) => call.publishableKeyWiring === "local-secrets-loader", ); + const hasRunSchemeConfigure = selectedTarget.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "process-info-environment", + ); const hasSatisfiedLocalRuntimeSink = configureStep?.status === "satisfied" && inspection.localPublishableKey.source != null && @@ -416,7 +423,7 @@ export async function applyIOSLocalSetup( (sink) => sink.path === inspection.localPublishableKey.source, ); const plannedRuntimeKeyVerification = - hasLocalSecretsConfigure || hasSatisfiedLocalRuntimeSink + hasLocalSecretsConfigure || hasRunSchemeConfigure || hasSatisfiedLocalRuntimeSink ? await planIOSRuntimeKeyVerification({ root: options.root, projectPath: selection.projectPath, @@ -511,7 +518,9 @@ export async function applyIOSLocalSetup( : undefined; if (appleEntitlementPlan?.status === "blocked") { throw iosSetupError( - `Native Sign in with Apple could not be configured safely. No local files were changed:\n${blockerList(appleEntitlementPlan.blockers)}`, + `Native Sign in with Apple could not be configured safely. No local files were changed:\n${blockerList( + appleEntitlementPlan.blockers, + )}`, ); } const { sdkInstallPlan, reviewOnlyUnattributedInstall } = normalizeIOSSDKInstallPlanForSetup({ @@ -522,12 +531,16 @@ export async function applyIOSLocalSetup( if (plannedRuntimeKeyVerification?.status === "blocked") { throw iosSetupError( - `The existing iOS runtime publishable key could not be verified safely. clerk init will not change that compatibility file; repair it manually, then rerun the command. No local files were changed:\n${blockerList(plannedRuntimeKeyVerification.blockers)}`, + `The existing iOS runtime publishable key could not be verified safely. clerk init will not change that compatibility file; repair it manually, then rerun the command. No local files were changed:\n${blockerList( + plannedRuntimeKeyVerification.blockers, + )}`, ); } if (directConfigPlan?.status === "blocked") { throw iosSetupError( - `The selected SwiftUI app could not be configured automatically. No local files were changed:\n${blockerList(directConfigPlan.blockers)}`, + `The selected SwiftUI app could not be configured automatically. No local files were changed:\n${blockerList( + directConfigPlan.blockers, + )}`, ); } if ( @@ -544,9 +557,9 @@ export async function applyIOSLocalSetup( `The fresh SwiftUI target was not edited because ${reason}. Resolve that setup or configure Clerk directly in the @main initializer, then rerun clerk init. No local files were changed.`, ); } - if (hasLocalSecretsConfigure && !runtimeKeyVerificationPlan) { + if ((hasLocalSecretsConfigure || hasRunSchemeConfigure) && !runtimeKeyVerificationPlan) { throw iosSetupError( - "An existing LocalSecrets-based Clerk configuration was found, but it does not provide one proven development publishable key to the selected target. clerk init preserves custom runtime sources and will not write this plist; add the intended key manually, then rerun the command.", + "An existing Clerk runtime-key configuration was found, but it does not provide one proven development publishable key to the selected target. clerk init preserves custom runtime sources and will not rewrite them; repair the intended key manually, then rerun the command.", ); } if (prebuiltAuthActive) { @@ -564,7 +577,9 @@ export async function applyIOSLocalSetup( const verb = installPlan.products.length === 1 ? "is" : "are"; log.info( dim( - `\n${formatProducts(installPlan.products)} ${verb} already linked to ${selection.targetName}.`, + `\n${formatProducts(installPlan.products)} ${verb} already linked to ${ + selection.targetName + }.`, ), ); } @@ -572,12 +587,16 @@ export async function applyIOSLocalSetup( const verb = installPlan.products.length === 1 ? "is" : "are"; log.info( dim( - `\n${formatProducts(installPlan.products)} ${verb} already linked to ${selection.targetName}, but package attribution is not represented in this project graph. The existing Xcode package graph will be left unchanged.`, + `\n${formatProducts(installPlan.products)} ${verb} already linked to ${ + selection.targetName + }, but package attribution is not represented in this project graph. The existing Xcode package graph will be left unchanged.`, ), ); } else if (installPlan.status === "blocked") { throw iosSetupError( - `The Clerk iOS SDK could not be installed automatically:\n${blockerList(installPlan.blockers)}`, + `The Clerk iOS SDK could not be installed automatically:\n${blockerList( + installPlan.blockers, + )}`, ); } const plannedPaths: Array<{ absolutePath: string; displayPath: string }> = []; @@ -798,7 +817,10 @@ export async function applyIOSLocalSetup( ); } if (hasLocalWrites && !options.yes) { - const proceed = await confirm({ message: "Apply these local iOS changes?", default: false }); + const proceed = await confirm({ + message: "Apply these local iOS changes?", + default: false, + }); if (!proceed) throwUserAbort(); } @@ -872,7 +894,9 @@ async function prepareSDKForCommit( } if (prepared.status === "blocked") { throw iosSetupError( - `The Clerk iOS SDK could no longer be prepared safely. No local setup changes were written:\n${preparedSDKBlockers(prepared)}`, + `The Clerk iOS SDK could no longer be prepared safely. No local setup changes were written:\n${preparedSDKBlockers( + prepared, + )}`, ); } return prepared; @@ -891,7 +915,9 @@ async function preparePrebuiltAuthForCommit( } if (prepared.status === "blocked") { throw iosSetupError( - `The prebuilt AuthView flow could no longer be prepared safely. No local setup changes were written:\n${blockerList(prepared.plan.blockers)}`, + `The prebuilt AuthView flow could no longer be prepared safely. No local setup changes were written:\n${blockerList( + prepared.plan.blockers, + )}`, ); } return prepared; @@ -915,7 +941,9 @@ async function prepareAssociatedDomainForCommit( if (prepared.status === "blocked") { const reasons = blockerList(prepared.plan.blockers); throw iosSetupError( - `The Clerk Associated Domain could no longer be prepared safely. No local setup changes were written${reasons ? `:\n${reasons}` : "."}`, + `The Clerk Associated Domain could no longer be prepared safely. No local setup changes were written${ + reasons ? `:\n${reasons}` : "." + }`, ); } return prepared; @@ -926,7 +954,9 @@ async function prepareAppleEntitlementForCommit( baseMutations: readonly IOSFileMutation[], ): Promise { if (!plan) return undefined; - const prepared = await prepareIOSAppleEntitlementMutation(plan, { baseMutations }); + const prepared = await prepareIOSAppleEntitlementMutation(plan, { + baseMutations, + }); if (prepared.status === "stale") { throw iosSetupError( "An iOS entitlements file changed after the Sign in with Apple preview. No local setup changes were written; rerun clerk init.", @@ -935,7 +965,9 @@ async function prepareAppleEntitlementForCommit( } if (prepared.status === "blocked") { throw iosSetupError( - `The Sign in with Apple entitlement could no longer be prepared safely. No local setup changes were written:\n${blockerList(prepared.plan.blockers)}`, + `The Sign in with Apple entitlement could no longer be prepared safely. No local setup changes were written:\n${blockerList( + prepared.plan.blockers, + )}`, ); } return prepared; @@ -1143,8 +1175,8 @@ export async function applyIOSPlannedLocalSetup( } const key = requireDevelopmentKey(setup, publishableKey); - // Existing LocalSecrets values are verified before any PBX mutation. A - // mismatched application can therefore never change the selected target. + // Existing LocalSecrets and Run-scheme values are verified before any local + // mutation. A mismatched application can therefore never change the target. if (setup.runtimeKeyVerificationPlan) { const result = await withSpinner("Verifying the existing iOS publishable key...", async () => verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan!, key), @@ -1165,7 +1197,9 @@ export async function applyIOSPlannedLocalSetup( } if (preparedDirect.status === "blocked") { throw iosSetupError( - `The Swift app entry source could no longer be configured safely. No local setup changes were written:\n${blockerList(preparedDirect.plan.blockers)}`, + `The Swift app entry source could no longer be configured safely. No local setup changes were written:\n${blockerList( + preparedDirect.plan.blockers, + )}`, ); } // Verify an existing inline key before using the supplied key to derive @@ -1370,6 +1404,11 @@ export async function applyIOSPlannedLocalSetup( } if (setup.runtimeKeyVerificationPlan) { + if (localMutations.length === 0) { + await options.beforePostWriteValidation?.(); + const result = await verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan, key); + assertRuntimeKeyVerificationMatched(result); + } log.info(dim("The existing publishable key matches the linked Clerk application.")); } } @@ -1397,7 +1436,7 @@ function assertRuntimeKeyVerificationMatched( } if (result.status === "stale") { throw iosSetupError( - "LocalSecrets.plist changed after the read-only verification preflight. No key was changed; rerun clerk init.", + "The selected iOS runtime-key source changed after the read-only verification preflight. No key was changed; rerun clerk init.", ERROR_CODE.IOS_SETUP_STALE, ); } From faace9ed9d7fd3163e274274ceac295d8821d3a7 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 17:50:47 -0400 Subject: [PATCH 35/55] fix(cli): validate native settings responses --- .../commands/init/ios/native-remote.test.ts | 117 +++++++++++++++++- .../src/commands/init/ios/native-remote.ts | 38 ++++-- .../cli-core/src/lib/plapi-native.test.ts | 36 +++++- packages/cli-core/src/lib/plapi.ts | 38 +++++- 4 files changed, 217 insertions(+), 12 deletions(-) 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 9d6663928..2c7aa6cbb 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 @@ -15,7 +15,11 @@ import { type IOSNativeRemoteTargetReader, type IOSNativeRemoteTargetSnapshot, } from "./native-remote.ts"; -import type { IOSApplication, NativeSettings } from "../../../lib/plapi.ts"; +import { + validateNativeSettings, + type IOSApplication, + type NativeSettings, +} from "../../../lib/plapi.ts"; import type { IOSNativeRegistrationRetryIdentity, IOSNativeRegistrationRetryStore, @@ -34,6 +38,10 @@ function nativeSettings(apiEnabled: boolean): NativeSettings { return { object: "native_settings", api_enabled: apiEnabled }; } +function malformedNativeSettings(apiEnabled: unknown): NativeSettings { + return { object: "native_settings", api_enabled: apiEnabled } as unknown as NativeSettings; +} + function registration( appIdPrefix = LOCAL_PREFIX, bundleId = BUNDLE_IDENTIFIER, @@ -358,6 +366,26 @@ describe("Clerk Native Application remote setup", () => { ); }); + test("rejects malformed Native settings before planning", () => { + let thrown: unknown; + try { + buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + nativeSettings: malformedNativeSettings("false"), + registrations: [registration()], + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + test.each([ { name: "local Bundle ID", @@ -1253,6 +1281,93 @@ describe("Clerk Native Application remote setup", () => { expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); }); + test("does not let a follow-up GET hide a malformed Native settings PATCH response", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[exactRegistration]], + enable: async () => malformedNativeSettings("false"), + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "GET native settings")).toHaveLength(1); + expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); + }); + + test("does not let a follow-up GET hide a Native settings client parser failure", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[exactRegistration]], + enable: async () => validateNativeSettings(malformedNativeSettings("false")), + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "GET native settings")).toHaveLength(1); + expect(calls.filter((call) => call === "PATCH native settings")).toHaveLength(1); + }); + + test("rejects malformed Native settings returned by ambiguity confirmation", async () => { + const exactRegistration = registration(); + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false), malformedNativeSettings("false")], + registrationReads: [[exactRegistration]], + enable: async () => { + throw new Error("connection reset after enable"); + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed Native settings during final verification", async () => { + const exactRegistration = registration(); + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false), malformedNativeSettings("false")], + registrationReads: [[exactRegistration], [exactRegistration]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + test("fails final verification when the approved remote postcondition is not present", async () => { const { api } = scriptedAPI({ nativeReads: [nativeSettings(false), nativeSettings(true)], 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 4a91d4230..23cfd0fcb 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -15,6 +15,7 @@ import { enableNativeApi, getNativeSettings, listIOSApplications, + validateNativeSettings, type IOSApplication, type NativeSettings, } from "../../../lib/plapi.ts"; @@ -321,6 +322,7 @@ export function buildIOSNativeRemotePlan(options: { nativeSettings: NativeSettings; registrations: IOSApplication[]; }): IOSNativeRemotePlan { + const nativeSettings = validateNativeSettings(options.nativeSettings); const identity = localIdentity(options.target); const blockers = [...identity.blockers]; const bundleIdentifier = identity.bundleIdentifier; @@ -410,7 +412,7 @@ export function buildIOSNativeRemotePlan(options: { } } - const nativeApi = options.nativeSettings.api_enabled ? "satisfied" : "required"; + const nativeApi = nativeSettings.api_enabled ? "satisfied" : "required"; const actions: string[] = []; if (registration === "required" && appIdPrefix && bundleIdentifier) { actions.push( @@ -452,7 +454,7 @@ async function readRemoteState( api.getNativeSettings(applicationId, instanceId), api.listIOSApplications(applicationId, instanceId), ]); - return { nativeSettings, registrations }; + return { nativeSettings: validateNativeSettings(nativeSettings), registrations }; } function formatBlockers(plan: IOSNativeRemotePlan): string { @@ -898,23 +900,43 @@ export async function applyIOSNativeRemoteSetup( } if (currentPlan.nativeApi === "required") { + let enableError: unknown; + let enabledResponse: unknown; + let enableCompleted = false; try { - const enabled = await withSpinner("Enabling the Clerk Native API...", async () => + enabledResponse = await withSpinner("Enabling the Clerk Native API...", async () => api.enableNativeApi(plan.applicationId, plan.instanceId, { idempotencyKey: nativeAPIIdempotencyKey, }), ); + enableCompleted = true; + } catch (error) { + logSuppressedFailure("Could not enable the Clerk Native API"); + if (error instanceof CliError && error.code === ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE) { + throw error; + } + enableError = error; + } + + // A successful HTTP response with a malformed DTO is authoritative + // evidence of a protocol violation, not an ambiguous transport outcome. + // Validate outside the transport catch so a later GET cannot hide it. + if (enableCompleted) { + const enabled = validateNativeSettings(enabledResponse); if (!enabled.api_enabled) { - throw iosRemoteError( + enableError = iosRemoteError( "Clerk did not report the Native API as enabled. The local setup and any completed registration remain intact; rerun clerk init.", ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, ); } - } catch (error) { - logSuppressedFailure("Could not enable the Clerk Native API"); + } + + if (enableError) { let current: NativeSettings; try { - current = await api.getNativeSettings(plan.applicationId, plan.instanceId); + current = validateNativeSettings( + await api.getNativeSettings(plan.applicationId, plan.instanceId), + ); } catch (fallbackError) { logSuppressedFailure("Could not confirm Clerk Native API state"); rethrowKnownRemoteError(fallbackError); @@ -923,7 +945,7 @@ export async function applyIOSNativeRemoteSetup( ); } if (!current.api_enabled) { - rethrowKnownRemoteError(error); + rethrowKnownRemoteError(enableError); throw iosRemoteError( "The Native API could not be enabled. The local setup and any completed iOS registration remain intact; rerun clerk init to retry safely.", ); diff --git a/packages/cli-core/src/lib/plapi-native.test.ts b/packages/cli-core/src/lib/plapi-native.test.ts index 730150ee8..403f19e25 100644 --- a/packages/cli-core/src/lib/plapi-native.test.ts +++ b/packages/cli-core/src/lib/plapi-native.test.ts @@ -9,7 +9,7 @@ mock.module("./credential-store.ts", () => ({ const { createIOSApplication, enableNativeApi, getNativeSettings, listIOSApplications } = await import("./plapi.ts"); -const { PlapiError } = await import("./errors.ts"); +const { ERROR_CODE, PlapiError } = await import("./errors.ts"); describe("PLAPI native application client", () => { const originalEnv = { ...process.env }; @@ -78,6 +78,40 @@ describe("PLAPI native application client", () => { expect(result).toEqual(responseBody); }); + test.each([ + { name: "an array", body: [] }, + { name: "the wrong object discriminator", body: { object: "instance", api_enabled: true } }, + { + name: "a non-boolean enabled value", + body: { object: "native_settings", api_enabled: "false" }, + }, + ])("rejects $name from the Native settings GET", async ({ body }) => { + stubFetch(async () => Response.json(body)); + + await expect(getNativeSettings("app_abc", "development")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test.each([ + { name: "an array", body: [] }, + { name: "the wrong object discriminator", body: { object: "instance", api_enabled: true } }, + { + name: "a non-boolean enabled value", + body: { object: "native_settings", api_enabled: "false" }, + }, + ])("rejects $name from the Native settings PATCH", async ({ body }) => { + stubFetch(async () => Response.json(body)); + + await expect( + enableNativeApi("app_abc", "development", { idempotencyKey: "enable-native-api-123" }), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + test("lists the public iOS application DTOs", async () => { let capturedUrl = ""; let capturedMethod = ""; diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 7ce1afb23..46b579c10 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -243,6 +243,40 @@ export type NativeSettings = { api_enabled: boolean; }; +function unexpectedNativeSettingsResponse(): CliError { + return new CliError("Clerk returned an invalid Native API settings response.", { + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); +} + +/** + * Validate Native settings at runtime. This is exported so callers that inject + * an API implementation in tests or integrations retain the same fail-closed + * behavior as the production HTTP client. + */ +export function validateNativeSettings(value: unknown): NativeSettings { + if ( + value == null || + typeof value !== "object" || + Array.isArray(value) || + (value as Record).object !== "native_settings" || + typeof (value as Record).api_enabled !== "boolean" + ) { + throw unexpectedNativeSettingsResponse(); + } + return value as NativeSettings; +} + +async function readNativeSettingsResponse(response: Response): Promise { + let value: unknown; + try { + value = await response.json(); + } catch { + throw unexpectedNativeSettingsResponse(); + } + return validateNativeSettings(value); +} + export type IOSApplication = { object: "ios_application"; id: string; @@ -271,7 +305,7 @@ export async function getNativeSettings( getPlapiBaseUrl(), ); const response = await plapiFetch("GET", url); - return response.json() as Promise; + return readNativeSettingsResponse(response); } export async function enableNativeApi( @@ -287,7 +321,7 @@ export async function enableNativeApi( body: JSON.stringify({ api_enabled: true }), idempotencyKey: options?.idempotencyKey, }); - return response.json() as Promise; + return readNativeSettingsResponse(response); } export async function listIOSApplications( From 579659f5450b328af55bf0e203813293ceec5adf Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 18:02:10 -0400 Subject: [PATCH 36/55] fix(init): revalidate iOS runtime keys before remote setup --- packages/cli-core/src/commands/init/README.md | 2 +- .../src/commands/init/frameworks/ios.ts | 2 +- .../src/commands/init/index-ios.test.ts | 103 +++++++++++++++++- packages/cli-core/src/commands/init/index.ts | 13 +++ .../cli-core/src/commands/init/ios/apply.ts | 1 - .../cli-core/src/commands/init/ios/plan.ts | 2 +- 6 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 6628ac27f..071b8767f 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -66,7 +66,7 @@ For a native iOS project, normal `clerk init` re-runs the semantic inspection, b For a safely inspectable fresh SwiftUI target, the same command selects or creates a Clerk application, fetches only its development publishable key, adds `import ClerkKit`, configures Clerk directly in the single shipping `@main` initializer, and adds `.environment(Clerk.shared)` to the proven `WindowGroup` root. The key is public client configuration and is written directly to Swift source, matching the iOS Quickstart. It remains in memory until commit and is never printed, returned in JSON, sent to telemetry, or written through an intermediate `.env` or plist. Existing inline keys are compared with the selected application's key and never replaced on a mismatch. -Existing proven LocalSecrets and ProcessInfo/Run-scheme integrations remain compatibility paths and are never migrated automatically. An existing valid LocalSecrets value is recognized and verified against the linked application, but `clerk init` never writes the plist or its ignore rules. Missing, different, tracked/shared/malformed, or custom runtime-key sources are preserved and require manual review. +Existing proven LocalSecrets and ProcessInfo/Run-scheme integrations remain compatibility paths and are never migrated automatically. Their existing development publishable key is verified against the linked application, but `clerk init` never writes the plist, scheme, or ignore rules. Missing, different, tracked/shared/malformed, or custom runtime-key sources are preserved and require manual review. The CLI previews every planned local path and asks once before writing. Human users can pass `--yes` to skip that confirmation. Agent/non-TTY mode must pass `--yes` explicitly for iOS mutations; agent mode never implies consent here. A planned file with existing Git changes is refused unless `--allow-dirty` is also explicit, and `--yes` does not imply `--allow-dirty`. diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 071f68a65..42408c197 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -67,7 +67,7 @@ export const ios: FrameworkScaffold = { }) : undefined; const runtimeKeyVerificationPlan = - selection.state === "selected" && hasLocalSecretsConfigure + selection.state === "selected" && (hasLocalSecretsConfigure || hasProcessInfoConfigure) ? await planIOSRuntimeKeyVerification({ root: ctx.cwd, projectPath: selection.projectPath, diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index 45242857d..2175cce8d 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -28,13 +28,14 @@ import * as telemetryMod from "../../lib/telemetry.ts"; import { getLogLevel, setLogLevel } from "../../lib/log.ts"; import * as iosFileTransactionMod from "./ios/file-transaction.ts"; import { init } from "./index.ts"; -import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; import type { IOSLocalSetupResult } from "./ios/apply.ts"; import type { IOSAppleEntitlementPlan } from "./ios/apple-entitlement.ts"; import type { IOSNativeApplePlan } from "./ios/native-apple.ts"; import type { IOSNativeRemotePlan } from "./ios/native-remote.ts"; import type { IOSNativeReadinessTarget } from "./ios/native-readiness.ts"; import type { IOSPrebuiltAuthPlan } from "./ios/prebuilt-auth.ts"; +import type { IOSRuntimeKeyVerificationPlan } from "./ios/runtime-key.ts"; const VALID_DEVELOPMENT_KEY = `pk_test_${btoa("example.clerk.accounts.dev$")}`; @@ -148,6 +149,23 @@ function iosSetupResult(overrides: Partial = {}): IOSLocalS }; } +function iosRuntimeKeyVerificationPlan(): IOSRuntimeKeyVerificationPlan { + return { + schemaVersion: 1, + kind: "clerk-ios-runtime-key-verification", + status: "ready", + root: "/tmp/test", + projectPath: "MyApp.xcodeproj", + targetId: "TARGET", + source: { + kind: "run-scheme", + path: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", + expectedHash: "scheme-hash", + }, + blockers: [], + }; +} + function selectedNativeTarget( overrides: Partial> = {}, ): Extract { @@ -1445,6 +1463,89 @@ describe("init iOS", () => { expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); }); + test("revalidates an existing runtime key after local setup and before Native mutation", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const linkedKey = `pk_test_${Buffer.from("revalidated.clerk.example$").toString("base64")}`; + const verificationPlan = iosRuntimeKeyVerificationPlan(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: true, + verifiesExistingKey: true, + runtimeKeyVerificationPlan: verificationPlan, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_revalidated" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( + undefined, + ); + const revalidateRuntime = spyOn(iosApplyMod, "verifyIOSRuntimeKeySetup").mockResolvedValue( + undefined, + ); + const applyRemote = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockResolvedValue( + undefined, + ); + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_revalidated", + instanceId: "ins_revalidated", + publishableKey: linkedKey, + }); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan({ applicationId: "app_revalidated", instanceId: "ins_revalidated" }), + ); + + await init({ yes: true }); + + expect(revalidateRuntime).toHaveBeenCalledWith(verificationPlan, linkedKey); + expect(commitLocal.mock.invocationCallOrder[0]).toBeLessThan( + revalidateRuntime.mock.invocationCallOrder[0]!, + ); + expect(revalidateRuntime.mock.invocationCallOrder[0]).toBeLessThan( + applyRemote.mock.invocationCallOrder[0]!, + ); + }); + + test("blocks Native mutation when the final runtime-key revalidation fails", async () => { + setup({ email: "test@test.com" }); + const iosCtx = nativeIOSContext(); + const linkedKey = `pk_test_${Buffer.from("runtime-race.clerk.example$").toString("base64")}`; + const verificationPlan = iosRuntimeKeyVerificationPlan(); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: true, + verifiesExistingKey: true, + runtimeKeyVerificationPlan: verificationPlan, + }); + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_runtime_race" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue(undefined); + spyOn(iosApplyMod, "verifyIOSRuntimeKeySetup").mockRejectedValue( + new CliError("The selected iOS runtime-key source changed.", { + code: ERROR_CODE.IOS_SETUP_STALE, + }), + ); + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_runtime_race", + instanceId: "ins_runtime_race", + publishableKey: linkedKey, + }); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan({ applicationId: "app_runtime_race", instanceId: "ins_runtime_race" }), + ); + + await expect(init({ yes: true })).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + }); + + expect(nativeRemoteMod.applyIOSNativeRemoteSetup).not.toHaveBeenCalled(); + }); + test("matching an existing iOS runtime key is a read-only authenticated no-op", async () => { const { captured } = setup({ email: "test@test.com" }); const iosCtx = nativeIOSContext(); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index b543b7894..83f9cefcb 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -82,6 +82,7 @@ import { applyIOSPlannedLocalSetup, normalizeIOSSDKInstallPlanForSetup, planIOSPrebuiltAuthRuntimeBlockers, + verifyIOSRuntimeKeySetup, type IOSLocalSetupResult, } from "./ios/apply.ts"; import { @@ -671,6 +672,12 @@ export async function init(options: InitOptions = {}) { applicationId: nativeRemotePlan.applicationId, phase: "native-application", }); + if (iosSetupForCommit.runtimeKeyVerificationPlan) { + await verifyIOSRuntimeKeySetup( + iosSetupForCommit.runtimeKeyVerificationPlan, + keys.publishableKey, + ); + } try { setTelemetryStage("ios_native_setup"); await applyIOSNativeRemoteSetup(nativeRemotePlan); @@ -693,6 +700,12 @@ export async function init(options: InitOptions = {}) { applicationId: nativeApplePlan.applicationId, phase: "native-apple", }); + if (iosSetupForCommit.runtimeKeyVerificationPlan) { + await verifyIOSRuntimeKeySetup( + iosSetupForCommit.runtimeKeyVerificationPlan, + keys.publishableKey, + ); + } try { setTelemetryStage("ios_apple_setup"); await applyIOSNativeAppleConnection(nativeApplePlan); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index e7fd14f47..4122fa2ef 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -1421,7 +1421,6 @@ export async function verifyIOSRuntimeKeySetup( verifyIOSRuntimeKey(plan, linkedPublishableKey), ); assertRuntimeKeyVerificationMatched(result); - log.info(dim("The existing publishable key matches the linked Clerk application.")); } function assertRuntimeKeyVerificationMatched( diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index a48bd366e..ca6ddcd6d 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -286,7 +286,7 @@ export function buildIOSSetupPlan( "Configure Clerk with a publishable key", configuredStatus, runtimeKeyVerificationBlocked - ? `The existing LocalSecrets.plist compatibility path cannot be verified safely. clerk init preserves this file and will not replace it. ${runtimeKeyVerificationBlocker ?? "Repair it manually, then rerun the command."}` + ? `The existing iOS runtime-key compatibility path cannot be verified safely. clerk init preserves this source and will not replace it. ${runtimeKeyVerificationBlocker ?? "Repair it manually, then rerun the command."}` : publishableKeyBlocked ? inspection.localPublishableKey.conflict ? "Multiple effective publishable-key sources point at different Clerk instances. Resolve the conflict before configuring the app." From 141b7cc00c3b9ded21fb78efc3e78f0b1990d6a6 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 18:20:58 -0400 Subject: [PATCH 37/55] fix(cli): validate native application responses --- .../commands/init/ios/native-remote.test.ts | 132 ++++++++++++++++++ .../src/commands/init/ios/native-remote.ts | 31 ++-- .../cli-core/src/lib/plapi-native.test.ts | 90 ++++++++++++ packages/cli-core/src/lib/plapi.ts | 43 +++++- 4 files changed, 285 insertions(+), 11 deletions(-) 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 2c7aa6cbb..9f095af80 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 @@ -57,6 +57,16 @@ function registration( }; } +function malformedRegistration(): IOSApplication { + return { + object: "ios_application", + id: "iosapp_malformed", + app_id_prefix: LOCAL_PREFIX, + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + } as unknown as IOSApplication; +} + function selectedTarget( options: { bundleIdentifier?: string; @@ -386,6 +396,42 @@ describe("Clerk Native Application remote setup", () => { }); }); + test("rejects malformed iOS registrations before planning", () => { + let thrown: unknown; + try { + buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget(), + nativeSettings: nativeSettings(true), + registrations: [malformedRegistration()], + }); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed registrations during the initial remote audit", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[malformedRegistration()]], + }); + + await expect( + prepareIOSNativeRemoteSetup(prepareOptions(), { api, prompts: prompts() }), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls).not.toContain("POST iOS registration"); + }); + test.each([ { name: "local Bundle ID", @@ -1090,6 +1136,26 @@ describe("Clerk Native Application remote setup", () => { expect(calls).not.toContain("PATCH native settings"); }); + test("rejects malformed registrations before the pre-write registration decision", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[malformedRegistration()]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls).not.toContain("POST iOS registration"); + }); + test("creates the iOS registration before enabling Native API", async () => { const exactRegistration = registration(); const { api, calls } = scriptedAPI({ @@ -1130,6 +1196,72 @@ describe("Clerk Native Application remote setup", () => { expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); }); + test("does not let a fallback list hide a malformed registration create response", async () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + create: async () => malformedRegistration(), + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "GET iOS registrations")).toHaveLength(1); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("rejects malformed registrations while confirming an ambiguous create", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[], [malformedRegistration()]], + create: async () => { + throw new Error("connection reset after create"); + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + + test("rejects malformed registrations during final verification", async () => { + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [malformedRegistration()]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + test("reuses a pending registration key across invocations until final verification", async () => { const retry = memoryRegistrationRetryStore(); const ambiguous = scriptedAPI({ 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 23cfd0fcb..ddbef69a8 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -15,6 +15,8 @@ import { enableNativeApi, getNativeSettings, listIOSApplications, + validateIOSApplication, + validateIOSApplications, validateNativeSettings, type IOSApplication, type NativeSettings, @@ -323,6 +325,7 @@ export function buildIOSNativeRemotePlan(options: { registrations: IOSApplication[]; }): IOSNativeRemotePlan { const nativeSettings = validateNativeSettings(options.nativeSettings); + const registrations = validateIOSApplications(options.registrations); const identity = localIdentity(options.target); const blockers = [...identity.blockers]; const bundleIdentifier = identity.bundleIdentifier; @@ -348,7 +351,7 @@ export function buildIOSNativeRemotePlan(options: { } const matchingBundle = bundleIdentifier - ? options.registrations.filter((registration) => registration.bundle_id === bundleIdentifier) + ? registrations.filter((registration) => registration.bundle_id === bundleIdentifier) : []; const invalidRegisteredPrefixes = matchingBundle.filter( (registration) => @@ -454,7 +457,10 @@ async function readRemoteState( api.getNativeSettings(applicationId, instanceId), api.listIOSApplications(applicationId, instanceId), ]); - return { nativeSettings: validateNativeSettings(nativeSettings), registrations }; + return { + nativeSettings: validateNativeSettings(nativeSettings), + registrations: validateIOSApplications(registrations), + }; } function formatBlockers(plan: IOSNativeRemotePlan): string { @@ -855,12 +861,14 @@ export async function applyIOSNativeRemoteSetup( ); } try { - const created = await withSpinner("Registering the iOS application with Clerk...", async () => - api.createIOSApplication( - plan.applicationId, - plan.instanceId, - { appIdPrefix: plan.appIdPrefix!, bundleId: plan.bundleIdentifier! }, - { idempotencyKey: observedRegistrationRetryKey }, + const created = validateIOSApplication( + await withSpinner("Registering the iOS application with Clerk...", async () => + api.createIOSApplication( + plan.applicationId, + plan.instanceId, + { appIdPrefix: plan.appIdPrefix!, bundleId: plan.bundleIdentifier! }, + { idempotencyKey: observedRegistrationRetryKey }, + ), ), ); if ( @@ -874,9 +882,14 @@ export async function applyIOSNativeRemoteSetup( } } catch (error) { logSuppressedFailure("Could not create the iOS application registration"); + if (error instanceof CliError && error.code === ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE) { + throw error; + } let registrations: IOSApplication[]; try { - registrations = await api.listIOSApplications(plan.applicationId, plan.instanceId); + registrations = validateIOSApplications( + await api.listIOSApplications(plan.applicationId, plan.instanceId), + ); } catch (fallbackError) { logSuppressedFailure("Could not confirm the iOS application registration"); rethrowKnownRemoteError(fallbackError); diff --git a/packages/cli-core/src/lib/plapi-native.test.ts b/packages/cli-core/src/lib/plapi-native.test.ts index 403f19e25..c66c9ac37 100644 --- a/packages/cli-core/src/lib/plapi-native.test.ts +++ b/packages/cli-core/src/lib/plapi-native.test.ts @@ -123,6 +123,7 @@ describe("PLAPI native application client", () => { bundle_id: "com.example.coolappy", created_at: 1_787_000_000_000, updated_at: 1_787_000_000_000, + future_field: "preserved", }, ]; stubFetch(async (input, init) => { @@ -139,6 +140,53 @@ describe("PLAPI native application client", () => { ); expect(result).toEqual(responseBody); expect(result[0]).not.toHaveProperty("team_id"); + expect((result[0] as unknown as Record).future_field).toBe("preserved"); + }); + + test.each([ + { name: "a non-array root", body: {} }, + { name: "a null item", body: [null] }, + { + name: "an incomplete item", + body: [ + { + object: "ios_application", + id: "iosapp_123", + app_id_prefix: "ABCD123456", + created_at: 1_787_000_000_000, + updated_at: 1_787_000_000_000, + }, + ], + }, + { + name: "an item with a mistyped field", + body: [ + { + object: "ios_application", + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + created_at: "1787000000000", + updated_at: 1_787_000_000_000, + }, + ], + }, + ])("rejects $name from the iOS application list", async ({ body }) => { + stubFetch(async () => Response.json(body)); + + await expect(listIOSApplications("app_abc", "ins_dev_123")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed JSON from the iOS application list", async () => { + stubFetch(async () => new Response("{", { status: 200 })); + + await expect(listIOSApplications("app_abc", "ins_dev_123")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); }); test("creates an iOS application with the public field names and idempotency key", async () => { @@ -182,6 +230,48 @@ describe("PLAPI native application client", () => { expect(result).toEqual(responseBody); }); + test("rejects an incomplete iOS application create response", async () => { + stubFetch(async () => + Response.json( + { + object: "ios_application", + id: "iosapp_123", + app_id_prefix: "ABCD123456", + bundle_id: "com.example.coolappy", + }, + { status: 201 }, + ), + ); + + await expect( + createIOSApplication( + "app_abc", + "development", + { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, + { idempotencyKey: "create-ios-app-123" }, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + + test("rejects malformed JSON from the iOS application create response", async () => { + stubFetch(async () => new Response("{", { status: 201 })); + + await expect( + createIOSApplication( + "app_abc", + "development", + { appIdPrefix: "ABCD123456", bundleId: "com.example.coolappy" }, + { idempotencyKey: "create-ios-app-123" }, + ), + ).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); + }); + test("preserves typed PLAPI errors from native endpoints without credential data", async () => { stubFetch( async () => diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index 46b579c10..e390127f1 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -286,6 +286,45 @@ export type IOSApplication = { updated_at: number; }; +function unexpectedIOSApplicationResponse(): CliError { + return new CliError("Clerk returned an invalid iOS application response.", { + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); +} + +export function validateIOSApplication(value: unknown): IOSApplication { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw unexpectedIOSApplicationResponse(); + } + const item = value as Record; + if ( + item.object !== "ios_application" || + typeof item.id !== "string" || + typeof item.app_id_prefix !== "string" || + typeof item.bundle_id !== "string" || + typeof item.created_at !== "number" || + !Number.isFinite(item.created_at) || + typeof item.updated_at !== "number" || + !Number.isFinite(item.updated_at) + ) { + throw unexpectedIOSApplicationResponse(); + } + return value as IOSApplication; +} + +export function validateIOSApplications(value: unknown): IOSApplication[] { + if (!Array.isArray(value)) throw unexpectedIOSApplicationResponse(); + return value.map(validateIOSApplication); +} + +async function readIOSApplicationResponse(response: Response): Promise { + try { + return await response.json(); + } catch { + throw unexpectedIOSApplicationResponse(); + } +} + export type CreateIOSApplicationParams = { appIdPrefix: string; bundleId: string; @@ -333,7 +372,7 @@ export async function listIOSApplications( getPlapiBaseUrl(), ); const response = await plapiFetch("GET", url); - return response.json() as Promise; + return validateIOSApplications(await readIOSApplicationResponse(response)); } export async function createIOSApplication( @@ -353,7 +392,7 @@ export async function createIOSApplication( }), idempotencyKey: options.idempotencyKey, }); - return response.json() as Promise; + return validateIOSApplication(await readIOSApplicationResponse(response)); } export interface FetchApplicationOptions { From c9a5aa968b45c3fd51dcd35fa722b91b1f956f25 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 20:01:46 -0400 Subject: [PATCH 38/55] fix(init): report stale registration retry locks --- .../ios/native-registration-retry.test.ts | 50 ++++++++++++- .../init/ios/native-registration-retry.ts | 62 ++++++++++++++-- .../commands/init/ios/native-remote.test.ts | 74 ++++++++++++++++++- .../src/commands/init/ios/native-remote.ts | 27 ++++++- 4 files changed, 201 insertions(+), 12 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts index 5f434b09c..b5e190fb8 100644 --- a/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; import { + IOSNativeRegistrationRetryLockError, createIOSNativeRegistrationRetryStore, type IOSNativeRegistrationRetryIdentity, } from "./native-registration-retry.ts"; @@ -119,7 +120,7 @@ describe("iOS native registration retry state", () => { await expect(store.getOrCreate(target)).rejects.toThrow("retry record is malformed"); }); - test("fails closed without stealing an abandoned stale filesystem lock", async () => { + test("reports an actionable stale lock without stealing it and reuses the key after recovery", async () => { const stateDirectory = await temporaryStateDirectory(); const store = createIOSNativeRegistrationRetryStore(() => stateDirectory, { lockRetryMs: 1, @@ -136,7 +137,52 @@ describe("iOS native registration retry state", () => { await utimes(lock, stale, stale); expect(first).toStartWith("clerk-init-ios-registration-"); - await expect(store.getOrCreate(target)).rejects.toThrow("lock is stale"); + let caught: unknown; + try { + await store.getOrCreate(target); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(IOSNativeRegistrationRetryLockError); + expect(caught).toMatchObject({ + status: "stale", + recoveryPath: join("Clerk CLI config directory", "idempotency", `${filename!}.lock`), + }); + expect((caught as Error).message).not.toContain(stateDirectory); + expect(await readdir(lock)).toEqual([]); + + // Manual recovery removes only the empty lock. The pending record remains, + // so an ambiguous POST is retried with the exact same idempotency key. + await rm(lock, { recursive: true }); + expect(await store.getOrCreate(target)).toBe(first); + }); + + test("distinguishes a live-looking busy lock without suggesting stale recovery", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory, { + lockRetryMs: 1, + lockTimeoutMs: 5, + lockStaleMs: 60_000, + }); + const target = identity(); + await store.getOrCreate(target); + const directory = join(stateDirectory, "idempotency"); + const [filename] = await readdir(directory); + const lock = join(directory, `${filename!}.lock`); + await mkdir(lock); + + let caught: unknown; + try { + await store.getOrCreate(target); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(IOSNativeRegistrationRetryLockError); + expect(caught).toMatchObject({ + status: "busy", + recoveryPath: join("Clerk CLI config directory", "idempotency", `${filename!}.lock`), + }); + expect((caught as Error).message).not.toContain(stateDirectory); expect(await readdir(lock)).toEqual([]); }); }); diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts index 133e77cb4..481d6a524 100644 --- a/packages/cli-core/src/commands/init/ios/native-registration-retry.ts +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts @@ -1,7 +1,8 @@ import { createHash, randomUUID } from "node:crypto"; import { lstat, mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; import { setTimeout as sleep } from "node:timers/promises"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { getConfigFile } from "../../../lib/config.ts"; import { withHomeFsAccess } from "../../../lib/host-execution.ts"; @@ -35,6 +36,29 @@ export interface IOSNativeRegistrationRetryStore { clear(identity: IOSNativeRegistrationRetryIdentity, expectedKey: string): Promise; } +export type IOSNativeRegistrationRetryLockStatus = "busy" | "stale"; + +/** + * A fail-closed retry-state lock failure with a path safe to render publicly. + * `recoveryPath` is always relative to the user's home or Clerk config root; + * the raw absolute filesystem path must stay out of logs and telemetry. + */ +export class IOSNativeRegistrationRetryLockError extends Error { + readonly status: IOSNativeRegistrationRetryLockStatus; + readonly recoveryPath: string; + + constructor(status: IOSNativeRegistrationRetryLockStatus, recoveryPath: string) { + super( + status === "stale" + ? `The Clerk iOS registration retry-state lock is stale: ${recoveryPath}` + : "Timed out waiting for the Clerk iOS registration retry-state lock.", + ); + this.name = "IOSNativeRegistrationRetryLockError"; + this.status = status; + this.recoveryPath = recoveryPath; + } +} + interface IOSNativeRegistrationRetryRecord { schemaVersion: 1; kind: "clerk-ios-native-registration-retry"; @@ -83,6 +107,35 @@ function lockPath(baseDirectory: string, identity: IOSNativeRegistrationRetryIde return `${retryPath(baseDirectory, identity)}.lock`; } +function containedRelativePath(root: string, path: string): string | undefined { + const candidate = relative(resolve(root), resolve(path)); + if ( + candidate === "" || + candidate === ".." || + candidate.startsWith(`..${sep}`) || + isAbsolute(candidate) + ) { + return undefined; + } + return candidate; +} + +function publicLockPath(baseDirectory: string, path: string): string { + const relativeToConfig = containedRelativePath(baseDirectory, path); + // `path` is constructed below this base. Keep a defensive basename-only + // fallback so an unexpected caller can never put an absolute path in output. + const configPath = relativeToConfig ?? join(RETRY_DIRECTORY, path.split(sep).at(-1) ?? "lock"); + const configuredDirectory = process.env.CLERK_CONFIG_DIR; + if (configuredDirectory && resolve(configuredDirectory) === resolve(baseDirectory)) { + return join("$CLERK_CONFIG_DIR", configPath); + } + + const relativeToHome = containedRelativePath(homedir(), path); + if (relativeToHome) return join("~", relativeToHome); + + return join("Clerk CLI config directory", configPath); +} + async function acquireLock( baseDirectory: string, identity: IOSNativeRegistrationRetryIdentity, @@ -105,10 +158,9 @@ async function acquireLock( if (isMissingFile(statError)) continue; throw statError; } - throw new Error( - stale - ? `The Clerk iOS registration retry-state lock is stale and was left in place for safety: ${path}` - : "Timed out waiting for the Clerk iOS registration retry-state lock.", + throw new IOSNativeRegistrationRetryLockError( + stale ? "stale" : "busy", + publicLockPath(baseDirectory, path), ); } await sleep(options.lockRetryMs); 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 9f095af80..e68ca69db 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 @@ -20,9 +20,10 @@ import { type IOSApplication, type NativeSettings, } from "../../../lib/plapi.ts"; -import type { - IOSNativeRegistrationRetryIdentity, - IOSNativeRegistrationRetryStore, +import { + IOSNativeRegistrationRetryLockError, + type IOSNativeRegistrationRetryIdentity, + type IOSNativeRegistrationRetryStore, } from "./native-registration-retry.ts"; const APPLICATION_ID = "app_native_test"; @@ -1175,6 +1176,37 @@ describe("Clerk Native Application remote setup", () => { ); }); + test("surfaces safe manual recovery for a stale retry lock before remote access", async () => { + const recoveryPath = "$CLERK_CONFIG_DIR/idempotency/ios-native-registration-test.json.lock"; + const retryStore: IOSNativeRegistrationRetryStore = { + async getOrCreate() { + throw new IOSNativeRegistrationRetryLockError("stale", recoveryPath); + }, + async peek() { + throw new Error("unexpected peek"); + }, + async clear() { + throw new Error("unexpected clear"); + }, + }; + const { api, calls } = scriptedAPI(); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + retryStore, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED, + message: expect.stringContaining( + `Confirm no other Clerk command is running, then remove only the stale lock directory at \`${recoveryPath}\``, + ), + }); + expect(calls).toEqual([]); + }); + test("reconciles an ambiguous registration-create error when the exact row now exists", async () => { const exactRegistration = registration(); const ambiguousError = new Error("connection reset after create"); @@ -1333,6 +1365,42 @@ describe("Clerk Native Application remote setup", () => { expect(retry.pending(registrationRetryIdentity())).toBeUndefined(); }); + test("surfaces stale-lock recovery when verified remote state cannot clear retry state", async () => { + const recoveryPath = "~/.config/clerk-cli/idempotency/ios-native-registration-test.json.lock"; + const retryKey = "clerk-init-ios-registration-11111111-1111-4111-8111-111111111111"; + const retryStore: IOSNativeRegistrationRetryStore = { + async getOrCreate() { + return retryKey; + }, + async peek() { + return retryKey; + }, + async clear() { + throw new IOSNativeRegistrationRetryLockError("stale", recoveryPath); + }, + }; + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + retryStore, + ), + ).rejects.toMatchObject({ + code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + message: expect.stringContaining( + `no further remote changes are required. Confirm no other Clerk command is running, then remove only the stale lock directory at \`${recoveryPath}\``, + ), + }); + expect(calls.filter((call) => call === "POST iOS registration")).toHaveLength(1); + }); + test("rechecks remote state after a paused invocation acquires a newer retry generation", async () => { const retry = memoryRegistrationRetryStore(); let releaseGet!: () => void; 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 ddbef69a8..2a417a560 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -30,6 +30,7 @@ import type { } from "./native-readiness.ts"; import { buildIOSNativeReadinessAudit } from "./native-readiness.ts"; import { + IOSNativeRegistrationRetryLockError, cliStateIOSNativeRegistrationRetryStore, type IOSNativeRegistrationRetryIdentity, type IOSNativeRegistrationRetryStore, @@ -58,6 +59,19 @@ function logSuppressedFailure(context: string): void { log.debug(`${context}; underlying error details were omitted.`); } +function retryLockFailureMessage( + error: IOSNativeRegistrationRetryLockError, + remoteSettingsVerified: boolean, +): string { + const outcome = remoteSettingsVerified + ? "Clerk Native Application settings were verified, and no further remote changes are required." + : "The local setup remains intact, and no registration request was sent."; + if (error.status === "busy") { + return `Another Clerk command is updating the iOS registration retry state. ${outcome} Wait for it to finish, then rerun \`clerk init\`.`; + } + return `An interrupted Clerk command left a stale iOS registration retry-state lock. ${outcome} Confirm no other Clerk command is running, then remove only the stale lock directory at \`${error.recoveryPath}\` and rerun \`clerk init\`.`; +} + export type IOSNativeRemoteBlockerCode = | "target-not-selected" | "bundle-identifier-unavailable" @@ -814,8 +828,11 @@ export async function applyIOSNativeRemoteSetup( plan.registration === "required" ? await registrationRetryStore.getOrCreate(retryIdentity) : await registrationRetryStore.peek(retryIdentity); - } catch { + } catch (error) { logSuppressedFailure("Could not read or preserve the iOS registration retry state"); + if (error instanceof IOSNativeRegistrationRetryLockError) { + throw iosRemoteError(retryLockFailureMessage(error, false)); + } throw iosRemoteError( "The iOS application registration retry state could not be read or preserved safely. The local setup remains intact, and no registration request was sent; verify CLI state directory access and rerun clerk init.", ); @@ -997,8 +1014,14 @@ export async function applyIOSNativeRemoteSetup( "Preserved a newer iOS registration retry state created after this invocation began.", ); } - } catch { + } catch (error) { logSuppressedFailure("Could not clear the verified iOS registration retry state"); + if (error instanceof IOSNativeRegistrationRetryLockError) { + throw iosRemoteError( + retryLockFailureMessage(error, true), + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } throw iosRemoteError( "Clerk Native Application settings were verified, but the local registration retry state could not be cleared. No further remote changes are required; verify CLI state directory access and rerun clerk init.", ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, From 1362ba2387e77e46e58ae27c9eab5ee43fea04ae Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 20:39:12 -0400 Subject: [PATCH 39/55] fix(init): reject empty Bundle ID components --- .../commands/init/ios/native-remote.test.ts | 24 +++++++++++++++++++ .../src/commands/init/ios/native-remote.ts | 5 ++-- 2 files changed, 27 insertions(+), 2 deletions(-) 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 e68ca69db..b85b486c4 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 @@ -327,10 +327,34 @@ describe("Clerk Native Application remote setup", () => { expect(validateAppIdPrefix("x".repeat(11))).toBeUndefined(); expect(validateBundleIdentifier("NativeApp")).toBe("NativeApp"); expect(validateBundleIdentifier("com.example-NativeApp")).toBe("com.example-NativeApp"); + expect(validateBundleIdentifier(".")).toBeUndefined(); + expect(validateBundleIdentifier(".com.example")).toBeUndefined(); + expect(validateBundleIdentifier("com..example")).toBeUndefined(); + expect(validateBundleIdentifier("com.example.")).toBeUndefined(); expect(validateBundleIdentifier("com.example_bad")).toBeUndefined(); expect(validateBundleIdentifier("x".repeat(256))).toBeUndefined(); }); + test.each([".", ".com.example", "com..example", "com.example."])( + "blocks the malformed Bundle ID %s before planning registration", + (bundleIdentifier) => { + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + target: selectedTarget({ bundleIdentifier }), + nativeSettings: nativeSettings(false), + registrations: [], + }); + + expect(result.status).toBe("blocked"); + expect(result.registration).toBe("blocked"); + expect(result.actions).not.toContainEqual(expect.stringContaining("Register iOS Bundle ID")); + expect(result.blockers).toContainEqual( + expect.objectContaining({ code: "bundle-identifier-invalid" }), + ); + }, + ); + test("accepts a legacy App ID Prefix that differs from DEVELOPMENT_TEAM", async () => { const { api } = scriptedAPI({ nativeReads: [nativeSettings(true)], 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 2a417a560..df9fa1ebc 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -210,7 +210,8 @@ export function validateAppIdPrefix(value: string | undefined): string | undefin export function validateBundleIdentifier(value: string | undefined): string | undefined { return value && value.length <= BUNDLE_IDENTIFIER_MAX_LENGTH && - BUNDLE_IDENTIFIER_PATTERN.test(value) + BUNDLE_IDENTIFIER_PATTERN.test(value) && + value.split(".").every((component) => component.length > 0) ? value : undefined; } @@ -286,7 +287,7 @@ function localIdentity(target: IOSNativeReadinessTarget): { blockers.push( blocker( "bundle-identifier-invalid", - `The selected target's Bundle ID must contain between 1 and ${BUNDLE_IDENTIFIER_MAX_LENGTH} ASCII letters, numbers, hyphens, or periods.`, + `The selected target's Bundle ID must contain between 1 and ${BUNDLE_IDENTIFIER_MAX_LENGTH} ASCII letters, numbers, hyphens, or periods, with no empty dot-separated components.`, ), ); } From 21eac9a517806ddd8a47f490d7e47501af4b7af3 Mon Sep 17 00:00:00 2001 From: seanperez Date: Fri, 28 Aug 2026 21:13:22 -0400 Subject: [PATCH 40/55] fix(init): reject invalid SwiftUI environment overloads --- .../commands/init/ios/direct-config.test.ts | 28 +++++++++++++++++ .../src/commands/init/ios/plan.test.ts | 29 ++++++++++++++++++ .../src/commands/init/ios/swift-app-root.ts | 2 +- .../src/commands/init/ios/swift.test.ts | 30 +++++++++++++++++++ .../cli-core/src/commands/init/ios/swift.ts | 8 +++-- 5 files changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 42064d21c..9da7a32c1 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -340,6 +340,34 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(before); }); + test("blocks invalid EnvironmentValues overloads at the WindowGroup root", async () => { + for (const keyPath of ["\\.self", ".self"]) { + const root = await fixture(); + await replaceSource( + root, + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView().environment(${keyPath}, Clerk.shared) + } + } +} +`, + ); + + const before = await readFile(appSourcePath(root)); + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("blocked"); + expect(blockerCodes(plan)).toContain("conflicting-environment"); + expect(await readFile(appSourcePath(root))).toEqual(before); + } + }); + test("refuses indirect Clerk access before an existing inline configuration", async () => { const root = await fixture(); await replaceSource( diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 6f556ff5a..821d14273 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -233,6 +233,35 @@ struct MyApp: App { ).toContain("not proven on the shipping WindowGroup root"); }); + test("does not satisfy environment setup from an invalid EnvironmentValues overload", async () => { + for (const keyPath of ["\\.self", ".self"]) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-root-environment-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit + import ClerkKitUI + import SwiftUI + @main struct MyApp: App { + var body: some Scene { + WindowGroup { AuthView().environment(${keyPath}, Clerk.shared) } + } + }`, + ); + + const inspection = await inspectIOSProject(root); + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.environmentInjections).toEqual([]); + expect(inspection.appTargets[0]?.swift.rootEnvironmentInjections).toEqual([]); + expect(plan.steps.find((step) => step.id === "inject-clerk-environment")).toMatchObject({ + status: "required", + automatable: false, + }); + } + }); + test("advertises a proven prebuilt AuthView scaffold without selecting it", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-prebuilt-auth-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift-app-root.ts b/packages/cli-core/src/commands/init/ios/swift-app-root.ts index 30e3ce4bc..f0db1f139 100644 --- a/packages/cli-core/src/commands/init/ios/swift-app-root.ts +++ b/packages/cli-core/src/commands/init/ios/swift-app-root.ts @@ -353,7 +353,7 @@ function clerkEnvironment( modifier.openingParenthesis + 1, modifier.closingParenthesis, ); - if (/^\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*$/.test(argumentsSource)) { + if (/^\s*Clerk\s*\.\s*shared\s*$/.test(argumentsSource)) { found = true; } else if (/\bClerk\s*\.\s*shared\b/.test(argumentsSource)) { conflicting = true; 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 1e76909c9..6dc192b69 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -517,6 +517,36 @@ Clerk.configure(publishableKey: key)`, expect(JSON.stringify(inspection)).not.toContain("must-not-leak"); }); + test("rejects EnvironmentValues overloads as Clerk environment injections", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-environment-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + + for (const keyPath of ["\\.self", ".self"]) { + await Bun.write( + path, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { + ContentView().environment(${keyPath}, Clerk.shared) + } + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); + expect(inspection.environmentInjections).toEqual([]); + expect(inspection.rootEnvironmentInjections).toEqual([]); + expect(inspection.status).toBe("partial"); + } + }); + test("retains only decoded metadata for a valid inline publishable key", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 68a535d63..8ada268c8 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -18,7 +18,7 @@ const CLERK_NATIVE_AUTH_FLOW = const CLERK_MAGIC_LINK_AUTH_FLOW = /(?:\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*signInWithEmailLink|\.\s*sendEmailLink)\s*\(/; const CLERK_ENVIRONMENT_INJECTION = - /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\s*\)/; + /\.\s*environment\s*\(\s*Clerk\s*\.\s*shared\s*\)/; const CLERK_ENVIRONMENT_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; const CLERK_AUTH_VIEW = /\bAuthView\s*\(/; const CLERK_KIT_IMPORT = @@ -819,7 +819,11 @@ export async function inspectSwiftSources( if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_INJECTION)) { environmentInjections.push(evidence); } - if (importsClerkModule && appRoot?.clerkEnvironment.found) { + if ( + importsClerkModule && + appRoot?.clerkEnvironment.found && + !appRoot.clerkEnvironment.conflicting + ) { rootEnvironmentInjections.push(evidence); } if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_CONSUMER)) { From 2c39864451eb9e088660e11d2040478d475b17c1 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 09:41:51 -0400 Subject: [PATCH 41/55] refactor(ios): require app selection for custom keys --- .../src/commands/init/frameworks/ios.ts | 40 ++--- packages/cli-core/src/commands/init/index.ts | 43 ++--- .../cli-core/src/commands/init/ios/apply.ts | 161 +++--------------- .../cli-core/src/commands/init/ios/plan.ts | 157 +++++------------ packages/cli-core/src/commands/link/index.ts | 32 +++- packages/cli-core/src/lib/app-picker.ts | 10 +- 6 files changed, 135 insertions(+), 308 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 42408c197..50629c006 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -4,7 +4,6 @@ import { inspectIOSProject } from "../ios/inspect.ts"; import { buildIOSSetupPlan } from "../ios/plan.ts"; import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "../ios/products.ts"; import { planIOSAssociatedDomain } from "../ios/associated-domain.ts"; -import { planIOSRuntimeKeyVerification } from "../ios/runtime-key.ts"; /** * iOS (Swift) support for `clerk init`. @@ -14,8 +13,8 @@ import { planIOSRuntimeKeyVerification } from "../ios/runtime-key.ts"; * file. The dedicated iOS apply phase safely handles the selected target's SPM * product linkage before this scaffolder runs. For a safely inspectable fresh * SwiftUI target, init configures the linked development publishable key - * directly in the shipping @main App source. Existing LocalSecrets and - * ProcessInfo integrations remain read-only compatibility paths. + * directly in the shipping @main App source. Existing custom key sources are + * preserved and require the developer to select their Clerk application. * * Docs: https://clerk.com/docs/ios/getting-started/quickstart */ @@ -38,11 +37,8 @@ export const ios: FrameworkScaffold = { : undefined; const productDecision = target ? clerkKitUIInstallDecision(target) : "prebuilt"; const includeClerkKitUI = productDecision === "prebuilt"; - const hasLocalSecretsConfigure = target?.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "local-secrets-loader", - ); - const hasProcessInfoConfigure = target?.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "process-info-environment", + const hasCustomConfigure = target?.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "custom", ); const shouldPlanDirectConfig = selection.state === "selected" && @@ -62,21 +58,13 @@ export const ios: FrameworkScaffold = { root: ctx.cwd, projectPath: selection.projectPath, targetId: selection.targetId, - deferToPublishableKey: directConfigPlan?.status === "ready", + deferToPublishableKey: + directConfigPlan?.status === "ready" || hasCustomConfigure === true, allowMissingEntitlementsCreation: true, }) : undefined; - const runtimeKeyVerificationPlan = - selection.state === "selected" && (hasLocalSecretsConfigure || hasProcessInfoConfigure) - ? await planIOSRuntimeKeyVerification({ - root: ctx.cwd, - projectPath: selection.projectPath, - targetId: selection.targetId, - }) - : undefined; const setupPlan = buildIOSSetupPlan(inspection, { directConfigPlan, - runtimeKeyVerificationPlan, associatedDomainPlan, }); const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); @@ -137,15 +125,13 @@ export const ios: FrameworkScaffold = { const configureInstructions = needsAttention("configure-publishable-key") ? selection.state === "selected" && configureStep?.status === "blocked" ? [configureStep.description] - : hasLocalSecretsConfigure - ? [configureStep?.description ?? "Repair the existing LocalSecrets runtime wiring."] - : hasProcessInfoConfigure - ? [ - "Keep the existing ProcessInfo integration connected to CLERK_PUBLISHABLE_KEY in the enabled Run scheme. This is a compatibility path; clerk init does not write or replace Run-scheme variables.", - ] - : [ - 'Configure Clerk directly in the single shipping `@main` App initializer with the selected application\'s development publishable key: `Clerk.configure(publishableKey: "")`. For a safely inspectable SwiftUI target, `clerk init` applies this with the value redacted from previews and output.', - ] + : hasCustomConfigure + ? [ + "Keep the existing custom Clerk.configure(...) source unchanged. Select the Clerk application it belongs to during setup, or pass --app in agent mode; clerk init does not inspect or rewrite the custom key value.", + ] + : [ + 'Configure Clerk directly in the single shipping `@main` App initializer with the selected application\'s development publishable key: `Clerk.configure(publishableKey: "")`. For a safely inspectable SwiftUI target, `clerk init` applies this with the value redacted from previews and output.', + ] : []; const authFlowInstructions = needsAttention("add-authentication-flow") ? [ diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 83f9cefcb..2b7c4e7aa 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -74,7 +74,6 @@ import { planIOSAssociatedDomain } from "./ios/associated-domain.ts"; import { planIOSAppleEntitlement } from "./ios/apple-entitlement.ts"; import { planIOSPrebuiltAuth } from "./ios/prebuilt-auth.ts"; import { planIOSSDKInstall } from "./ios/install-sdk.ts"; -import { planIOSRuntimeKeyVerification } from "./ios/runtime-key.ts"; import { resolveIOSDevelopmentPublicKey } from "./ios/development-key.ts"; import { createIOSDryRunOutput, formatIOSSetupPlan } from "./ios/output.ts"; import { @@ -82,7 +81,6 @@ import { applyIOSPlannedLocalSetup, normalizeIOSSDKInstallPlanForSetup, planIOSPrebuiltAuthRuntimeBlockers, - verifyIOSRuntimeKeySetup, type IOSLocalSetupResult, } from "./ios/apply.ts"; import { @@ -242,6 +240,9 @@ export async function init(options: InitOptions = {}) { ) : undefined; const productDecision = selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined; + const hasCustomConfigure = selectedTarget?.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "custom", + ); const inspectedPrebuiltAuthPlan = dryRunSelection.state === "selected" ? await planIOSPrebuiltAuth({ @@ -293,23 +294,11 @@ export async function init(options: InitOptions = {}) { root: ctx.cwd, projectPath: dryRunSelection.projectPath, targetId: dryRunSelection.targetId, - deferToPublishableKey: directConfigPlan?.status === "ready", + deferToPublishableKey: + directConfigPlan?.status === "ready" || hasCustomConfigure === true, allowMissingEntitlementsCreation: true, }) : undefined; - const runtimeKeyVerificationPlan = - dryRunSelection.state === "selected" && - selectedTarget?.swift.configureCalls.some( - (call) => - call.publishableKeyWiring === "local-secrets-loader" || - call.publishableKeyWiring === "process-info-environment", - ) - ? await planIOSRuntimeKeyVerification({ - root: ctx.cwd, - projectPath: dryRunSelection.projectPath, - targetId: dryRunSelection.targetId, - }) - : undefined; const hasLocalAppleIntent = selectedTarget?.configurations.some( (configuration) => configuration.entitlements !== undefined && @@ -346,7 +335,6 @@ export async function init(options: InitOptions = {}) { const plan = buildIOSSetupPlan(inspection, { sdkInstallPlan, directConfigPlan, - runtimeKeyVerificationPlan, associatedDomainPlan, appleEntitlementPlan, prebuiltAuthPlan, @@ -399,9 +387,9 @@ export async function init(options: InitOptions = {}) { signInWithApple: options.signInWithApple, prebuiltAuthUI: options.prebuiltAuthUI, }); - if (agent && iosLocalSetup.verifiesExistingKey && !options.app && !iosProfile) { + if (agent && iosLocalSetup.requiresExplicitApplication && !options.app) { throwUsageError( - "This iOS target already contains a publishable key. Agent mode cannot choose its matching Clerk application safely; rerun with --app . No local files were changed.", + "This iOS target already contains a preserved publishable-key configuration. Agent mode cannot choose its Clerk application; ask the developer which existing application it belongs to, then rerun with --app . The custom key value was not inspected and no local files were changed.", ); } } @@ -473,6 +461,7 @@ export async function init(options: InitOptions = {}) { options.app, createIfMissing, iosLocalSetup?.requiresLinkedApp === true, + iosLocalSetup?.requiresExplicitApplication === true, preauthenticatedIOSLabel, ); authenticatedAppId = authenticated.applicationId; @@ -672,12 +661,6 @@ export async function init(options: InitOptions = {}) { applicationId: nativeRemotePlan.applicationId, phase: "native-application", }); - if (iosSetupForCommit.runtimeKeyVerificationPlan) { - await verifyIOSRuntimeKeySetup( - iosSetupForCommit.runtimeKeyVerificationPlan, - keys.publishableKey, - ); - } try { setTelemetryStage("ios_native_setup"); await applyIOSNativeRemoteSetup(nativeRemotePlan); @@ -700,12 +683,6 @@ export async function init(options: InitOptions = {}) { applicationId: nativeApplePlan.applicationId, phase: "native-apple", }); - if (iosSetupForCommit.runtimeKeyVerificationPlan) { - await verifyIOSRuntimeKeySetup( - iosSetupForCommit.runtimeKeyVerificationPlan, - keys.publishableKey, - ); - } try { setTelemetryStage("ios_apple_setup"); await applyIOSNativeAppleConnection(nativeApplePlan); @@ -1155,6 +1132,7 @@ async function authenticateAndLink( app: string | undefined, createIfMissing: string | undefined, requireLinkedAppId: boolean, + requireExplicitApplication: boolean, preauthenticatedLabel?: string, ): Promise<{ applicationId?: string; @@ -1165,7 +1143,7 @@ async function authenticateAndLink( const alreadyOnRequestedApp = profile && (!app || profile.profile.appId === app); - if (label && alreadyOnRequestedApp) { + if (label && alreadyOnRequestedApp && !requireExplicitApplication) { log.info(dim(`${label} · Linked to ${profile.profile.appId}`)); return { applicationId: profile.profile.appId }; } @@ -1180,6 +1158,7 @@ async function authenticateAndLink( cwd, createIfMissing, ...(requireLinkedAppId && { skipAutolink: true }), + ...(requireExplicitApplication && { requireExistingAppSelection: true }), }); const linked = app || requireLinkedAppId ? await resolveProfile(cwd) : undefined; diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 4122fa2ef..128590fe3 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -33,11 +33,6 @@ import { type IOSExistingFileMutation, type IOSFileMutation, } from "./file-transaction.ts"; -import { - planIOSRuntimeKeyVerification, - verifyIOSRuntimeKey, - type IOSRuntimeKeyVerificationPlan, -} from "./runtime-key.ts"; import { planIOSAssociatedDomain, prepareIOSAssociatedDomainMutation, @@ -119,8 +114,6 @@ export interface IOSLocalSetupResult { sdkInstallPlan?: IOSSDKInstallPlan; /** Fresh/default direct Swift configuration or existing inline verification. */ directConfigPlan?: IOSDirectConfigPlan; - /** Read-only proof for comparing an already configured sink after app linking. */ - runtimeKeyVerificationPlan?: IOSRuntimeKeyVerificationPlan; /** Existing entitlements files that can receive the exact linked webcredentials host. */ associatedDomainPlan?: IOSAssociatedDomainPlan; /** Selected-target Sign in with Apple entitlement setup or verification. */ @@ -142,8 +135,8 @@ export interface IOSLocalSetupResult { requiresLinkedApp: boolean; /** The approved local transaction consumes the linked development publishable key. */ requiresDevelopmentKey: boolean; - /** An existing runtime value must not be paired with an auto-created agent app. */ - verifiesExistingKey: boolean; + /** A preserved runtime configuration requires the developer to choose its Clerk application. */ + requiresExplicitApplication: boolean; } /** @internal Test-only hook used to prove aggregate post-write rollback. */ @@ -407,34 +400,15 @@ export async function applyIOSLocalSetup( requirePrebuiltAuthCompatibility: prebuiltAuthActive, }); - const configureStep = buildIOSSetupPlan(inspection).steps.find( - (candidate) => candidate.id === "configure-publishable-key", - ); - const hasLocalSecretsConfigure = selectedTarget.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "local-secrets-loader", - ); - const hasRunSchemeConfigure = selectedTarget.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "process-info-environment", - ); - const hasSatisfiedLocalRuntimeSink = - configureStep?.status === "satisfied" && - inspection.localPublishableKey.source != null && - selectedTarget.runtimeKeySinks.some( - (sink) => sink.path === inspection.localPublishableKey.source, - ); - const plannedRuntimeKeyVerification = - hasLocalSecretsConfigure || hasRunSchemeConfigure || hasSatisfiedLocalRuntimeSink - ? await planIOSRuntimeKeyVerification({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - }) - : undefined; - const runtimeKeyVerificationPlan = - plannedRuntimeKeyVerification?.status === "ready" ? plannedRuntimeKeyVerification : undefined; - const hasEnabledSchemeKey = inspection.localPublishableKey.candidateSources.some((source) => - source.endsWith(".xcscheme"), + const customConfigureCalls = selectedTarget.swift.configureCalls.filter( + (call) => call.publishableKeyWiring === "custom", ); + const hasCustomConfigure = customConfigureCalls.length > 0; + const hasSupportedCustomConfigure = + selectedTarget.swift.evidenceComplete && + selectedTarget.swift.status !== "ambiguous" && + selectedTarget.swift.configureCalls.length === 1 && + customConfigureCalls[0]?.startupBinding === "app-init"; const shouldPlanDirectConfig = shouldPlanIOSDirectConfig( inspection, selectedTarget, @@ -462,7 +436,7 @@ export async function applyIOSLocalSetup( root: options.root, projectPath: selection.projectPath, targetId: selection.targetId, - deferToPublishableKey: directConfigPlan?.status === "ready", + deferToPublishableKey: directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, allowMissingEntitlementsCreation: true, }); // Associated Domains is an independent additive improvement. Unsupported @@ -529,13 +503,6 @@ export async function applyIOSLocalSetup( prebuiltAuthActive, }); - if (plannedRuntimeKeyVerification?.status === "blocked") { - throw iosSetupError( - `The existing iOS runtime publishable key could not be verified safely. clerk init will not change that compatibility file; repair it manually, then rerun the command. No local files were changed:\n${blockerList( - plannedRuntimeKeyVerification.blockers, - )}`, - ); - } if (directConfigPlan?.status === "blocked") { throw iosSetupError( `The selected SwiftUI app could not be configured automatically. No local files were changed:\n${blockerList( @@ -548,18 +515,13 @@ export async function applyIOSLocalSetup( selectedTarget.swift.configureCalls.length === 0 && !directConfigPlan ) { - const reason = hasEnabledSchemeKey - ? "an enabled Run-scheme publishable key already indicates a custom runtime configuration" - : selectedTarget.runtimeKeySinks.length > 0 - ? "a target-owned LocalSecrets.plist exists without a proven loader" - : "the selected runtime configuration could not be proven"; throw iosSetupError( - `The fresh SwiftUI target was not edited because ${reason}. Resolve that setup or configure Clerk directly in the @main initializer, then rerun clerk init. No local files were changed.`, + "The fresh SwiftUI target was not edited because the selected runtime configuration could not be proven. Configure Clerk directly in the @main initializer, then rerun clerk init. No local files were changed.", ); } - if ((hasLocalSecretsConfigure || hasRunSchemeConfigure) && !runtimeKeyVerificationPlan) { + if (hasCustomConfigure && !hasSupportedCustomConfigure) { throw iosSetupError( - "An existing Clerk runtime-key configuration was found, but it does not provide one proven development publishable key to the selected target. clerk init preserves custom runtime sources and will not rewrite them; repair the intended key manually, then rerun the command.", + "A custom Clerk.configure(...) source was found, but it is not one unambiguous call in the selected app's startup initializer. clerk init preserved it and made no local or remote changes. Confirm the shipping configuration manually, then rerun the command.", ); } if (prebuiltAuthActive) { @@ -693,12 +655,7 @@ export async function applyIOSLocalSetup( prebuiltAuthAppleEntitlementPlan?.status === "ready"; if (hasLocalWrites) { log.info("\nclerk init will make the following local iOS changes:\n"); - } else if ( - directConfigPlan || - runtimeKeyVerificationPlan || - appleEntitlementPlan || - prebuiltAuthPlan - ) { + } else if (directConfigPlan || appleEntitlementPlan || prebuiltAuthPlan) { log.info("\nclerk init will perform the following read-only iOS verification:\n"); } if (installPlan.status === "ready") { @@ -830,7 +787,6 @@ export async function applyIOSLocalSetup( ...(unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion } : {}), sdkInstallPlan, directConfigPlan, - runtimeKeyVerificationPlan, associatedDomainPlan, appleEntitlementPlan, prebuiltAuthPlan, @@ -840,12 +796,9 @@ export async function applyIOSLocalSetup( nativeAppleRequested, requiresLinkedApp: true, requiresDevelopmentKey: - directConfigPlan != null || - runtimeKeyVerificationPlan != null || - associatedDomainPlan?.requiresPublishableKey === true, - verifiesExistingKey: - directConfigPlan?.changes?.configuration === "verify-existing" || - runtimeKeyVerificationPlan != null, + directConfigPlan != null || associatedDomainPlan?.requiresPublishableKey === true, + requiresExplicitApplication: + hasSupportedCustomConfigure || directConfigPlan?.changes?.configuration === "verify-existing", }; } @@ -1031,9 +984,7 @@ function requireDevelopmentKey( publishableKey: string | undefined, ): string { const planNeedsKey = Boolean( - setup.directConfigPlan || - setup.runtimeKeyVerificationPlan || - setup.associatedDomainPlan?.requiresPublishableKey, + setup.directConfigPlan || setup.associatedDomainPlan?.requiresPublishableKey, ); if (planNeedsKey !== setup.requiresDevelopmentKey) { throw iosSetupError( @@ -1082,15 +1033,7 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } - const runtimePlans = [setup.directConfigPlan, setup.runtimeKeyVerificationPlan].filter( - (plan) => plan != null, - ); - if (runtimePlans.length > 1) { - throw iosSetupError( - "The approved iOS setup contains conflicting runtime configuration routes. No local setup changes were written; rerun clerk init.", - ERROR_CODE.IOS_SETUP_PLAN_INVALID, - ); - } + const runtimePlans = [setup.directConfigPlan].filter((plan) => plan != null); const plans: Array<{ root: string; projectPath: string; targetId: string }> = [ setup.sdkInstallPlan, ...runtimePlans, @@ -1132,8 +1075,8 @@ function assertCoherentLocalSetup(setup: IOSLocalSetupResult): void { /** * Commits a previously previewed iOS setup after authentication. Fresh direct * configuration combines project.pbxproj and the Swift entry source in one - * guarded local transaction. Existing LocalSecrets integrations are verified - * read-only and are never rewritten. + * guarded local transaction. Existing custom key sources are preserved and + * are never rewritten or interpreted. */ export async function applyIOSPlannedLocalSetup( setup: IOSLocalSetupResult, @@ -1175,15 +1118,6 @@ export async function applyIOSPlannedLocalSetup( } const key = requireDevelopmentKey(setup, publishableKey); - // Existing LocalSecrets and Run-scheme values are verified before any local - // mutation. A mismatched application can therefore never change the target. - if (setup.runtimeKeyVerificationPlan) { - const result = await withSpinner("Verifying the existing iOS publishable key...", async () => - verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan!, key), - ); - assertRuntimeKeyVerificationMatched(result); - } - const preparedSDK = await prepareSDKForCommit(setup.sdkInstallPlan); const preparedPrebuiltAuth = await preparePrebuiltAuthForCommit(setup.prebuiltAuthPlan); @@ -1342,8 +1276,8 @@ export async function applyIOSPlannedLocalSetup( const localMutations = composeAppleMutations(baseMutations, preparedAppleEntitlement); assertUniqueMutationPaths(localMutations); - // SDK-only and read-only compatibility routes apply their local candidates - // together after authentication. + // SDK-only and custom-runtime routes apply their local candidates together + // after the developer has selected the intended Clerk application. if (localMutations.length > 0) { const postconditions: Array<() => boolean | Promise> = [ ...(preparedSDK ? [async () => validateIOSSDKInstallPostcondition(preparedSDK.plan)] : []), @@ -1366,12 +1300,10 @@ export async function applyIOSPlannedLocalSetup( ? [async () => validatePrebuiltAuthRuntimePostcondition(setup)] : []), ]; - if (setup.runtimeKeyVerificationPlan) { + if (options.beforePostWriteValidation) { postconditions.push(async () => { await options.beforePostWriteValidation?.(); - return ( - (await verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan!, key)).status === "matched" - ); + return true; }); } const result = await withSpinner("Applying the local iOS setup...", async () => @@ -1403,44 +1335,5 @@ export async function applyIOSPlannedLocalSetup( } } - if (setup.runtimeKeyVerificationPlan) { - if (localMutations.length === 0) { - await options.beforePostWriteValidation?.(); - const result = await verifyIOSRuntimeKey(setup.runtimeKeyVerificationPlan, key); - assertRuntimeKeyVerificationMatched(result); - } - log.info(dim("The existing publishable key matches the linked Clerk application.")); - } -} - -export async function verifyIOSRuntimeKeySetup( - plan: IOSRuntimeKeyVerificationPlan, - linkedPublishableKey: string, -): Promise { - const result = await withSpinner("Verifying the existing iOS publishable key...", async () => - verifyIOSRuntimeKey(plan, linkedPublishableKey), - ); - assertRuntimeKeyVerificationMatched(result); -} - -function assertRuntimeKeyVerificationMatched( - result: Awaited>, -): void { - if (result.status === "matched") return; - if (result.status === "mismatched") { - throw iosSetupError( - "The existing iOS runtime publishable key does not match the linked Clerk application's development key. No key was changed; link the matching application or clear the existing runtime key intentionally before rerunning clerk init.", - ERROR_CODE.IOS_PUBLISHABLE_KEY_MISMATCH, - ); - } - if (result.status === "stale") { - throw iosSetupError( - "The selected iOS runtime-key source changed after the read-only verification preflight. No key was changed; rerun clerk init.", - ERROR_CODE.IOS_SETUP_STALE, - ); - } - const reasons = result.plan.blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); - throw iosSetupError( - `The existing iOS runtime publishable key could not be verified safely. No key was changed:\n${reasons}`, - ); + if (localMutations.length === 0) await options.beforePostWriteValidation?.(); } diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index ca6ddcd6d..a98592c05 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -7,14 +7,12 @@ import type { IOSSourceEvidence, IOSValueResolution, } from "./types.ts"; -import { hasIOSDirectConfigCompatibility } from "./products.ts"; import { clerkKitUIInstallDecision } from "./products.ts"; import type { IOSDirectConfigPlan } from "./direct-config.ts"; import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; import type { IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; import type { IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; import type { IOSSDKInstallPlan } from "./install-sdk.ts"; -import type { IOSRuntimeKeyVerificationPlan } from "./runtime-key.ts"; const NATIVE_APPLICATIONS_URL = "https://dashboard.clerk.com/~/native-applications"; const QUICKSTART_URL = "https://clerk.com/docs/ios/getting-started/quickstart"; @@ -69,32 +67,11 @@ function step( return { id, title, status, automatable, description, links, evidence }; } -function publishableKeyRuntimeSource( - source: string | undefined, - target: IOSAppTarget, -): "inline-literal" | "run-scheme" | "local-secrets" | "available-only" | undefined { - if (!source) return undefined; - if ( - target.swift.configureCalls.some( - (call) => call.path === source && call.publishableKeyWiring === "inline-literal", - ) - ) { - return "inline-literal"; - } - if (source.endsWith(".xcscheme")) return "run-scheme"; - if (target.runtimeKeySinks.some((sink) => sink.path === source)) { - return "local-secrets"; - } - return "available-only"; -} - export interface BuildIOSSetupPlanOptions { /** Strict SDK/package compatibility from the same planner used by apply. */ sdkInstallPlan?: Pick; /** Strict, publishable-key-redacted Swift source readiness from the apply planner. */ directConfigPlan?: IOSDirectConfigPlan; - /** Read-only validation for the exact supported LocalSecrets compatibility path. */ - runtimeKeyVerificationPlan?: Pick; /** Strict existing-entitlements readiness from the same planner used by apply. */ associatedDomainPlan?: Pick< IOSAssociatedDomainPlan, @@ -217,112 +194,71 @@ export function buildIOSSetupPlan( ); const configured = target.swift.configureCalls.length > 0; - const usablePublishableKey = - inspection.localPublishableKey.found && - !inspection.localPublishableKey.conflict && - inspection.localPublishableKey.frontendApiHost != null; - const runtimeKeySource = publishableKeyRuntimeSource( - inspection.localPublishableKey.source, - target, - ); - const publishableKeySourceIsRuntime = - runtimeKeySource === "inline-literal" || - runtimeKeySource === "run-scheme" || - runtimeKeySource === "local-secrets"; - const configureCallConnectedToRuntime = - usablePublishableKey && - runtimeKeySource != null && - runtimeKeySource !== "available-only" && + const oneStartupConfigure = + target.swift.evidenceComplete && + !sourceEntryPointIsAmbiguous && target.swift.configureCalls.length === 1 && - target.swift.configureCalls.every( - (call) => - call.startupBinding === "app-init" && - (runtimeKeySource === "inline-literal" - ? call.publishableKeyWiring === "inline-literal" && - call.inlinePublishableKey?.state === "valid" - : runtimeKeySource === "local-secrets" - ? call.publishableKeyWiring === "local-secrets-loader" && - call.localSecretsRuntimeBinding === "proven" - : call.publishableKeyWiring === "process-info-environment"), - ); + target.swift.configureCalls[0]?.startupBinding === "app-init"; + const configureCall = target.swift.configureCalls[0]; + const inlineConfigureValid = + oneStartupConfigure && + configureCall?.publishableKeyWiring === "inline-literal" && + configureCall.inlinePublishableKey?.state === "valid"; + const customConfigureReady = + oneStartupConfigure && configureCall?.publishableKeyWiring === "custom"; const publishableKeyBlocked = - publishableKeySourceIsRuntime && - (inspection.localPublishableKey.conflict || - (!inspection.localPublishableKey.found && - inspection.localPublishableKey.invalidSources.length > 0)); - const hasDirectConfigCompatibility = hasIOSDirectConfigCompatibility(inspection, target); - const directConfigPlanApplies = options.directConfigPlan != null && !hasDirectConfigCompatibility; + configureCall?.publishableKeyWiring === "inline-literal" && + configureCall.inlinePublishableKey?.state === "invalid"; + const directConfigPlanApplies = options.directConfigPlan != null; const directConfigAutomationReady = directConfigPlanApplies && options.directConfigPlan?.status === "ready" && options.directConfigPlan.changes?.configuration !== "verify-existing"; const directConfigBlocked = directConfigPlanApplies && options.directConfigPlan?.status === "blocked"; - const runtimeKeyVerificationBlocked = options.runtimeKeyVerificationPlan?.status === "blocked"; - const runtimeKeyVerificationBlocker = runtimeKeyVerificationBlocked - ? options.runtimeKeyVerificationPlan?.blockers.map((blocker) => blocker.message).join(" ") - : undefined; const directConfigBlocker = directConfigBlocked ? options.directConfigPlan?.blockers.map((blocker) => blocker.message).join(" ") : undefined; - const configuredStatus: IOSSetupStepStatus = runtimeKeyVerificationBlocked + const configuredStatus: IOSSetupStepStatus = publishableKeyBlocked ? "blocked" - : publishableKeyBlocked + : directConfigBlocked ? "blocked" - : directConfigBlocked - ? "blocked" - : configured - ? sourceEntryPointIsAmbiguous || !configureCallConnectedToRuntime - ? "review" - : "satisfied" - : directConfigAutomationReady + : configured + ? inlineConfigureValid || customConfigureReady + ? "satisfied" + : "review" + : directConfigAutomationReady + ? "required" + : target.swift.evidenceComplete ? "required" - : target.swift.evidenceComplete - ? "required" - : "review"; + : "review"; steps.push( step( "configure-publishable-key", "Configure Clerk with a publishable key", configuredStatus, - runtimeKeyVerificationBlocked - ? `The existing iOS runtime-key compatibility path cannot be verified safely. clerk init preserves this source and will not replace it. ${runtimeKeyVerificationBlocker ?? "Repair it manually, then rerun the command."}` - : publishableKeyBlocked - ? inspection.localPublishableKey.conflict - ? "Multiple effective publishable-key sources point at different Clerk instances. Resolve the conflict before configuring the app." - : runtimeKeySource === "local-secrets" - ? "The existing LocalSecrets.plist publishable key is malformed. clerk init preserves this compatibility file and will not replace it; add the intended development key manually." - : "The effective publishable-key source is malformed. Replace it before relying on Clerk.configure(...)." - : directConfigBlocked - ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's @main App initializer and root Scene manually."}` - : configured - ? target.swift.configureCalls.length > 1 - ? "More than one Clerk.configure(...) call is present. Confirm that every call uses the intended selected-target runtime key and runs during app startup." - : sourceEntryPointIsAmbiguous - ? "A Clerk.configure(...) call is present, but multiple @main entry points make startup ownership ambiguous. Confirm which entry point ships." - : configureCallConnectedToRuntime - ? runtimeKeySource === "inline-literal" - ? "Clerk is configured directly in the selected target's @main initializer with a valid publishable key. The value is intentionally redacted from this plan." - : "A Clerk.configure(...) call is connected to a recognized selected-target runtime key loader. The key expression and value are intentionally redacted from this plan." - : usablePublishableKey - ? runtimeKeySource === "available-only" - ? "A usable publishable key is available to copy, but the app is not proven to load it at runtime. Configure Clerk directly in the selected target's @main App initializer, or repair the app's existing runtime loader if it intentionally uses one." - : "A selected-target runtime publishable key is present, but the Clerk.configure(...) expression could not be connected to its loader. Confirm the wiring manually; the expression and value are intentionally redacted." - : "A Clerk.configure(...) call is present, but the inspector could not validate a usable selected-target runtime key source. Confirm the runtime value manually; the expression is intentionally redacted." - : !target.swift.evidenceComplete - ? "No Clerk.configure(...) call was found in the safely inspected source subset. Complete source membership inspection or confirm startup setup manually." - : inspection.localPublishableKey.conflict - ? "Available publishable-key candidates point to different Clerk instances. Choose the intended development instance and call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer." - : inspection.localPublishableKey.invalidSources.length > 0 - ? "The available publishable-key candidate is malformed. Replace it with the intended development key and call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer." - : inspection.localPublishableKey.found - ? "A local publishable key is available, but it is not proven to configure this target. New projects should call Clerk.configure(publishableKey:) directly in the selected target's @main App initializer; the plan will never print the key." - : directConfigAutomationReady - ? `clerk init can add Clerk.configure(publishableKey:) directly to ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App initializer"} with the selected application's development key. The preview and result keep the value redacted.` - : "Select a Clerk application and call Clerk.configure(publishableKey:) with its development publishable key directly in the selected target's @main App initializer.", + publishableKeyBlocked + ? "The inline Clerk publishable key is malformed. Replace it before relying on Clerk.configure(...)." + : directConfigBlocked + ? `Automatic direct configuration stopped because the selected Swift startup source is not safe to edit: ${directConfigBlocker ?? "Review the selected target's @main App initializer and root Scene manually."}` + : configured + ? target.swift.configureCalls.length > 1 + ? "More than one Clerk.configure(...) call is present. Confirm which call configures the shipping app before continuing." + : sourceEntryPointIsAmbiguous + ? "A Clerk.configure(...) call is present, but multiple @main entry points make startup ownership ambiguous. Confirm which entry point ships." + : inlineConfigureValid + ? "Clerk is configured directly in the selected target's @main initializer with a valid publishable key. The value is intentionally redacted from this plan." + : customConfigureReady + ? "Clerk is configured at app startup through a custom publishable-key source. clerk init will preserve that source and require the developer to select its Clerk application; the value is not inspected or independently verified." + : "A Clerk.configure(...) call is present, but it is not proven to run from the selected app's startup initializer. Confirm the shipping configuration manually; the expression is intentionally redacted." + : !target.swift.evidenceComplete + ? "No Clerk.configure(...) call was found in the safely inspected source subset. Complete source membership inspection or confirm startup setup manually." + : directConfigAutomationReady + ? `clerk init can add Clerk.configure(publishableKey:) directly to ${options.directConfigPlan?.sourcePath ?? "the single shipping @main App initializer"} with the selected application's development key. The preview and result keep the value redacted.` + : "Select a Clerk application and call Clerk.configure(publishableKey:) with its development publishable key directly in the selected target's @main App initializer.", target.swift.configureCalls, undefined, - directConfigAutomationReady && !runtimeKeyVerificationBlocked, + directConfigAutomationReady, ), ); @@ -471,10 +407,7 @@ export function buildIOSSetupPlan( const expectedDomain = inspection.localPublishableKey.frontendApiHost ? `webcredentials:${inspection.localPublishableKey.frontendApiHost}` : undefined; - const expectedDomainIsSelectedTargetRuntime = - runtimeKeySource === "inline-literal" || - runtimeKeySource === "run-scheme" || - runtimeKeySource === "local-secrets"; + const expectedDomainIsSelectedTargetRuntime = inlineConfigureValid; const entitlements = target.configurations .map((configuration) => configuration.entitlements) .filter((value) => value != null); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 6db3f9934..5972265b9 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -32,6 +32,11 @@ interface LinkOptions { * this so a web app's ambient key cannot silently choose the embedded app. */ skipAutolink?: boolean; + /** + * Require the developer to affirm an existing application. Used when a + * native project contains an opaque custom key source that must be preserved. + */ + requireExistingAppSelection?: boolean; } export async function link(options: LinkOptions = {}): Promise { @@ -46,7 +51,12 @@ export async function link(options: LinkOptions = {}): Promise { const existing = await resolveProfile(cwd); const targetsDifferentApp = options.app && existing && options.app !== existing.profile.appId; - if (existing && options.skipIfLinked && !targetsDifferentApp) { + if ( + existing && + options.skipIfLinked && + !targetsDifferentApp && + !options.requireExistingAppSelection + ) { printExistingStatus(existing, normalizedRemote); return; } @@ -90,7 +100,12 @@ export async function link(options: LinkOptions = {}): Promise { createApplication(options.createIfMissing), "Failed to create application", ) - : await resolveApp(cwd, displayPath, !existing && !options.skipAutolink); + : await resolveApp( + cwd, + displayPath, + !existing && !options.skipAutolink, + options.requireExistingAppSelection !== true, + ); const devInstance = app.instances.find((i) => i.environment_type === "development"); const prodInstance = app.instances.find((i) => i.environment_type === "production"); @@ -174,6 +189,17 @@ async function handleExistingProfile( return confirm({ message: `Re-link to ${cyan(appLabel(targetApp))}?`, default: false }); } + if (options.requireExistingAppSelection) { + const label = existing.profile.appName + ? `${existing.profile.appName} (${existing.profile.appId})` + : existing.profile.appId; + const keepExisting = await confirm({ + message: `Use ${cyan(label)} for this preserved iOS key configuration?`, + default: true, + }); + return !keepExisting; + } + return confirm({ message: "Re-link to a different application?", default: false }); } @@ -193,6 +219,7 @@ async function resolveApp( cwd: string, displayPath: string, detectKeys: boolean, + allowCreate = true, ): Promise { const apps = await fetchAppsTolerantly(); @@ -204,6 +231,7 @@ async function resolveApp( return pickOrCreateApp({ apps, message: `Select a Clerk application to link ${dim(`(repo: ${basename(displayPath)})`)}`, + allowCreate, }); } diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index a41b1c754..5b0fec5f6 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -44,6 +44,7 @@ export async function fetchAppsTolerantly(): Promise { export async function pickOrCreateApp(opts: { apps: Application[]; message: string; + allowCreate?: boolean; }): Promise { const appChoices = opts.apps.map((a) => ({ name: appLabel(a), value: a.application_id })); const createChoice = { @@ -51,13 +52,20 @@ export async function pickOrCreateApp(opts: { value: CREATE_NEW_APP, }; + if (opts.allowCreate === false && appChoices.length === 0) { + throw new CliError( + "No existing Clerk applications are available. Create the intended application first, then rerun with --app .", + { code: ERROR_CODE.APP_NOT_FOUND }, + ); + } + const selectedId = await search({ message: opts.message, source: (term) => { const filtered = term ? appChoices.filter((c) => c.name.toLowerCase().includes(term.toLowerCase())) : appChoices; - return [createChoice, ...filtered]; + return opts.allowCreate === false ? filtered : [createChoice, ...filtered]; }, }); From b2a25c2c3b5e932074fa2d7a2d374342164f42b3 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 09:59:26 -0400 Subject: [PATCH 42/55] refactor(ios): align custom app selection flow --- .changeset/calm-apples-inspect.md | 2 +- packages/cli-core/src/commands/init/README.md | 16 +- .../src/commands/init/frameworks/ios.test.ts | 10 +- .../src/commands/init/frameworks/ios.ts | 10 +- .../src/commands/init/index-ios.test.ts | 208 +++-------------- packages/cli-core/src/commands/init/index.ts | 17 +- .../init/ios/apply-cli-runtime.test.ts | 105 ++++----- .../src/commands/init/ios/apply-cli.test.ts | 93 ++++---- .../cli-core/src/commands/init/ios/apply.ts | 22 +- .../commands/init/ios/build-settings.test.ts | 2 - .../src/commands/init/ios/dry-run.test.ts | 61 ++++- .../init/ios/native-readiness.test.ts | 50 ++-- .../cli-core/src/commands/init/ios/output.ts | 3 + .../src/commands/init/ios/plan.test.ts | 213 ++++-------------- .../src/commands/init/ios/products.ts | 18 +- .../src/commands/init/strategy.test.ts | 2 +- .../cli-core/src/commands/link/index.test.ts | 104 +++++++++ packages/cli-core/src/commands/link/index.ts | 8 +- packages/cli-core/src/lib/app-picker.ts | 6 +- .../cli-core/src/test/lib/init-harness.ts | 3 +- 20 files changed, 417 insertions(+), 536 deletions(-) diff --git a/.changeset/calm-apples-inspect.md b/.changeset/calm-apples-inspect.md index 965f42cc7..af21bdb3a 100644 --- a/.changeset/calm-apples-inspect.md +++ b/.changeset/calm-apples-inspect.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing proven LocalSecrets and ProcessInfo integrations remain read-only compatibility paths and source-proven custom flows remain core-only. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the linked development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and an `AuthView` sheet; established or partially integrated application UI is never rewritten. +Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing custom `Clerk.configure(...)` sources remain unchanged and require explicit application selection; their backing values are not inspected or claimed to match. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the selected development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and an `AuthView` sheet; established or partially integrated application UI is never rewritten. diff --git a/packages/cli-core/src/commands/init/README.md b/packages/cli-core/src/commands/init/README.md index 071b8767f..9e061e537 100644 --- a/packages/cli-core/src/commands/init/README.md +++ b/packages/cli-core/src/commands/init/README.md @@ -52,9 +52,9 @@ clerk init --dry-run --target MyApp --json ## Read-only iOS inspection -`clerk init --dry-run` takes a separate, read-only path for existing native iOS projects. It inspects Xcode projects and workspaces, application targets and build configurations, Swift Package Manager linkage, target source membership, Swift Clerk setup, entitlements, and locally configured `CLERK_PUBLISHABLE_KEY` metadata. It then prints an ordered setup plan with a top-level status of `ready`, `action-required`, or `blocked`. +`clerk init --dry-run` takes a separate, read-only path for existing native iOS projects. It inspects Xcode projects and workspaces, application targets and build configurations, Swift Package Manager linkage, target source membership, Swift Clerk setup, and entitlements. It then prints an ordered setup plan with a top-level status of `ready`, `action-required`, or `blocked`. -Publishable-key discovery is target-aware. The inspector can recognize an inline key passed directly to `Clerk.configure(publishableKey:)`, an enabled Run-scheme environment variable, a target-owned `LocalSecrets.plist`, project `.env` files, and a local Clerk keyless breadcrumb. It distinguishes a key that is available to copy from one that the inspected Swift startup code is known to consume. Output contains only redacted source evidence plus the decoded Frontend API host needed for Associated Domains; it never contains the publishable key itself. +Publishable-key inspection intentionally has a narrow boundary. One literal passed directly to `Clerk.configure(publishableKey:)` in the selected app's startup initializer can be validated with its value redacted. Every other expression is classified as custom: the CLI preserves it without reading its backing file, scheme, environment, or value. The command does not authenticate, call Clerk APIs, run Xcode, resolve packages, send command telemetry, check for CLI updates, or write project/global CLI files. Publishable key values are never included in output. Flags that imply project creation or already-known remote application state (`--starter`, `--app`, `--app-id-prefix`, `--keyless`, `--login`, `--template`, and `--fresh`) are rejected before inspection. `--sign-in-with-apple` is allowed because dry-run previews only the local entitlement; it reports the Clerk connection as not inspected until a regular authenticated run. @@ -66,7 +66,7 @@ For a native iOS project, normal `clerk init` re-runs the semantic inspection, b For a safely inspectable fresh SwiftUI target, the same command selects or creates a Clerk application, fetches only its development publishable key, adds `import ClerkKit`, configures Clerk directly in the single shipping `@main` initializer, and adds `.environment(Clerk.shared)` to the proven `WindowGroup` root. The key is public client configuration and is written directly to Swift source, matching the iOS Quickstart. It remains in memory until commit and is never printed, returned in JSON, sent to telemetry, or written through an intermediate `.env` or plist. Existing inline keys are compared with the selected application's key and never replaced on a mismatch. -Existing proven LocalSecrets and ProcessInfo/Run-scheme integrations remain compatibility paths and are never migrated automatically. Their existing development publishable key is verified against the linked application, but `clerk init` never writes the plist, scheme, or ignore rules. Missing, different, tracked/shared/malformed, or custom runtime-key sources are preserved and require manual review. +An existing custom `Clerk.configure(...)` source is never migrated or rewritten. The developer must explicitly select the existing Clerk application it belongs to; agents do this with `--app `. That choice authorizes linked-app and Native Application setup, but the CLI does not inspect the custom value or claim that it matches the selected application. The CLI previews every planned local path and asks once before writing. Human users can pass `--yes` to skip that confirmation. Agent/non-TTY mode must pass `--yes` explicitly for iOS mutations; agent mode never implies consent here. A planned file with existing Git changes is refused unless `--allow-dirty` is also explicit, and `--yes` does not imply `--allow-dirty`. @@ -121,7 +121,7 @@ The normal setup flow is: - **Human mode + existing project + not authenticated**: runs the authenticated flow, which triggers an interactive login so real keys can be pulled. `-y` does not bypass this — it only suppresses y/n confirmation prompts, not authentication - `--template` and `--fresh` are rejected with a usage error whenever the resolved strategy above isn't keyless — see [Application templates](#application-templates) and [Keyless breadcrumb](#keyless-breadcrumb) 4. **Authenticated mode only**: authenticates via `clerk auth login` (skipped if already authenticated) and links or creates/selects the project application via `clerk link` -5. **Eligible native iOS only**: resolves the newly linked application by its exact ID, fetches only its public development key, and audits Native API, iOS registration, the selected prebuilt AuthView environment, and any explicitly requested native Apple connection before writing. It then prepares the approved PBX, Swift, and entitlements candidates again. A fresh target commits the eligible files through one rollback-aware transaction and re-inspects the result. This can include creating and attaching one entitlements file for an exclusive filesystem-synchronized target root, replacing only an explicitly selected pristine starter screen with the documented `UserButton` and `AuthView` sheet, and adding the exact native Apple entitlement when required by either explicit Apple setup or an already-enabled Apple button. A proven existing LocalSecrets path is checked read-only and never rewritten. The key is never resolved through mutable current-directory profile state, printed, or copied through dotenv. Additive remote Native Application changes run after the local commit; native Apple is enabled last and final state is re-read +5. **Eligible native iOS only**: resolves the explicitly selected application by its exact ID, fetches only its public development key, and audits Native API, iOS registration, the selected prebuilt AuthView environment, and any explicitly requested native Apple connection before writing. It then prepares the approved PBX, Swift, and entitlements candidates again. A fresh target commits the eligible files through one rollback-aware transaction and re-inspects the result. This can include creating and attaching one entitlements file for an exclusive filesystem-synchronized target root, replacing only an explicitly selected pristine starter screen with the documented `UserButton` and `AuthView` sheet, and adding the exact native Apple entitlement when required by either explicit Apple setup or an already-enabled Apple button. Custom key sources are preserved without inspection. The key is never resolved through mutable current-directory profile state, printed, or copied through dotenv. Additive remote Native Application changes run after the local commit; native Apple is enabled last and final state is re-read 6. Displays detected framework and variant 7. Detects existing auth libraries (NextAuth, Auth0, Supabase, Firebase, Passport, Better Auth, Kinde) and shows migration guidance 8. Installs the appropriate Clerk SDK (skips if already present) @@ -132,7 +132,7 @@ The normal setup flow is: 13. Runs project formatters (Prettier/Biome) on generated files 14. Scans for issues: hardcoded keys, leftover auth-library imports, stale API calls 15. Prints a summary of created, modified, and skipped files with recommendations -16. **Authenticated mode**: pulls development instance API keys via `clerk env pull` for frameworks that consume dotenv files. Native iOS either completes the proven runtime-key handoff or leaves key storage unchanged +16. **Authenticated mode**: pulls development instance API keys via `clerk env pull` for frameworks that consume dotenv files. Native iOS leaves custom key storage unchanged 17. **Keyless mode** (unauthenticated runs whose resolved strategy in step 3 is keyless — an unauthenticated human-mode rerun on an existing project resolves to the authenticated flow instead): mints a keyless application and prints instructions for development without API keys and how to connect a Clerk account later — unless an unclaimed keyless app already exists for this project (see [Re-running init on an already-keyless project](#re-running-init-on-an-already-keyless-project)), in which case the existing keys are kept and reported instead 18. Optionally installs Clerk agent skills (cli + core + features, plus a framework-specific skill) via the project's package runner (see [Agent skills install](#agent-skills-install)) @@ -161,7 +161,7 @@ Native mobile platforms may not have a `package.json`, so they are detected from | `*.xcodeproj` / `*.xcworkspace` | iOS (Swift) | `ClerkKit` + `ClerkKitUI` (Swift Package Manager) | `CLERK_PUBLISHABLE_KEY` | | `app/src/main/AndroidManifest.xml` / `src/main/AndroidManifest.xml` | Android (Kotlin) | `com.clerk:clerk-android-ui` (Gradle) | `CLERK_PUBLISHABLE_KEY` | -A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. Native SDKs are not installed by a JavaScript package manager. For iOS, init can edit the selected target's Swift Package Manager graph directly. New and source-blank core-only integrations receive both ClerkKit and ClerkKitUI for the prebuilt authentication path; a source-proven custom integration stays ClerkKit-only. A safely inspectable fresh SwiftUI target is configured directly in its shipping `@main` source. Existing LocalSecrets and ProcessInfo integrations remain compatibility paths. Android still prints the Gradle installation steps. +A bare `Package.swift` or `build.gradle` is intentionally **not** enough — those also match server-side Swift packages and non-Android JVM projects. Native SDKs are not installed by a JavaScript package manager. For iOS, init can edit the selected target's Swift Package Manager graph directly. New and source-blank core-only integrations receive both ClerkKit and ClerkKitUI for the prebuilt authentication path; a source-proven custom integration stays ClerkKit-only. A safely inspectable fresh SwiftUI target is configured directly in its shipping `@main` source. Existing custom `Clerk.configure(...)` sources are preserved without interpreting how they load a key. Android still prints the Gradle installation steps. The **Keyless** column indicates whether the framework's Clerk SDK supports keyless mode (auto-generated temporary dev keys). Keyless is the default for unauthenticated runs on Yes-row frameworks — during bootstrap (new projects) in human mode, and in all agent-mode runs. In human mode, an unauthenticated re-run in an existing project still triggers the authenticated flow. `--keyless` forces keyless anywhere a Yes-row framework is detected (existing projects included, even when logged in); passing it for a No-row framework exits with a usage error. In agent mode, an authenticated run on a keyless-capable framework creates a real app named after the project and links it. @@ -169,7 +169,7 @@ Package manager is detected from lock files: `bun.lockb`/`bun.lock` → bun, `ya ## Scaffolding -Scaffolding is supported for every detected framework. The dedicated iOS preflight may safely update the selected Xcode target's Swift package graph and authorize an exact runtime-key destination before generic scaffolding; remaining iOS work and all Android native setup are printed as post-instructions. +Scaffolding is supported for every detected framework. The dedicated iOS preflight may safely update the selected Xcode target's Swift package graph and direct configuration before generic scaffolding; custom key sources are preserved and require explicit application selection. Remaining iOS work and all Android native setup are printed as post-instructions. All scaffolding is idempotent — files are skipped if they already contain Clerk setup. @@ -288,7 +288,7 @@ Express and Fastify share the server-entry scaffolding in [`node-server.ts`](./f ### iOS (Swift) / Android (Kotlin) -For iOS, the dedicated setup phase links both `ClerkKit` and `ClerkKitUI` for a fresh target so the optional prebuilt authentication path is available. It also upgrades a source-blank target left ClerkKit-only by an earlier setup, while preserving a source-proven ClerkKit-only custom flow. A safely inspectable fresh SwiftUI target receives direct `@main` Clerk configuration and environment injection; proven LocalSecrets/ProcessInfo projects stay on their existing compatibility path. With explicit `--prebuilt-auth-ui` consent, only an exact untouched SwiftUI starter screen can receive the quickstart `UserButton`, image prefetching, and `AuthView` sheet; established UI is never rewritten. Safe XML entitlements files can receive the exact Associated Domain transactionally, and a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file. The authenticated phase then audits and, with separate consent, additively creates the exact iOS registration and enables Native API for the linked development instance. The optional `--sign-in-with-apple` path composes the native Apple entitlement into that transaction and enables only the exact Bundle ID's Clerk Apple connection. Android prints the Gradle SDK step for `com.clerk:clerk-android-*`. +For iOS, the dedicated setup phase links both `ClerkKit` and `ClerkKitUI` for a fresh target so the optional prebuilt authentication path is available. It also upgrades a source-blank target left ClerkKit-only by an earlier setup, while preserving a source-proven ClerkKit-only custom flow. A safely inspectable fresh SwiftUI target receives direct `@main` Clerk configuration and environment injection; custom configuration sources remain unchanged and require explicit application selection. With explicit `--prebuilt-auth-ui` consent, only an exact untouched SwiftUI starter screen can receive the quickstart `UserButton`, image prefetching, and `AuthView` sheet; established UI is never rewritten. Safe XML entitlements files can receive the selected application's exact Associated Domain transactionally, and a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file. The authenticated phase then audits and, with separate consent, additively creates the exact iOS registration and enables Native API for the selected development instance. The optional `--sign-in-with-apple` path composes the native Apple entitlement into that transaction and enables only the exact Bundle ID's Clerk Apple connection. Android prints the Gradle SDK step for `com.clerk:clerk-android-*`. ## Agent skills install diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index 919f5643b..8eba8b2dd 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -144,7 +144,7 @@ test("explains that the prebuilt AuthView exposes Apple automatically after nati ).toBe(true); }); -test("keeps a proven LocalSecrets loader as a compatibility path", async () => { +test("preserves a custom LocalSecrets loader without interpreting its value", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-local-secrets-")); temporaryRoots.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -155,8 +155,8 @@ test("keeps a proven LocalSecrets loader as a compatibility path", async () => { const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); - expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist"))).toBe(true); - expect(plan.postInstructions.some((i) => i.includes("will not replace it"))).toBe(true); + expect(plan.postInstructions.some((i) => i.includes("LocalSecrets.plist"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("custom key value"))).toBe(false); expect( plan.postInstructions.some((i) => i.includes("single shipping `@main` App initializer")), ).toBe(false); @@ -207,7 +207,7 @@ test("omits SwiftUI environment injection when it is already present", async () expect(plan.postInstructions.some((i) => i.includes(".environment(Clerk.shared)"))).toBe(false); }); -test("omits locally satisfied setup instructions for the selected target", async () => { +test("does not derive setup state from a LocalSecrets value", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-framework-satisfied-")); temporaryRoots.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -220,7 +220,7 @@ test("omits locally satisfied setup instructions for the selected target", async const plan = await ios.scaffold({ ...makeCtx(), cwd: root, iosTarget: "MyApp" }); expect(plan.postInstructions.some((i) => i.includes("github.com/clerk/clerk-ios"))).toBe(false); - expect(plan.postInstructions.some((i) => i.includes("Associated Domains"))).toBe(false); + expect(plan.postInstructions.some((i) => i.includes("Associated Domains"))).toBe(true); expect(plan.postInstructions.some((i) => i.includes("Configure Clerk"))).toBe(false); expect(plan.postInstructions.some((i) => i.includes("signed-out authentication route"))).toBe( false, diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 50629c006..75ff5e330 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -2,7 +2,11 @@ import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js import { planIOSDirectConfig } from "../ios/direct-config.ts"; import { inspectIOSProject } from "../ios/inspect.ts"; import { buildIOSSetupPlan } from "../ios/plan.ts"; -import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "../ios/products.ts"; +import { + clerkKitUIInstallDecision, + hasSupportedIOSCustomConfigure, + shouldPlanIOSDirectConfig, +} from "../ios/products.ts"; import { planIOSAssociatedDomain } from "../ios/associated-domain.ts"; /** @@ -37,9 +41,7 @@ export const ios: FrameworkScaffold = { : undefined; const productDecision = target ? clerkKitUIInstallDecision(target) : "prebuilt"; const includeClerkKitUI = productDecision === "prebuilt"; - const hasCustomConfigure = target?.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "custom", - ); + const hasCustomConfigure = target != null && hasSupportedIOSCustomConfigure(target); const shouldPlanDirectConfig = selection.state === "selected" && target != null && diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index 2175cce8d..9afcc7bd3 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -28,14 +28,13 @@ import * as telemetryMod from "../../lib/telemetry.ts"; import { getLogLevel, setLogLevel } from "../../lib/log.ts"; import * as iosFileTransactionMod from "./ios/file-transaction.ts"; import { init } from "./index.ts"; -import { CliError, ERROR_CODE, PlapiError } from "../../lib/errors.ts"; +import { ERROR_CODE, PlapiError } from "../../lib/errors.ts"; import type { IOSLocalSetupResult } from "./ios/apply.ts"; import type { IOSAppleEntitlementPlan } from "./ios/apple-entitlement.ts"; import type { IOSNativeApplePlan } from "./ios/native-apple.ts"; import type { IOSNativeRemotePlan } from "./ios/native-remote.ts"; import type { IOSNativeReadinessTarget } from "./ios/native-readiness.ts"; import type { IOSPrebuiltAuthPlan } from "./ios/prebuilt-auth.ts"; -import type { IOSRuntimeKeyVerificationPlan } from "./ios/runtime-key.ts"; const VALID_DEVELOPMENT_KEY = `pk_test_${btoa("example.clerk.accounts.dev$")}`; @@ -144,28 +143,11 @@ function iosSetupResult(overrides: Partial = {}): IOSLocalS requiresLinkedApp: false, requiresDevelopmentKey: overrides.requiresDevelopmentKey ?? overrides.requiresLinkedApp ?? false, - verifiesExistingKey: false, + requiresExplicitApplication: false, ...overrides, }; } -function iosRuntimeKeyVerificationPlan(): IOSRuntimeKeyVerificationPlan { - return { - schemaVersion: 1, - kind: "clerk-ios-runtime-key-verification", - status: "ready", - root: "/tmp/test", - projectPath: "MyApp.xcodeproj", - targetId: "TARGET", - source: { - kind: "run-scheme", - path: "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme", - expectedHash: "scheme-hash", - }, - blockers: [], - }; -} - function selectedNativeTarget( overrides: Partial> = {}, ): Extract { @@ -307,7 +289,7 @@ describe("init iOS", () => { expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); }); - test("does not let agent mode guess an application for an existing runtime key", async () => { + test("requires --app in agent mode for a preserved custom key source", async () => { setup({ isAgent: true, email: "test@test.com" }); const iosCtx = nativeIOSContext(); spyOn(context, "gatherContext").mockResolvedValue(iosCtx); @@ -315,13 +297,13 @@ describe("init iOS", () => { spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( iosSetupResult({ requiresLinkedApp: true, - requiresDevelopmentKey: true, - verifiesExistingKey: true, + requiresDevelopmentKey: false, + requiresExplicitApplication: true, }), ); await expect(init({ yes: true })).rejects.toThrow( - "Agent mode cannot choose its matching Clerk application safely; rerun with --app ", + "requires explicit Clerk application selection", ); expect(linkMod.link).not.toHaveBeenCalled(); @@ -1399,184 +1381,45 @@ describe("init iOS", () => { expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); }); - test("rejects an explicit same-profile app when its existing iOS runtime key is stale", async () => { - const { captured } = setup({ email: "test@test.com" }); - const iosCtx = nativeIOSContext(); - const linkedKey = `pk_test_${Buffer.from("explicit-stale.clerk.example$").toString("base64")}`; - spyOn(context, "gatherContext").mockResolvedValue(iosCtx); - spyOn(config, "resolveProfile").mockResolvedValue({ - profile: { appId: "app_same_profile" }, - } as never); - const setupResult = iosSetupResult({ - requiresLinkedApp: true, - verifiesExistingKey: true, - }); - const localApply = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - const commit = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( - new Error("The existing iOS runtime publishable key does not match the linked app."), - ); - spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ - applicationId: "app_same_profile", - instanceId: "ins_same_profile", - publishableKey: linkedKey, - }); - await expect(init({ yes: true, app: "app_same_profile" })).rejects.toThrow( - "does not match the linked app", - ); - - expect(iosApplyMod.applyIOSLocalSetup).toHaveBeenCalledWith( - expect.not.objectContaining({ expectedPublishableKey: expect.anything() }), - ); - expect(linkMod.link).not.toHaveBeenCalled(); - expect(localApply).toHaveBeenCalledTimes(1); - expect(commit).toHaveBeenCalledWith(setupResult, linkedKey); - expect(pullMod.pull).not.toHaveBeenCalled(); - expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); - }); - - test("rejects an implicitly linked profile when its existing iOS runtime key is stale", async () => { - const { captured } = setup({ email: "test@test.com" }); - const iosCtx = nativeIOSContext(); - const linkedKey = `pk_test_${Buffer.from("implicit-stale.clerk.example$").toString("base64")}`; - spyOn(context, "gatherContext").mockResolvedValue(iosCtx); - spyOn(config, "resolveProfile").mockResolvedValue({ - profile: { appId: "app_implicitly_linked" }, - } as never); - const setupResult = iosSetupResult({ - requiresLinkedApp: true, - verifiesExistingKey: true, - }); - spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockRejectedValue( - new Error("The existing iOS runtime publishable key does not match the linked app."), - ); - spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ - applicationId: "app_implicitly_linked", - instanceId: "ins_implicitly_linked", - publishableKey: linkedKey, - }); - await expect(init({ yes: true })).rejects.toThrow("does not match the linked app"); - - expect(linkMod.link).not.toHaveBeenCalled(); - expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, linkedKey); - expect(pullMod.pull).not.toHaveBeenCalled(); - expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); - }); - - test("revalidates an existing runtime key after local setup and before Native mutation", async () => { - setup({ email: "test@test.com" }); - const iosCtx = nativeIOSContext(); - const linkedKey = `pk_test_${Buffer.from("revalidated.clerk.example$").toString("base64")}`; - const verificationPlan = iosRuntimeKeyVerificationPlan(); - const setupResult = iosSetupResult({ - requiresLinkedApp: true, - requiresDevelopmentKey: true, - verifiesExistingKey: true, - runtimeKeyVerificationPlan: verificationPlan, - }); - spyOn(context, "gatherContext").mockResolvedValue(iosCtx); - spyOn(config, "resolveProfile").mockResolvedValue({ - profile: { appId: "app_revalidated" }, - } as never); - spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - const commitLocal = spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue( - undefined, - ); - const revalidateRuntime = spyOn(iosApplyMod, "verifyIOSRuntimeKeySetup").mockResolvedValue( - undefined, - ); - const applyRemote = spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockResolvedValue( - undefined, - ); - spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ - applicationId: "app_revalidated", - instanceId: "ins_revalidated", - publishableKey: linkedKey, - }); - spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( - iosRemotePlan({ applicationId: "app_revalidated", instanceId: "ins_revalidated" }), - ); - - await init({ yes: true }); - - expect(revalidateRuntime).toHaveBeenCalledWith(verificationPlan, linkedKey); - expect(commitLocal.mock.invocationCallOrder[0]).toBeLessThan( - revalidateRuntime.mock.invocationCallOrder[0]!, - ); - expect(revalidateRuntime.mock.invocationCallOrder[0]).toBeLessThan( - applyRemote.mock.invocationCallOrder[0]!, - ); - }); - - test("blocks Native mutation when the final runtime-key revalidation fails", async () => { - setup({ email: "test@test.com" }); - const iosCtx = nativeIOSContext(); - const linkedKey = `pk_test_${Buffer.from("runtime-race.clerk.example$").toString("base64")}`; - const verificationPlan = iosRuntimeKeyVerificationPlan(); - const setupResult = iosSetupResult({ - requiresLinkedApp: true, - requiresDevelopmentKey: true, - verifiesExistingKey: true, - runtimeKeyVerificationPlan: verificationPlan, - }); - spyOn(context, "gatherContext").mockResolvedValue(iosCtx); - spyOn(config, "resolveProfile").mockResolvedValue({ - profile: { appId: "app_runtime_race" }, - } as never); - spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); - spyOn(iosApplyMod, "applyIOSPlannedLocalSetup").mockResolvedValue(undefined); - spyOn(iosApplyMod, "verifyIOSRuntimeKeySetup").mockRejectedValue( - new CliError("The selected iOS runtime-key source changed.", { - code: ERROR_CODE.IOS_SETUP_STALE, - }), - ); - spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ - applicationId: "app_runtime_race", - instanceId: "ins_runtime_race", - publishableKey: linkedKey, - }); - spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( - iosRemotePlan({ applicationId: "app_runtime_race", instanceId: "ins_runtime_race" }), - ); - - await expect(init({ yes: true })).rejects.toMatchObject({ - code: ERROR_CODE.IOS_SETUP_STALE, - }); - - expect(nativeRemoteMod.applyIOSNativeRemoteSetup).not.toHaveBeenCalled(); - }); - - test("matching an existing iOS runtime key is a read-only authenticated no-op", async () => { + test("preserves a custom key source after the developer selects its application", async () => { const { captured } = setup({ email: "test@test.com" }); const iosCtx = nativeIOSContext(); - const linkedKey = `pk_test_${Buffer.from("matching.clerk.example$").toString("base64")}`; + const linkedKey = `pk_test_${Buffer.from("selected.clerk.example$").toString("base64")}`; spyOn(context, "gatherContext").mockResolvedValue(iosCtx); spyOn(config, "resolveProfile").mockResolvedValue({ - profile: { appId: "app_matching" }, + profile: { appId: "app_selected" }, } as never); const setupResult = iosSetupResult({ requiresLinkedApp: true, - verifiesExistingKey: true, + requiresExplicitApplication: true, }); spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); const resolveKeys = spyOn( iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey", ).mockResolvedValue({ - applicationId: "app_matching", - instanceId: "ins_matching", + applicationId: "app_selected", + instanceId: "ins_selected", publishableKey: linkedKey, }); spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( iosRemotePlan({ - applicationId: "app_matching", - instanceId: "ins_matching", + applicationId: "app_selected", + instanceId: "ins_selected", }), ); - await init({ yes: true }); + await init({ yes: true, app: "app_selected" }); expect(resolveKeys).toHaveBeenCalledTimes(1); - expect(resolveKeys).toHaveBeenCalledWith("app_matching"); + expect(resolveKeys).toHaveBeenCalledWith("app_selected"); + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: "app_selected", + cwd: iosCtx.cwd, + createIfMissing: undefined, + skipAutolink: true, + requireExistingAppSelection: true, + }); expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, linkedKey); expect(pullMod.pull).not.toHaveBeenCalled(); expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); @@ -1601,7 +1444,7 @@ describe("init iOS", () => { }); const setupResult = iosSetupResult({ requiresLinkedApp: true, - verifiesExistingKey: true, + requiresExplicitApplication: true, }); const localApply = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( @@ -1622,6 +1465,7 @@ describe("init iOS", () => { cwd: iosCtx.cwd, createIfMissing: undefined, skipAutolink: true, + requireExistingAppSelection: true, }); expect(resolveKeys).toHaveBeenCalledTimes(1); expect(iosApplyMod.applyIOSPlannedLocalSetup).toHaveBeenCalledWith(setupResult, key); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 2b7c4e7aa..133fd6ddd 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -69,7 +69,11 @@ import { inspectIOSProject } from "./ios/inspect.ts"; import { recoverIOSFileTransactions } from "./ios/file-transaction.ts"; import { buildIOSSetupPlan } from "./ios/plan.ts"; import { planIOSDirectConfig } from "./ios/direct-config.ts"; -import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./ios/products.ts"; +import { + clerkKitUIInstallDecision, + hasSupportedIOSCustomConfigure, + shouldPlanIOSDirectConfig, +} from "./ios/products.ts"; import { planIOSAssociatedDomain } from "./ios/associated-domain.ts"; import { planIOSAppleEntitlement } from "./ios/apple-entitlement.ts"; import { planIOSPrebuiltAuth } from "./ios/prebuilt-auth.ts"; @@ -240,9 +244,8 @@ export async function init(options: InitOptions = {}) { ) : undefined; const productDecision = selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined; - const hasCustomConfigure = selectedTarget?.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "custom", - ); + const hasSupportedCustomConfigure = + selectedTarget != null && hasSupportedIOSCustomConfigure(selectedTarget); const inspectedPrebuiltAuthPlan = dryRunSelection.state === "selected" ? await planIOSPrebuiltAuth({ @@ -295,7 +298,7 @@ export async function init(options: InitOptions = {}) { projectPath: dryRunSelection.projectPath, targetId: dryRunSelection.targetId, deferToPublishableKey: - directConfigPlan?.status === "ready" || hasCustomConfigure === true, + directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, allowMissingEntitlementsCreation: true, }) : undefined; @@ -389,7 +392,7 @@ export async function init(options: InitOptions = {}) { }); if (agent && iosLocalSetup.requiresExplicitApplication && !options.app) { throwUsageError( - "This iOS target already contains a preserved publishable-key configuration. Agent mode cannot choose its Clerk application; ask the developer which existing application it belongs to, then rerun with --app . The custom key value was not inspected and no local files were changed.", + "This iOS target already contains a publishable-key configuration that requires explicit Clerk application selection. Ask the developer which existing application it belongs to, then rerun with --app . No local files were changed.", ); } } @@ -1019,7 +1022,7 @@ function printBootstrapManualSetupInfo(framework: FrameworkInfo): void { `\n Set up Clerk for ${framework.name}:`, " Run `clerk init --app ` to link the project and configure a safely inspectable fresh SwiftUI target automatically.", ' Manual source setup uses `Clerk.configure(publishableKey: "")` in the shipping @main App initializer and `.environment(Clerk.shared)` on the WindowGroup root.', - " Existing ProcessInfo/Run-scheme and LocalSecrets loaders remain supported compatibility paths; clerk init does not replace a custom runtime source.", + " Existing custom Clerk.configure(...) sources remain unchanged; select the existing Clerk application they belong to with --app .", ]; log.info(lines.map(dim).join("\n")); return; diff --git a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts index bbeb48c09..0420254f2 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts @@ -214,7 +214,7 @@ describe("clerk init iOS SDK runtime apply", () => { expect(await treeDigest(root)).toEqual(before); }); - test("blocks all local writes when a structurally eligible runtime sink fails strict preflight", async () => { + test("does not inspect a custom LocalSecrets value during planning", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-blocked-")); temporaryDirectories.push(root); await createIOSFixture(root, { @@ -226,20 +226,19 @@ describe("clerk init iOS SDK runtime apply", () => { await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), ""); const before = await treeDigest(root); - await expect( - applyIOSLocalSetup({ - root, - target: "MyApp", - yes: true, - agent: false, - allowDirty: false, - }), - ).rejects.toThrow("readable XML property-list dictionary"); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + expect(setup.requiresExplicitApplication).toBe(true); expect(await treeDigest(root)).toEqual(before); }); - test("a mismatched expected app key blocks before SDK or key mutation", async () => { + test("a selected application does not rewrite or compare a custom key source", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-relink-")); temporaryDirectories.push(root); await createIOSFixture(root, { @@ -259,18 +258,14 @@ describe("clerk init iOS SDK runtime apply", () => { allowDirty: false, }); - await expect(applyIOSPlannedLocalSetup(setup, expectedKey)).rejects.toMatchObject({ - code: ERROR_CODE.IOS_PUBLISHABLE_KEY_MISMATCH, - message: expect.stringContaining( - "does not match the linked Clerk application's development key", - ), - }); + await applyIOSPlannedLocalSetup(setup, expectedKey); - expect(await treeDigest(root)).toEqual(before); + expect(setup.requiresExplicitApplication).toBe(true); + expect(await treeDigest(root)).not.toEqual(before); expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); }); - test("a requested app with a satisfied sink fails closed without its expected key", async () => { + test("a custom source still requires the linked key for associated-domain setup", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-missing-expected-")); temporaryDirectories.push(root); await createIOSFixture(root, { @@ -297,7 +292,7 @@ describe("clerk init iOS SDK runtime apply", () => { expect(await treeDigest(root)).toEqual(before); }); - test("a mismatched selected-target Run-scheme key blocks before local mutation", async () => { + test("a custom Run-scheme source is preserved after explicit application selection", async () => { const schemeKey = developmentPublishableKey("scheme-existing.clerk.example"); const linkedKey = developmentPublishableKey("scheme-linked.clerk.example"); const { root } = await createProcessInfoFixture(schemeKey, { @@ -315,22 +310,16 @@ describe("clerk init iOS SDK runtime apply", () => { expect(setup).toMatchObject({ requiresDevelopmentKey: true, - verifiesExistingKey: true, - runtimeKeyVerificationPlan: { - status: "ready", - source: { kind: "run-scheme" }, - }, - }); - await expect(applyIOSPlannedLocalSetup(setup, linkedKey)).rejects.toMatchObject({ - code: ERROR_CODE.IOS_PUBLISHABLE_KEY_MISMATCH, + requiresExplicitApplication: true, }); - expect(await treeDigest(root)).toEqual(before); + await applyIOSPlannedLocalSetup(setup, linkedKey); + expect(await treeDigest(root)).not.toEqual(before); expect(JSON.stringify(setup)).not.toContain(schemeKey); expect(`${captured.out}\n${captured.err}`).not.toContain(schemeKey); expect(`${captured.out}\n${captured.err}`).not.toContain(linkedKey); }); - test("a Run-scheme change during local setup rolls every project edit back", async () => { + test("a custom Run-scheme change is not overwritten during local setup", async () => { const expectedKey = developmentPublishableKey("scheme-verified.clerk.example"); const concurrentKey = developmentPublishableKey("scheme-concurrent.clerk.example"); const { root, schemePath } = await createProcessInfoFixture(expectedKey, { @@ -348,25 +337,22 @@ describe("clerk init iOS SDK runtime apply", () => { agent: false, allowDirty: false, }); - await expect( - applyIOSPlannedLocalSetup(setup, expectedKey, { - beforePostWriteValidation: async () => { - await Bun.write(schemePath, runSchemeSource(concurrentKey)); - }, - }), - ).rejects.toThrow("SDK change was restored byte-for-byte"); + await applyIOSPlannedLocalSetup(setup, expectedKey, { + beforePostWriteValidation: async () => { + await Bun.write(schemePath, runSchemeSource(concurrentKey)); + }, + }); - expect(await Bun.file(projectPath).bytes()).toEqual(projectBefore); - expect(await Bun.file(entitlementsPath).bytes()).toEqual(entitlementsBefore); + expect(await Bun.file(projectPath).bytes()).not.toEqual(projectBefore); + expect(await Bun.file(entitlementsPath).bytes()).not.toEqual(entitlementsBefore); expect(await Bun.file(schemePath).text()).toBe(runSchemeSource(concurrentKey)); expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); expect(`${captured.out}\n${captured.err}`).not.toContain(concurrentKey); }); - test("revalidates a satisfied Run-scheme source immediately before remote setup", async () => { + test("does not serialize a custom Run-scheme key into its setup plan", async () => { const expectedKey = developmentPublishableKey("clerk.example.test"); - const concurrentKey = developmentPublishableKey("scheme-race.clerk.example"); - const { root, schemePath } = await createProcessInfoFixture(expectedKey); + const { root } = await createProcessInfoFixture(expectedKey); const setup = await applyIOSLocalSetup({ root, target: "MyApp", @@ -374,20 +360,9 @@ describe("clerk init iOS SDK runtime apply", () => { agent: false, allowDirty: false, }); - const before = await treeDigest(root); - - await expect( - applyIOSPlannedLocalSetup(setup, expectedKey, { - beforePostWriteValidation: async () => { - await Bun.write(schemePath, runSchemeSource(concurrentKey)); - }, - }), - ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE }); - - expect(await Bun.file(schemePath).text()).toBe(runSchemeSource(concurrentKey)); - expect(await treeDigest(root)).not.toEqual(before); + expect(setup.requiresExplicitApplication).toBe(true); + expect(JSON.stringify(setup)).not.toContain(expectedKey); expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); - expect(`${captured.out}\n${captured.err}`).not.toContain(concurrentKey); }); test("a matching expected app key permits SDK installation regardless of local profile", async () => { @@ -417,7 +392,7 @@ describe("clerk init iOS SDK runtime apply", () => { expect(result).toMatchObject({ requiresLinkedApp: true, - verifiesExistingKey: true, + requiresExplicitApplication: true, }); expect((await inspectIOSProject(root, { target: "MyApp" })).appTargets[0]?.packages).toEqual({ package: "remote", @@ -428,7 +403,7 @@ describe("clerk init iOS SDK runtime apply", () => { expect(JSON.stringify(result)).not.toContain(key); }); - test("a LocalSecrets change during SDK validation rolls the project edit back", async () => { + test("a custom LocalSecrets change is not overwritten during SDK installation", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-verification-race-")); temporaryDirectories.push(root); await createIOSFixture(root, { @@ -456,16 +431,14 @@ describe("clerk init iOS SDK runtime apply", () => { allowDirty: false, }); - await expect( - applyIOSPlannedLocalSetup(setup, expectedKey, { - beforePostWriteValidation: async () => { - await Bun.write(localSecretsPath, plist(concurrentKey)); - }, - }), - ).rejects.toThrow("SDK change was restored byte-for-byte"); + await applyIOSPlannedLocalSetup(setup, expectedKey, { + beforePostWriteValidation: async () => { + await Bun.write(localSecretsPath, plist(concurrentKey)); + }, + }); - expect(await Bun.file(projectPath).bytes()).toEqual(projectBefore); - expect(await Bun.file(entitlementsPath).bytes()).toEqual(entitlementsBefore); + expect(await Bun.file(projectPath).bytes()).not.toEqual(projectBefore); + expect(await Bun.file(entitlementsPath).bytes()).not.toEqual(entitlementsBefore); expect(await Bun.file(localSecretsPath).text()).toBe(plist(concurrentKey)); expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); expect(`${captured.out}\n${captured.err}`).not.toContain(concurrentKey); diff --git a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts index 616af6233..01b589c32 100644 --- a/packages/cli-core/src/commands/init/ios/apply-cli.test.ts +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -287,14 +287,6 @@ describe("clerk init iOS SDK apply", () => { const localSecretsPath = join(root, "MyApp", "LocalSecrets.plist"); const localSecretsBefore = await Bun.file(localSecretsPath).text(); - const before = await inspectIOSProject(root, { target: "MyApp" }); - expect(before.diagnostics).toContainEqual( - expect.objectContaining({ - code: "clerk.unconsumed-publishable-key-source", - severity: "warning", - }), - ); - const setup = await applyIOSLocalSetup({ root, target: "MyApp", @@ -310,7 +302,7 @@ describe("clerk init iOS SDK apply", () => { environment: "insert", }, }); - expect(setup.runtimeKeyVerificationPlan).toBeUndefined(); + expect(setup.requiresExplicitApplication).toBe(false); await applyIOSPlannedLocalSetup(setup, authFixtureKey); const appSource = await Bun.file(join(root, "MyApp", "MyAppApp.swift")).text(); @@ -330,14 +322,6 @@ describe("clerk init iOS SDK apply", () => { await mkdir(schemeDirectory, { recursive: true }); await Bun.write(schemePath, schemeSource); - const before = await inspectIOSProject(root, { target: "MyApp" }); - expect(before.diagnostics).toContainEqual( - expect.objectContaining({ - code: "clerk.unconsumed-publishable-key-source", - severity: "warning", - }), - ); - const setup = await applyIOSLocalSetup({ root, target: "MyApp", @@ -488,7 +472,18 @@ struct MyApp: App { const result = await runCLI( root, - ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app-id-prefix", "LEGACY1234"], + [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app", + "app_ios_apply", + "--app-id-prefix", + "LEGACY1234", + ], configDir, ); @@ -521,7 +516,18 @@ struct MyApp: App { const digest = await treeDigest(root); const second = await runCLI( root, - ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app-id-prefix", "LEGACY1234"], + [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app", + "app_ios_apply", + "--app-id-prefix", + "LEGACY1234", + ], configDir, ); expect(second.exitCode).toBe(0); @@ -599,7 +605,17 @@ struct MyApp: App { const digest = await treeDigest(root); const second = await runCLI( root, - ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--sign-in-with-apple"], + [ + "--mode", + "agent", + "init", + "--yes", + "--target", + "MyApp", + "--app", + "app_ios_apply", + "--sign-in-with-apple", + ], configDir, ); expect(second.exitCode).toBe(0); @@ -639,7 +655,7 @@ struct MyApp: App { resetAppleConfiguration({ enabled: false, authenticatable: true }); const withoutOptIn = await runCLI( root, - ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app", "app_ios_apply"], configDir, ); @@ -690,7 +706,7 @@ struct MyApp: App { const result = await runCLI( root, - ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app", "app_ios_apply"], configDir, ); @@ -727,7 +743,7 @@ struct MyApp: App { const afterFirstRun = await treeDigest(root); const second = await runCLI( root, - ["--mode", "agent", "init", "--yes", "--target", "MyApp"], + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app", "app_ios_apply"], configDir, ); expect(second.exitCode).toBe(0); @@ -937,7 +953,7 @@ import SwiftUI expect(await treeDigest(root)).toEqual(before); }); - test("an already-linked SDK returns a read-only runtime verification without prompting or writing", async () => { + test("an already-linked SDK preserves a custom runtime source without prompting or writing", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-verification-")); temporaryDirectories.push(root); await createIOSFixture(root, { @@ -960,18 +976,12 @@ import SwiftUI const result = await applyIOSLocalSetup({ root, target: "MyApp", - yes: false, + yes: true, agent: true, allowDirty: false, }); - expect(result.runtimeKeyVerificationPlan).toMatchObject({ - status: "ready", - source: { - kind: "local-secrets-plist", - path: "MyApp/LocalSecrets.plist", - }, - }); + expect(result.requiresExplicitApplication).toBe(true); expect(confirmation).not.toHaveBeenCalled(); expect(await treeDigest(root)).toEqual(before); } finally { @@ -979,7 +989,7 @@ import SwiftUI } }); - test("preserves a LocalSecrets runtime sink that has no valid key", async () => { + test("preserves a custom LocalSecrets source without inspecting its value", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-runtime-preflight-")); temporaryDirectories.push(root); await createIOSFixture(root, { @@ -993,16 +1003,15 @@ import SwiftUI ); const before = await treeDigest(root); - await expect( - applyIOSLocalSetup({ - root, - target: "MyApp", - yes: true, - agent: false, - allowDirty: false, - }), - ).rejects.toThrow("will not change that compatibility file"); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + expect(setup.requiresExplicitApplication).toBe(true); expect(await treeDigest(root)).toEqual(before); }); }); diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 128590fe3..737dea36e 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -20,7 +20,11 @@ import { type PreparedIOSSDKInstallMutation, } from "./install-sdk.ts"; import { buildIOSSetupPlan } from "./plan.ts"; -import { clerkKitUIInstallDecision, shouldPlanIOSDirectConfig } from "./products.ts"; +import { + clerkKitUIInstallDecision, + hasSupportedIOSCustomConfigure, + shouldPlanIOSDirectConfig, +} from "./products.ts"; import { planIOSDirectConfig, prepareIOSDirectConfigMutation, @@ -400,15 +404,10 @@ export async function applyIOSLocalSetup( requirePrebuiltAuthCompatibility: prebuiltAuthActive, }); - const customConfigureCalls = selectedTarget.swift.configureCalls.filter( + const hasCustomConfigure = selectedTarget.swift.configureCalls.some( (call) => call.publishableKeyWiring === "custom", ); - const hasCustomConfigure = customConfigureCalls.length > 0; - const hasSupportedCustomConfigure = - selectedTarget.swift.evidenceComplete && - selectedTarget.swift.status !== "ambiguous" && - selectedTarget.swift.configureCalls.length === 1 && - customConfigureCalls[0]?.startupBinding === "app-init"; + const hasSupportedCustomConfigure = hasSupportedIOSCustomConfigure(selectedTarget); const shouldPlanDirectConfig = shouldPlanIOSDirectConfig( inspection, selectedTarget, @@ -672,6 +671,13 @@ export async function applyIOSLocalSetup( ), ); } + if (hasSupportedCustomConfigure) { + log.info( + dim( + " PRESERVE Custom Clerk.configure(...) publishable-key source. Its value will not be inspected; the developer must select the existing Clerk application it belongs to.", + ), + ); + } if (prebuiltAuthPlan) { const operation = prebuiltAuthPlan.status === "ready" ? "MODIFY" : "VERIFY"; log.info(` ${yellow(operation)} ${prebuiltAuthPlan.sourcePath}`); 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 5d80f8337..7639a7aad 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 @@ -726,7 +726,6 @@ describe("inspectTargetBuildConfigurations", () => { projectPath: "Example.xcodeproj", configurations: configurations.map(({ model }) => model), packages: { package: "absent", clerkKit: "absent", clerkKitUI: "absent" }, - runtimeKeySinks: [], swift: { sourceFilesScanned: 0, evidenceComplete: true, @@ -734,7 +733,6 @@ describe("inspectTargetBuildConfigurations", () => { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], - localSecretsRuntimeBindings: [], appRootEvidence: [], environmentInjections: [], rootEnvironmentInjections: [], diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts index 4d736c5e2..d05a87dd3 100644 --- a/packages/cli-core/src/commands/init/ios/dry-run.test.ts +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -111,9 +111,9 @@ describe("clerk init --dry-run", () => { expect(output).toMatchObject({ schemaVersion: 1, mode: "read-only", - status: "ready", + status: "action-required", inspection: { platform: "ios", selection: { state: "selected", targetName: "MyApp" } }, - plan: { kind: "clerk-ios-setup", status: "ready" }, + plan: { kind: "clerk-ios-setup", status: "action-required" }, nativeReadiness: { kind: "clerk-ios-native-readiness", remote: { @@ -625,7 +625,7 @@ struct MyApp: App { expect(await treeDigest(root)).toEqual(before); }); - test("does not advertise runtime-key automation when the strict plist preflight blocks", async () => { + test("does not inspect a malformed custom LocalSecrets value", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -644,9 +644,40 @@ struct MyApp: App { const configure = output.plan.steps.find( (step: { id: string }) => step.id === "configure-publishable-key", ); - expect(configure).toMatchObject({ status: "blocked", automatable: false }); - expect(configure.description).toContain("readable XML property-list dictionary"); - expect(configure.description).not.toContain("clerk init can fetch"); + expect(configure).toMatchObject({ status: "satisfied", automatable: false }); + expect(configure.description).toContain("custom publishable-key source"); + expect(configure.description).toContain("value is not inspected"); + expect(await treeDigest(root)).toEqual(before); + }); + + test("does not defer domain automation for an ambiguous custom configuration", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-custom-ambiguous-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const sourcePath = join(root, "MyApp", "MyAppApp.swift"); + await Bun.write( + sourcePath, + `${await Bun.file(sourcePath).text()}\nfunc configureAgain() { Clerk.configure(publishableKey: OtherSecrets.key) }\n`, + ); + const configDir = await createIsolatedCLIState(); + const before = await treeDigest(root); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.plan.steps).toContainEqual( + expect.objectContaining({ + id: "add-associated-domain", + status: "review", + automatable: false, + }), + ); + expect(output.nativeReadiness.associatedDomain.automatable).toBe(false); expect(await treeDigest(root)).toEqual(before); }); @@ -691,6 +722,24 @@ struct MyApp: App { expect(await treeDigest(configDir)).toEqual(configBefore); }); + test("human output labels a supported custom key source without claiming the key is missing", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-custom-output-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + const configDir = await createIsolatedCLIState(); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = `${result.stdout}\n${result.stderr}`; + expect(output).toContain("Publishable key: custom source (value not inspected)"); + expect(output).not.toContain("Publishable key: not found"); + }); + test("human output distinguishes an actionable plan from a ready plan", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts index efe891e28..203020988 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -23,6 +23,26 @@ async function inspectionFor( return inspectIOSProject(root, { target }); } +async function inspectionWithInlineKey(options: Parameters[1] = {}) { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-native-readiness-inline-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { ...options, complete: false, includeKey: false }); + const encodedHost = Buffer.from("native.clerk.example$").toString("base64"); + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI + +@main +struct MyApp: App { + init() { Clerk.configure(publishableKey: "pk_test_${encodedHost}") } + var body: some Scene { WindowGroup { Text("Hello") } } +} +`, + ); + return inspectIOSProject(root, { target: "MyApp" }); +} + afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); }); @@ -144,11 +164,7 @@ describe("buildIOSNativeReadinessAudit", () => { }); test("requires the bare domain when only Apple's developer-mode entry is present", async () => { - const inspection = await inspectionFor({ - complete: true, - includeKey: false, - localSecrets: true, - }); + const inspection = await inspectionWithInlineKey(); for (const configuration of inspection.appTargets[0]!.configurations) { configuration.entitlements!.associatedDomains = [ "webcredentials:native.clerk.example?mode=developer", @@ -167,11 +183,7 @@ describe("buildIOSNativeReadinessAudit", () => { }); test("recognizes the exact bare domain as locally satisfied", async () => { - const inspection = await inspectionFor({ - complete: true, - includeKey: false, - localSecrets: true, - }); + const inspection = await inspectionWithInlineKey(); for (const configuration of inspection.appTargets[0]!.configurations) { configuration.entitlements!.associatedDomains = ["webcredentials:native.clerk.example"]; } @@ -188,11 +200,7 @@ describe("buildIOSNativeReadinessAudit", () => { }); test("does not satisfy readiness with a differently cased service token", async () => { - const inspection = await inspectionFor({ - complete: true, - includeKey: false, - localSecrets: true, - }); + const inspection = await inspectionWithInlineKey(); for (const configuration of inspection.appTargets[0]!.configurations) { configuration.entitlements!.associatedDomains = ["WEBCREDENTIALS:native.clerk.example"]; } @@ -206,11 +214,7 @@ describe("buildIOSNativeReadinessAudit", () => { }); test("blocks automation when configurations have mixed entitlements evidence", async () => { - const inspection = await inspectionFor({ - complete: true, - includeKey: false, - localSecrets: true, - }); + const inspection = await inspectionWithInlineKey(); const target = inspection.appTargets[0]!; target.configurations[1]!.entitlements = undefined; @@ -250,11 +254,7 @@ describe("buildIOSNativeReadinessAudit", () => { }); test("preserves all distinct existing XML entitlements routes", async () => { - const inspection = await inspectionFor({ - complete: true, - includeKey: false, - localSecrets: true, - }); + const inspection = await inspectionWithInlineKey(); const target = inspection.appTargets[0]!; const release = target.configurations[1]!; release.entitlements = { diff --git a/packages/cli-core/src/commands/init/ios/output.ts b/packages/cli-core/src/commands/init/ios/output.ts index a6247d03e..a7a9359fd 100644 --- a/packages/cli-core/src/commands/init/ios/output.ts +++ b/packages/cli-core/src/commands/init/ios/output.ts @@ -1,6 +1,7 @@ import type { IOSProjectInspectionResult, IOSSetupPlan, IOSSetupStepStatus } from "./types.ts"; import { buildIOSNativeReadinessAudit, type IOSNativeReadinessAudit } from "./native-readiness.ts"; import type { IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { hasSupportedIOSCustomConfigure } from "./products.ts"; const STATUS_MARKER: Record = { satisfied: "✓", @@ -84,6 +85,8 @@ export function formatIOSSetupPlan( lines.push( ` Publishable key: found (${inspection.localPublishableKey.instanceType}; ${inspection.localPublishableKey.frontendApiHost})`, ); + } else if (selected && hasSupportedIOSCustomConfigure(selected)) { + lines.push(" Publishable key: custom source (value not inspected)"); } else { const keyStatus = inspection.localPublishableKey.conflict ? "conflicting local sources" diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 821d14273..696a1b851 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -24,7 +24,7 @@ afterEach(async () => { }); describe("buildIOSSetupPlan", () => { - test("returns stable ordered steps without treating a project env key as runtime wiring", async () => { + test("returns stable ordered steps while preserving a custom project key source", async () => { const plan = await planFor({ complete: true }); expect(plan.steps.map((step) => step.id)).toEqual([ @@ -43,10 +43,9 @@ describe("buildIOSSetupPlan", () => { }); expect(plan.steps.filter((step) => step.automatable)).toEqual([]); const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); - expect(configureStep?.status).toBe("review"); - expect(configureStep?.description).toContain( - "could not validate a usable selected-target runtime key source", - ); + expect(configureStep?.status).toBe("satisfied"); + expect(configureStep?.description).toContain("custom publishable-key source"); + expect(configureStep?.description).toContain("value is not inspected"); const domainStep = plan.steps.find((step) => step.id === "add-associated-domain"); expect(domainStep?.status).toBe("blocked"); expect(domainStep?.description).toContain("valid local publishable key is needed"); @@ -56,7 +55,7 @@ describe("buildIOSSetupPlan", () => { expect(JSON.stringify(plan)).not.toContain("CLERK_PUBLISHABLE_KEY="); }); - test("satisfies configuration when a target LocalSecrets key has recognized loader wiring", async () => { + test("classifies a LocalSecrets loader as a preserved custom key source", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -66,12 +65,13 @@ describe("buildIOSSetupPlan", () => { expect(inspection.appTargets[0]?.swift.configureCalls).toEqual([ { + inlinePublishableKey: undefined, path: "MyApp/MyAppApp.swift", - publishableKeyWiring: "local-secrets-loader", + publishableKeyWiring: "custom", startupBinding: "app-init", - localSecretsRuntimeBinding: "proven", }, ]); + expect(inspection.localPublishableKey.found).toBe(false); expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( "satisfied", ); @@ -84,7 +84,7 @@ describe("buildIOSSetupPlan", () => { const inspection = await inspectIOSProject(root); inspection.appTargets[0]!.swift.configureCalls.push({ path: "MyApp/SecondarySetup.swift", - publishableKeyWiring: "unknown", + publishableKeyWiring: "custom", startupBinding: "unproven", }); @@ -96,35 +96,6 @@ describe("buildIOSSetupPlan", () => { expect(configureStep?.description).toContain("More than one Clerk.configure"); }); - test("does not replace an empty LocalSecrets source from a stale scheme candidate", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); - await Bun.write( - join(root, "MyApp", "LocalSecrets.plist"), - '', - ); - const inspection = await inspectIOSProject(root); - const schemePath = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; - inspection.localPublishableKey = { - evidenceComplete: true, - found: true, - source: schemePath, - frontendApiHost: "stale.clerk.example", - instanceType: "development", - conflict: false, - candidateSources: [schemePath], - invalidSources: [], - }; - - const configureStep = buildIOSSetupPlan(inspection).steps.find( - (step) => step.id === "configure-publishable-key", - ); - - expect(configureStep).toMatchObject({ status: "review", automatable: false }); - expect(configureStep?.description).toContain("could not be connected to its loader"); - }); - test("satisfies configuration and derives the domain from a redacted inline literal", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); @@ -573,7 +544,7 @@ struct MyApp: App { }); }); - test("does not satisfy or automate LocalSecrets wiring from a same-file helper", async () => { + test("does not satisfy a custom configure call outside app startup", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -593,9 +564,8 @@ struct MyApp: App { const plan = buildIOSSetupPlan(inspection); expect(inspection.appTargets[0]?.swift.configureCalls[0]).toMatchObject({ - publishableKeyWiring: "local-secrets-loader", + publishableKeyWiring: "custom", startupBinding: "unproven", - localSecretsRuntimeBinding: "proven", }); expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ status: "review", @@ -603,150 +573,29 @@ struct MyApp: App { }); }); - test("reviews a target runtime key when the configure expression has unknown wiring", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); - const inspection = await inspectIOSProject(root); - inspection.appTargets[0]!.swift.configureCalls = [ - { - path: "MyApp/MyAppApp.swift", - publishableKeyWiring: "unknown", - startupBinding: "app-init", - }, - ]; - - const plan = buildIOSSetupPlan(inspection); - - expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( - "review", - ); - }); - - test("satisfies configuration for a selected-target Run scheme and ProcessInfo wiring", async () => { + test("classifies ProcessInfo wiring as a preserved custom key source", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: true }); const inspection = await inspectIOSProject(root); if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); - const directConfigPlan = await planIOSDirectConfig({ - root, - projectPath: inspection.selection.projectPath, - targetId: inspection.selection.targetId, - }); - const schemePath = "MyApp.xcodeproj/xcshareddata/xcschemes/MyApp.xcscheme"; - inspection.localPublishableKey = { - evidenceComplete: true, - found: true, - source: schemePath, - frontendApiHost: "clerk.example.test", - instanceType: "development", - conflict: false, - candidateSources: [schemePath], - invalidSources: [], - }; inspection.appTargets[0]!.swift.configureCalls = [ { path: "MyApp/MyAppApp.swift", - publishableKeyWiring: "process-info-environment", + publishableKeyWiring: "custom", startupBinding: "app-init", }, ]; - const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); - - expect(directConfigPlan.status).toBe("blocked"); - expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( - "satisfied", - ); - }); - - test("keeps non-runtime key sources as available-to-copy evidence", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true }); - const inspection = await inspectIOSProject(root); - - for (const source of [ - ".env", - ".clerk/.tmp/keyless.json", - "CLERK_PUBLISHABLE_KEY environment variable", - ]) { - inspection.localPublishableKey = { - evidenceComplete: true, - found: true, - source, - frontendApiHost: "clerk.example.test", - instanceType: "development", - conflict: false, - candidateSources: [source], - invalidSources: [], - }; - const step = buildIOSSetupPlan(inspection).steps.find( - (candidate) => candidate.id === "configure-publishable-key", - ); - expect(step?.status).toBe("review"); - expect(step?.description).toContain("available to copy"); - } - }); - - test("reviews a malformed available-only key instead of treating it as runtime failure", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false }); - await Bun.write(join(root, ".env"), "CLERK_PUBLISHABLE_KEY=not-a-key\n"); - const inspection = await inspectIOSProject(root); - const plan = buildIOSSetupPlan(inspection); - expect(inspection.localPublishableKey).toMatchObject({ - found: false, - candidateSources: [".env"], - invalidSources: [".env"], - }); - expect(inspection.localPublishableKey.source).toBeUndefined(); expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( - "review", + "satisfied", ); + expect(inspection.localPublishableKey.found).toBe(false); }); - test("preserves a malformed key in a proven selected-target runtime sink", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); - await Bun.write( - join(root, "MyApp", "LocalSecrets.plist"), - 'CLERK_PUBLISHABLE_KEYnot-a-key', - ); - const inspection = await inspectIOSProject(root); - if (inspection.selection.state !== "selected") throw new Error("fixture target not selected"); - const directConfigPlan = await planIOSDirectConfig({ - root, - projectPath: inspection.selection.projectPath, - targetId: inspection.selection.targetId, - }); - - const plan = buildIOSSetupPlan(inspection, { directConfigPlan }); - - expect(inspection.localPublishableKey).toMatchObject({ - found: false, - source: "MyApp/LocalSecrets.plist", - invalidSources: ["MyApp/LocalSecrets.plist"], - }); - expect(directConfigPlan.status).toBe("blocked"); - expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ - status: "blocked", - automatable: false, - }); - expect( - plan.steps.find((step) => step.id === "configure-publishable-key")?.description, - ).toContain("malformed"); - expect( - plan.steps.find((step) => step.id === "configure-publishable-key")?.description, - ).not.toContain("Automatic direct configuration stopped"); - }); - - test("does not automate a name-only LocalSecrets expression without an exact loader binding", async () => { + test("preserves an arbitrary named key loader without interpreting it", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); @@ -768,7 +617,7 @@ struct MyApp: App { const plan = buildIOSSetupPlan(await inspectIOSProject(root)); expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ - status: "blocked", + status: "satisfied", automatable: false, }); }); @@ -934,18 +783,28 @@ struct MyApp: App { expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.status).toBe("review"); }); - test("reviews an existing configure call when no usable local key can be validated", async () => { + test("preserves an existing custom configure call without validating its value", async () => { const plan = await planFor({ complete: true, includeKey: false }); expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( - "review", + "satisfied", ); }); test("requires the bare domain when only Apple's developer-mode suffix is present", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await createIOSFixture(root, { complete: true, includeKey: false }); + const key = `pk_test_${Buffer.from("native.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI +@main struct MyApp: App { + init() { Clerk.configure(publishableKey: "${key}") } + var body: some Scene { WindowGroup { Text("Hello").environment(Clerk.shared) } } +}`, + ); const inspection = await inspectIOSProject(root); for (const configuration of inspection.appTargets[0]!.configurations) { configuration.entitlements!.associatedDomains = [ @@ -961,7 +820,17 @@ struct MyApp: App { test("matches only the associated-domain hostname case-insensitively", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); - await createIOSFixture(root, { complete: true, includeKey: false, localSecrets: true }); + await createIOSFixture(root, { complete: true, includeKey: false }); + const key = `pk_test_${Buffer.from("native.clerk.example$").toString("base64")}`; + await Bun.write( + join(root, "MyApp", "MyAppApp.swift"), + `import ClerkKit +import SwiftUI +@main struct MyApp: App { + init() { Clerk.configure(publishableKey: "${key}") } + var body: some Scene { WindowGroup { Text("Hello").environment(Clerk.shared) } } +}`, + ); const inspection = await inspectIOSProject(root); for (const configuration of inspection.appTargets[0]!.configurations) { diff --git a/packages/cli-core/src/commands/init/ios/products.ts b/packages/cli-core/src/commands/init/ios/products.ts index ab0f6dd9d..30e7913ce 100644 --- a/packages/cli-core/src/commands/init/ios/products.ts +++ b/packages/cli-core/src/commands/init/ios/products.ts @@ -22,7 +22,23 @@ export function shouldInstallClerkKitUI(target: IOSAppTarget): boolean { return clerkKitUIInstallDecision(target) === "prebuilt"; } -/** Existing custom runtime-key routes that direct source configuration must preserve. */ +/** + * A custom publishable-key source is structurally usable only when the + * selected app has one unambiguous configure call at startup. The expression + * itself remains opaque and is never inspected or compared. + */ +export function hasSupportedIOSCustomConfigure(target: IOSAppTarget): boolean { + const configureCalls = target.swift.configureCalls; + return ( + target.swift.evidenceComplete && + target.swift.status !== "ambiguous" && + configureCalls.length === 1 && + configureCalls[0]?.publishableKeyWiring === "custom" && + configureCalls[0].startupBinding === "app-init" + ); +} + +/** Existing custom configuration that direct source setup must preserve. */ export function hasIOSDirectConfigCompatibility( inspection: IOSProjectInspectionResult, target: IOSAppTarget, diff --git a/packages/cli-core/src/commands/init/strategy.test.ts b/packages/cli-core/src/commands/init/strategy.test.ts index f5c564d28..36419637c 100644 --- a/packages/cli-core/src/commands/init/strategy.test.ts +++ b/packages/cli-core/src/commands/init/strategy.test.ts @@ -392,7 +392,7 @@ describe("init strategy", () => { expect(captured.err).toContain("clerk init --app "); expect(captured.err).toContain("Clerk.configure(publishableKey:"); expect(captured.err).toContain(".environment(Clerk.shared)"); - expect(captured.err).toContain("LocalSecrets loaders remain supported compatibility paths"); + expect(captured.err).toContain("custom Clerk.configure(...) sources remain unchanged"); expect(captured.err).not.toContain("clerk env pull"); }); diff --git a/packages/cli-core/src/commands/link/index.test.ts b/packages/cli-core/src/commands/link/index.test.ts index 8a0d0fb9e..4f8f1ba45 100644 --- a/packages/cli-core/src/commands/link/index.test.ts +++ b/packages/cli-core/src/commands/link/index.test.ts @@ -488,6 +488,110 @@ describe("link", () => { ); }); + test("custom iOS setup offers only existing applications", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockListApplications.mockResolvedValue([mockApp]); + mockSearch.mockImplementation( + async (config: { + source: (term: string | undefined) => { name: string; value: string }[]; + }) => { + const results = config.source(undefined); + expect(results).toHaveLength(1); + expect(results[0]?.value).toBe("app_123"); + expect(results.some((result) => result.value === "__create_new__")).toBe(false); + return "app_123"; + }, + ); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runLink({ skipAutolink: true, requireExistingAppSelection: true }); + + expect(mockCreateApplication).not.toHaveBeenCalled(); + expect(mockSetProfile).toHaveBeenCalledWith( + "github.com/org/repo", + expect.objectContaining({ appId: "app_123" }), + ); + }); + + test("custom iOS setup explicitly confirms an existing project link", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockResolveProfile.mockResolvedValue({ + path: "/repo/.git", + profile: { + workspaceId: "", + appId: "app_existing", + appName: "Existing App", + instances: { development: "ins_1" }, + }, + }); + mockConfirm.mockResolvedValue(true); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runLink({ skipIfLinked: true, requireExistingAppSelection: true }); + + expect(mockConfirm).toHaveBeenCalledWith({ + message: expect.stringContaining("Use"), + default: true, + }); + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockSetProfile).not.toHaveBeenCalled(); + }); + + test("explicit --app accepts the same existing iOS link without another prompt", async () => { + mockIsAgent.mockReturnValue(false); + mockResolveProfile.mockResolvedValue({ + path: "/repo/.git", + profile: { + workspaceId: "", + appId: "app_123", + appName: "Existing App", + instances: { development: "ins_1" }, + }, + }); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await runLink({ + app: "app_123", + skipIfLinked: true, + requireExistingAppSelection: true, + }); + + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockFetchApplication).not.toHaveBeenCalled(); + expect(mockSetProfile).not.toHaveBeenCalled(); + }); + + test("custom iOS setup never creates an application when none can be selected", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockListApplications.mockResolvedValue([]); + consoleSpy = spyOn(console, "log").mockImplementation(() => {}); + + await expect( + runLink({ skipAutolink: true, requireExistingAppSelection: true }), + ).rejects.toThrow("No existing Clerk applications are available"); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockCreateApplication).not.toHaveBeenCalled(); + expect(mockSetProfile).not.toHaveBeenCalled(); + }); + + test("custom iOS setup surfaces application-list outages instead of offering creation", async () => { + mockIsAgent.mockReturnValue(false); + mockGetToken.mockResolvedValue("token"); + mockListApplications.mockRejectedValue(PlapiError.fromBody(500, "Internal Server Error")); + + await expect( + runLink({ skipAutolink: true, requireExistingAppSelection: true }), + ).rejects.toBeInstanceOf(PlapiError); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockCreateApplication).not.toHaveBeenCalled(); + expect(mockSetProfile).not.toHaveBeenCalled(); + }); + test("source returns create option first, then all choices, when term is empty", async () => { mockIsAgent.mockReturnValue(false); mockGetToken.mockResolvedValue("token"); diff --git a/packages/cli-core/src/commands/link/index.ts b/packages/cli-core/src/commands/link/index.ts index 5972265b9..03644ca89 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -165,6 +165,10 @@ async function handleExistingProfile( ): Promise { printExistingStatus(existing, normalizedRemote); + // Supplying the currently linked app is already an explicit selection. + // Do not ask the developer to confirm or relink it a second time. + if (options.app === existing.profile.appId) return false; + if (existing.availableRemote) { log.info( `We detected this is now a git repository with remote ${dim(existing.availableRemote)}.`, @@ -176,7 +180,7 @@ async function handleExistingProfile( if (upgrade) { await moveProfile(existing.path, existing.availableRemote); log.info(`\nLink updated to use git remote (${cyan(existing.availableRemote)})`); - return false; + if (!options.requireExistingAppSelection) return false; } } @@ -221,7 +225,7 @@ async function resolveApp( detectKeys: boolean, allowCreate = true, ): Promise { - const apps = await fetchAppsTolerantly(); + const apps = await fetchAppsTolerantly({ allowCreate }); if (apps.length > 0 && detectKeys) { const detected = await tryDetectApp(cwd, apps); diff --git a/packages/cli-core/src/lib/app-picker.ts b/packages/cli-core/src/lib/app-picker.ts index 5b0fec5f6..995166081 100644 --- a/packages/cli-core/src/lib/app-picker.ts +++ b/packages/cli-core/src/lib/app-picker.ts @@ -27,13 +27,15 @@ export function appLabel(app: Application): string { * Fetch the user's applications. Returns an empty list when PLAPI is degraded * (5xx) so the caller can still offer "create a new application". */ -export async function fetchAppsTolerantly(): Promise { +export async function fetchAppsTolerantly( + options: { allowCreate?: boolean } = {}, +): Promise { try { return await withSpinner("Fetching applications...", async () => withApiContext(listApplications(), "Failed to fetch applications"), ); } catch (error) { - if (error instanceof PlapiError && error.status >= 500) { + if (error instanceof PlapiError && error.status >= 500 && options.allowCreate !== false) { log.info("Could not fetch your applications, you can still create a new one"); return []; } diff --git a/packages/cli-core/src/test/lib/init-harness.ts b/packages/cli-core/src/test/lib/init-harness.ts index be7784f50..94e906979 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -220,10 +220,9 @@ export function useInitHarness(): InitHarness { nativeAppleRequested: false, requiresLinkedApp: false, requiresDevelopmentKey: false, - verifiesExistingKey: false, + requiresExplicitApplication: false, }), spyOn(iosApplyModule, "applyIOSPlannedLocalSetup").mockResolvedValue(undefined), - spyOn(iosApplyModule, "verifyIOSRuntimeKeySetup").mockResolvedValue(undefined), spyOn(nativeRemoteModule, "prepareIOSNativeRemoteSetup").mockResolvedValue({ schemaVersion: 1, kind: "clerk-ios-native-remote-setup", From 2f5d7a681727063471d77225c937da8a0c2a4ad4 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 10:21:18 -0400 Subject: [PATCH 43/55] refactor(ios): remove callback correctness inspection --- .../src/commands/init/frameworks/ios.test.ts | 35 -------- .../src/commands/init/frameworks/ios.ts | 7 -- .../commands/init/ios/build-settings.test.ts | 2 - .../cli-core/src/commands/init/ios/inspect.ts | 2 - .../src/commands/init/ios/plan.test.ts | 68 ++-------------- .../cli-core/src/commands/init/ios/plan.ts | 31 ------- .../src/commands/init/ios/products.test.ts | 2 - .../src/commands/init/ios/swift-app-root.ts | 53 ------------ .../src/commands/init/ios/swift.test.ts | 80 ++----------------- .../cli-core/src/commands/init/ios/swift.ts | 21 ++--- .../cli-core/src/commands/init/ios/types.ts | 5 -- 11 files changed, 15 insertions(+), 291 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/ios.test.ts b/packages/cli-core/src/commands/init/frameworks/ios.test.ts index 8eba8b2dd..33ab974a1 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.test.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.test.ts @@ -231,38 +231,3 @@ test("does not derive setup state from a LocalSecrets value", async () => { plan.postInstructions.some((i) => i.includes("dashboard.clerk.com/~/native-applications")), ).toBe(true); }); - -test("only recommends callback wiring for a custom native email-link flow", async () => { - const root = await makeIOSFixture(false); - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - `import ClerkKit - import SwiftUI - @main struct MyApp: App { - var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } - } - func begin(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, - ); - - const magicLinkPlan = await ios.scaffold({ ...makeCtx(), cwd: root }); - expect( - magicLinkPlan.postInstructions.some( - (instruction) => - instruction.includes("custom native email-link flow") && instruction.includes("onOpenURL"), - ), - ).toBe(true); - - await Bun.write( - join(root, "MyApp", "MyAppApp.swift"), - `import ClerkKit - import SwiftUI - @main struct MyApp: App { - var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } - } - func begin() async throws { try await Clerk.shared.auth.signInWithApple() }`, - ); - const applePlan = await ios.scaffold({ ...makeCtx(), cwd: root }); - expect(applePlan.postInstructions.some((instruction) => instruction.includes("onOpenURL"))).toBe( - false, - ); -}); diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 75ff5e330..03ea0b5d7 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -153,12 +153,6 @@ export const ios: FrameworkScaffold = { : "Native Sign in with Apple is ready; AuthView displays Apple automatically, while custom flows can call `try await Clerk.shared.auth.signInWithApple()`", ] : []; - const callbackInstructions = needsAttention("wire-auth-callbacks") - ? [ - "For a custom native email-link flow, attach an onOpenURL handler to the shipping SwiftUI root and forward incoming URLs to Clerk", - ] - : []; - return { actions: [], postInstructions: [ @@ -169,7 +163,6 @@ export const ios: FrameworkScaffold = { ...nativeAppleInstructions, ...authFlowInstructions, ...environmentInstructions, - ...callbackInstructions, "Full setup guide: https://clerk.com/docs/ios/getting-started/quickstart", ], }; 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 7639a7aad..3d94f97c8 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 @@ -738,9 +738,7 @@ describe("inspectTargetBuildConfigurations", () => { rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], - magicLinkAuthReferences: [], openURLHandlers: [], - rootOpenURLHandlers: [], status: "absent", }, }, diff --git a/packages/cli-core/src/commands/init/ios/inspect.ts b/packages/cli-core/src/commands/init/ios/inspect.ts index e19730fbc..3177bc4cc 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -98,9 +98,7 @@ function emptySwiftInspection() { rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], - magicLinkAuthReferences: [], openURLHandlers: [], - rootOpenURLHandlers: [], status: "absent" as const, }; } diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 696a1b851..686f38f70 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -255,10 +255,9 @@ struct MyApp: App { expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( "--prebuilt-auth-ui", ); - expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toBeUndefined(); }); - test("uses the documented AuthView sheet without generating app-level callback code", async () => { + test("uses the documented AuthView sheet when prebuilt authentication is selected", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-selected-prebuilt-auth-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: false, includeKey: false }); @@ -278,7 +277,6 @@ struct MyApp: App { status: "required", automatable: true, }); - expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toBeUndefined(); expect(plan.steps.find((step) => step.id === "add-authentication-flow")?.description).toContain( "network-free local plan", ); @@ -287,7 +285,7 @@ struct MyApp: App { ); }); - test("scopes callback review to custom email-link flows on the proven root", async () => { + test("treats a custom email-link implementation as an existing authentication flow", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-magic-link-")); temporaryDirectories.push(root); await createIOSFixture(root, { complete: false, includeKey: false }); @@ -301,72 +299,17 @@ struct MyApp: App { WindowGroup { ContentView() .environment(Clerk.shared) - .onOpenURL { url in Task { try await Clerk.shared.handle(url) } } } } } func send(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, ); - const provenPlan = buildIOSSetupPlan(await inspectIOSProject(root)); - expect(provenPlan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ - title: "Wire custom email-link callbacks", - status: "review", - automatable: false, - }); - expect( - provenPlan.steps.find((step) => step.id === "wire-auth-callbacks")?.description, - ).toContain("Confirm that custom email-link callbacks reach Clerk at runtime"); - - await Bun.write( - appPath, - `import ClerkKit - import SwiftUI - @main struct MyApp: App { - var body: some Scene { WindowGroup { ContentView().environment(Clerk.shared) } } - } - struct UnusedHelper: View { - var body: some View { - Text("Unused").onOpenURL { url in Task { try await Clerk.shared.handle(url) } } - } - } - func send(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, - ); - - const offRootPlan = buildIOSSetupPlan(await inspectIOSProject(root)); - expect(offRootPlan.steps.find((step) => step.id === "wire-auth-callbacks")).toMatchObject({ - status: "review", + const plan = buildIOSSetupPlan(await inspectIOSProject(root)); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "satisfied", automatable: false, }); - expect( - offRootPlan.steps.find((step) => step.id === "wire-auth-callbacks")?.description, - ).toContain("not proven on the shipping WindowGroup root"); - }); - - test("omits callback setup for AuthView and non-magic custom authentication", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-non-magic-")); - temporaryDirectories.push(root); - await createIOSFixture(root, { complete: false, includeKey: false }); - const appPath = join(root, "MyApp", "MyAppApp.swift"); - await Bun.write( - appPath, - `import ClerkKit - import ClerkKitUI - import SwiftUI - @main struct MyApp: App { - var body: some Scene { WindowGroup { AuthView().environment(Clerk.shared) } } - } - func otherFlows() async throws { - _ = try await Clerk.shared.auth.signInWithPassword(identifier: "a", password: "b") - _ = try await Clerk.shared.auth.signInWithEmailCode(emailAddress: "a") - _ = try await Clerk.shared.auth.signInWithOAuth(provider: .google) - _ = try await Clerk.shared.auth.signInWithApple() - _ = try await Clerk.shared.auth.startHostedAuth() - }`, - ); - - const plan = buildIOSSetupPlan(await inspectIOSProject(root)); - expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toBeUndefined(); }); test("blocks a selected AuthView scaffold when the SDK compatibility proof fails", async () => { @@ -401,7 +344,6 @@ struct MyApp: App { status: "blocked", automatable: false, }); - expect(plan.steps.find((step) => step.id === "wire-auth-callbacks")).toBeUndefined(); }); test("blocks an explicitly requested scaffold over a partial existing auth flow", async () => { diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index a98592c05..ef00cf76c 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -315,37 +315,6 @@ export function buildIOSSetupPlan( ), ); - if (target.swift.magicLinkAuthReferences.length > 0) { - const rootHandlesMagicLinks = - provenAppRoot && - target.swift.rootOpenURLHandlers.some( - (evidence) => evidence.path === target.swift.appRootEvidence[0]?.path, - ); - const hasUnprovenHandler = target.swift.openURLHandlers.length > 0 && !rootHandlesMagicLinks; - steps.push( - step( - "wire-auth-callbacks", - "Wire custom email-link callbacks", - "review", - rootHandlesMagicLinks - ? "The proven shipping WindowGroup root contains the documented Clerk callback shape. Confirm that custom email-link callbacks reach Clerk at runtime." - : hasUnprovenHandler - ? "A Clerk onOpenURL handler exists in target source, but it is not proven on the shipping WindowGroup root. Confirm that custom email-link callbacks reach Clerk." - : provenAppRoot - ? "A custom email-link flow is referenced, but the proven shipping WindowGroup root does not forward incoming URLs to Clerk. Review the flow's callback wiring." - : "A custom email-link flow is referenced, but the shipping root and its callback wiring could not be proven structurally. Review the flow manually.", - [ - ...target.swift.magicLinkAuthReferences, - ...(rootHandlesMagicLinks - ? target.swift.rootOpenURLHandlers - : target.swift.openURLHandlers), - ], - undefined, - false, - ), - ); - } - const bundleIdentifiers = distinctResolved( target, (configuration) => configuration.bundleIdentifier, 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 906de42a4..ef9aa0186 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -26,9 +26,7 @@ function target(): IOSAppTarget { rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], - magicLinkAuthReferences: [], openURLHandlers: [], - rootOpenURLHandlers: [], status: "absent", }, }; diff --git a/packages/cli-core/src/commands/init/ios/swift-app-root.ts b/packages/cli-core/src/commands/init/ios/swift-app-root.ts index f0db1f139..cfb192fa6 100644 --- a/packages/cli-core/src/commands/init/ios/swift-app-root.ts +++ b/packages/cli-core/src/commands/init/ios/swift-app-root.ts @@ -25,7 +25,6 @@ export interface SwiftUIAppRootStructure { body: SwiftUISceneBodyRange; root: SwiftUIRootExpression; clerkEnvironment: { found: boolean; conflicting: boolean }; - clerkOpenURLHandler: boolean; } export type SwiftUIAppRootInspection = @@ -362,57 +361,6 @@ function clerkEnvironment( return { found, conflicting }; } -function regexEscape(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function onOpenURLClosureBody(body: string): string | undefined { - const trimmed = body.trim(); - const wrapper = /^(?:perform\s*:\s*)?\{/.exec(trimmed); - if (!wrapper) return trimmed; - const openingBrace = trimmed.indexOf("{", wrapper.index); - const closingBrace = matchingBrace(trimmed, openingBrace); - if (closingBrace == null || trimmed.slice(closingBrace + 1).trim() !== "") return undefined; - return trimmed.slice(openingBrace + 1, closingBrace); -} - -function closureURLBinding(body: string): { parameter: string; bodyStart: number } | undefined { - const captureList = /^\s*\[[^\]]*\]\s*/.exec(body)?.[0] ?? ""; - const header = body.slice(captureList.length); - const parenthesized = /^\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^)]*)?\)\s+in\b/.exec(header); - if (parenthesized?.[1]) { - return { - parameter: parenthesized[1], - bodyStart: captureList.length + parenthesized[0].length, - }; - } - const named = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s+in\b/.exec(header); - return named?.[1] - ? { parameter: named[1], bodyStart: captureList.length + named[0].length } - : undefined; -} - -function isExactClerkOpenURLForwarder(body: string, parameter: string): boolean { - const escaped = regexEscape(parameter); - const forwarder = new RegExp( - `^\\s*Task\\s*\\{\\s*(?:_\\s*=\\s*)?try[!?]?\\s+await\\s+Clerk\\s*\\.\\s*shared\\s*\\.\\s*handle\\s*\\(\\s*${escaped}\\s*\\)\\s*;?\\s*\\}\\s*$`, - ); - return forwarder.test(body); -} - -function hasClerkOpenURLHandler(source: string, root: SwiftUIRootExpression): boolean { - return root.modifierStarts.some((modifierStart) => { - const modifier = modifierDetails(source, root, modifierStart); - if (modifier?.name !== "onOpenURL" || modifier.body == null) return false; - const closureBody = onOpenURLClosureBody(modifier.body); - if (!closureBody) return false; - const binding = closureURLBinding(closureBody); - if (!binding || binding.parameter === "_") return false; - const handlerBody = closureBody.slice(binding.bodyStart); - return isExactClerkOpenURLForwarder(handlerBody, binding.parameter); - }); -} - /** * Proves only the narrow shipping SwiftUI root that Clerk can reason about * deterministically: one unconditional top-level `@main` App, one @@ -433,7 +381,6 @@ export function inspectSwiftUIAppRootWithStatus(source: string): SwiftUIAppRootI body, root, clerkEnvironment: clerkEnvironment(source, root), - clerkOpenURLHandler: hasClerkOpenURLHandler(source, root), }, }; } 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 6dc192b69..6efd64cfe 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -791,11 +791,10 @@ Clerk.configure(publishableKey: key)`, expect(inspection.environmentInjections).toEqual([{ path: "UnusedHelper.swift" }]); expect(inspection.rootEnvironmentInjections).toEqual([]); expect(inspection.openURLHandlers).toEqual([{ path: "UnusedHelper.swift" }]); - expect(inspection.rootOpenURLHandlers).toEqual([]); expect(inspection.status).toBe("partial"); }); - test("proves Clerk modifiers only when attached to the unique WindowGroup root", async () => { + test("proves Clerk environment injection only on the unique WindowGroup root", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-")); temporaryDirectories.push(root); const path = join(root, "App.swift"); @@ -824,8 +823,8 @@ Clerk.configure(publishableKey: key)`, expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); expect(inspection.rootEnvironmentInjections).toEqual([{ path: "App.swift" }]); - expect(inspection.magicLinkAuthReferences).toEqual([{ path: "App.swift" }]); - expect(inspection.rootOpenURLHandlers).toEqual([{ path: "App.swift" }]); + expect(inspection.authFlowReferences).toEqual([{ path: "App.swift" }]); + expect(inspection.openURLHandlers).toEqual([{ path: "App.swift" }]); expect(inspection.status).toBe("complete"); }); @@ -856,75 +855,9 @@ Clerk.configure(publishableKey: key)`, expect(inspection.evidenceComplete).toBe(false); expect(inspection.appRootEvidence).toEqual([]); expect(inspection.rootEnvironmentInjections).toEqual([]); - expect(inspection.rootOpenURLHandlers).toEqual([]); - }); - - test("proves only an incoming URL forwarded directly to Clerk.shared", async () => { - const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-callback-")); - temporaryDirectories.push(root); - const path = join(root, "App.swift"); - const inspect = async (handler: string) => { - await Bun.write( - path, - `import ClerkKit - import SwiftUI - @main struct AppMain: App { - var body: some Scene { - WindowGroup { ContentView().onOpenURL { ${handler} } } - } - }`, - ); - return inspectSwiftSources([{ absolutePath: path, relativePath: "App.swift" }]); - }; - - expect( - (await inspect("url in Task { try await Clerk.shared.handle(url) }")).rootOpenURLHandlers, - ).toEqual([{ path: "App.swift" }]); - expect( - (await inspect("_ in Task { try await Clerk.shared.handle(fallbackURL) }")) - .rootOpenURLHandlers, - ).toEqual([]); - expect( - (await inspect("url in Task { try await clerk.handle(url) }")).rootOpenURLHandlers, - ).toEqual([]); - expect( - ( - await inspect( - "url in do { let url = fallbackURL; Task { try await Clerk.shared.handle(url) } }", - ) - ).rootOpenURLHandlers, - ).toEqual([]); - expect( - ( - await inspect( - "url in values.forEach { url in Task { try await Clerk.shared.handle(url) } }", - ) - ).rootOpenURLHandlers, - ).toEqual([]); - expect( - ( - await inspect( - "url in if case let .some(url) = fallbackURL { Task { try await Clerk.shared.handle(url) } }", - ) - ).rootOpenURLHandlers, - ).toEqual([]); - expect( - ( - await inspect( - "url in do { let ((first, second), url) = fallback; Task { try await Clerk.shared.handle(url) } }", - ) - ).rootOpenURLHandlers, - ).toEqual([]); - expect( - ( - await inspect( - "url in struct Local { init(url: URL) { Task { try await Clerk.shared.handle(url) } } }; _ = Local.self", - ) - ).rootOpenURLHandlers, - ).toEqual([]); }); - test("recognizes the Auth email-link convenience API as a custom magic-link flow", async () => { + test("recognizes the Auth email-link convenience API as an authentication flow", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-magic-link-")); temporaryDirectories.push(root); const path = join(root, "MagicLink.swift"); @@ -940,7 +873,7 @@ Clerk.configure(publishableKey: key)`, { absolutePath: path, relativePath: "MagicLink.swift" }, ]); - expect(inspection.magicLinkAuthReferences).toEqual([{ path: "MagicLink.swift" }]); + expect(inspection.authFlowReferences).toEqual([{ path: "MagicLink.swift" }]); }); test("does not prove an ambiguous, unsupported, or sanitized-decoy app root", async () => { @@ -974,7 +907,6 @@ Clerk.configure(publishableKey: key)`, expect(ambiguous.status).toBe("ambiguous"); expect(ambiguous.appRootEvidence).toEqual([]); expect(ambiguous.rootEnvironmentInjections).toEqual([]); - expect(ambiguous.magicLinkAuthReferences).toEqual([]); await Bun.write( secondPath, @@ -989,7 +921,6 @@ Clerk.configure(publishableKey: key)`, ]); expect(unsupported.appRootEvidence).toEqual([]); expect(unsupported.rootEnvironmentInjections).toEqual([]); - expect(unsupported.rootOpenURLHandlers).toEqual([]); }); test("recognizes native Clerk auth calls without matching unrelated sign-in APIs", async () => { @@ -1066,7 +997,6 @@ Clerk.configure(publishableKey: key)`, { path: "Password.swift" }, { path: "SignUp.swift" }, ]); - expect(inspection.magicLinkAuthReferences).toEqual([]); }); test("marks multiple entry points as ambiguous", async () => { diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 8ada268c8..06c8c5c7d 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -15,7 +15,7 @@ const CLERK_CONFIGURE_CALL = /\bClerk\s*\.\s*configure\s*\(/; const CLERK_URL_HANDLER = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*handle\s*\(/; const CLERK_NATIVE_AUTH_FLOW = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; -const CLERK_MAGIC_LINK_AUTH_FLOW = +const CLERK_EMAIL_LINK_AUTH_FLOW = /(?:\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*signInWithEmailLink|\.\s*sendEmailLink)\s*\(/; const CLERK_ENVIRONMENT_INJECTION = /\.\s*environment\s*\(\s*Clerk\s*\.\s*shared\s*\)/; @@ -99,7 +99,7 @@ const CLERK_EVIDENCE_PATTERNS = [ CLERK_CONFIGURE_CALL, CLERK_URL_HANDLER, CLERK_NATIVE_AUTH_FLOW, - CLERK_MAGIC_LINK_AUTH_FLOW, + CLERK_EMAIL_LINK_AUTH_FLOW, CLERK_ENVIRONMENT_INJECTION, CLERK_ENVIRONMENT_CONSUMER, CLERK_AUTH_VIEW, @@ -768,9 +768,7 @@ export async function inspectSwiftSources( const rootEnvironmentInjections: IOSSourceEvidence[] = []; const environmentConsumers: IOSSourceEvidence[] = []; const authFlowReferences: IOSSourceEvidence[] = []; - const magicLinkAuthReferences: IOSSourceEvidence[] = []; const openURLHandlers: IOSSourceEvidence[] = []; - const rootOpenURLHandlers: IOSSourceEvidence[] = []; let sourceFilesScanned = 0; let evidenceComplete = options.membershipComplete ?? true; @@ -831,19 +829,14 @@ export async function inspectSwiftSources( } if ( (importsUI && has(sanitized, CLERK_AUTH_VIEW)) || - (importsClerkModule && has(sanitized, CLERK_NATIVE_AUTH_FLOW)) + (importsClerkModule && + (has(sanitized, CLERK_NATIVE_AUTH_FLOW) || has(sanitized, CLERK_EMAIL_LINK_AUTH_FLOW))) ) { authFlowReferences.push(evidence); } - if (importsClerkModule && has(sanitized, CLERK_MAGIC_LINK_AUTH_FLOW)) { - magicLinkAuthReferences.push(evidence); - } if (importsClerkModule && hasClerkOpenURLHandler(sanitized)) { openURLHandlers.push(evidence); } - if (importsClerkModule && appRoot?.clerkOpenURLHandler) { - rootOpenURLHandlers.push(evidence); - } } const hasUniqueProvenAppRoot = @@ -853,7 +846,6 @@ export async function inspectSwiftSources( appRootEvidence[0]?.path === entryPoints[0]?.path; const provenAppRootEvidence = hasUniqueProvenAppRoot ? appRootEvidence : []; const provenRootEnvironmentInjections = hasUniqueProvenAppRoot ? rootEnvironmentInjections : []; - const provenRootOpenURLHandlers = hasUniqueProvenAppRoot ? rootOpenURLHandlers : []; const anyClerkEvidence = importsClerkKit.length + @@ -861,8 +853,7 @@ export async function inspectSwiftSources( configureCalls.length + environmentInjections.length + environmentConsumers.length + - authFlowReferences.length + - magicLinkAuthReferences.length > + authFlowReferences.length > 0; const status = entryPoints.length > 1 @@ -885,9 +876,7 @@ export async function inspectSwiftSources( rootEnvironmentInjections: provenRootEnvironmentInjections, environmentConsumers, authFlowReferences, - magicLinkAuthReferences, openURLHandlers, - rootOpenURLHandlers: provenRootOpenURLHandlers, status, }; } diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index d4db744c7..d86b21071 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -128,12 +128,8 @@ export interface IOSSwiftInspection { rootEnvironmentInjections: IOSSourceEvidence[]; environmentConsumers: IOSSourceEvidence[]; authFlowReferences: IOSSourceEvidence[]; - /** Lexical selected-target evidence of a custom native email-link flow. */ - magicLinkAuthReferences: IOSSourceEvidence[]; /** Broad lexical evidence retained for diagnostics and conflict detection only. */ openURLHandlers: IOSSourceEvidence[]; - /** Documented-shape Clerk URL handler candidate on the proven shipping WindowGroup root. */ - rootOpenURLHandlers: IOSSourceEvidence[]; status: "complete" | "partial" | "absent" | "ambiguous"; } @@ -200,7 +196,6 @@ export type IOSSetupStepId = | "install-clerk-sdk" | "configure-publishable-key" | "inject-clerk-environment" - | "wire-auth-callbacks" | "register-native-application" | "enable-native-apple" | "add-associated-domain" From d439a5f28aa84ae915e588021a2c32c1bc80cb6b Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 11:21:15 -0400 Subject: [PATCH 44/55] refactor(ios): report explicit key state --- .../src/commands/init/ios/build-settings.test.ts | 8 +------- .../src/commands/init/ios/native-readiness.ts | 5 ++++- .../cli-core/src/commands/init/ios/output.ts | 16 +++++++++------- .../cli-core/src/commands/init/ios/plan.test.ts | 13 ++++++++----- packages/cli-core/src/commands/init/ios/plan.ts | 12 ++++++------ 5 files changed, 28 insertions(+), 26 deletions(-) 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 3d94f97c8..0de416451 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 @@ -749,13 +749,7 @@ describe("inspectTargetBuildConfigurations", () => { targetName: "Example", projectPath: "Example.xcodeproj", }, - localPublishableKey: { - evidenceComplete: true, - found: false, - conflict: false, - candidateSources: [], - invalidSources: [], - }, + localPublishableKey: { state: "missing" }, generatedProject: null, diagnostics, }; diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.ts b/packages/cli-core/src/commands/init/ios/native-readiness.ts index b241a6db4..8ddd011f7 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -226,7 +226,10 @@ function associatedDomainReadiness( ): IOSAssociatedDomainReadiness { const plan = buildIOSSetupPlan(inspection, { associatedDomainPlan }); const planStep = plan.steps.find((step) => step.id === "add-associated-domain"); - const host = inspection.localPublishableKey.frontendApiHost; + const host = + inspection.localPublishableKey.state === "valid" + ? inspection.localPublishableKey.frontendApiHost + : undefined; const expectedDomain = host ? `webcredentials:${host}` : undefined; const files = associatedDomainPlan?.files.map((file) => file.path) ?? diff --git a/packages/cli-core/src/commands/init/ios/output.ts b/packages/cli-core/src/commands/init/ios/output.ts index a7a9359fd..08b5615b5 100644 --- a/packages/cli-core/src/commands/init/ios/output.ts +++ b/packages/cli-core/src/commands/init/ios/output.ts @@ -81,18 +81,20 @@ export function formatIOSSetupPlan( ` ClerkKit: ${selected.packages.clerkKit}; ClerkKitUI: ${selected.packages.clerkKitUI}`, ); } - if (inspection.localPublishableKey.frontendApiHost) { + const localPublishableKey = inspection.localPublishableKey; + if (localPublishableKey.state === "valid") { lines.push( - ` Publishable key: found (${inspection.localPublishableKey.instanceType}; ${inspection.localPublishableKey.frontendApiHost})`, + ` Publishable key: found (${localPublishableKey.instanceType}; ${localPublishableKey.frontendApiHost})`, ); } else if (selected && hasSupportedIOSCustomConfigure(selected)) { lines.push(" Publishable key: custom source (value not inspected)"); } else { - const keyStatus = inspection.localPublishableKey.conflict - ? "conflicting local sources" - : inspection.localPublishableKey.candidateSources.length > 0 - ? "found but invalid" - : "not found"; + const keyStatus = + localPublishableKey.state === "invalid" + ? "invalid inline key" + : localPublishableKey.state === "unproven" + ? "configuration needs review (value not inspected)" + : "not found"; lines.push(` Publishable key: ${keyStatus}`); } diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 686f38f70..362707bc5 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -71,7 +71,7 @@ describe("buildIOSSetupPlan", () => { startupBinding: "app-init", }, ]); - expect(inspection.localPublishableKey.found).toBe(false); + expect(inspection.localPublishableKey.state).toBe("unproven"); expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( "satisfied", ); @@ -88,12 +88,15 @@ describe("buildIOSSetupPlan", () => { startupBinding: "unproven", }); - const configureStep = buildIOSSetupPlan(inspection).steps.find( - (step) => step.id === "configure-publishable-key", - ); + const plan = buildIOSSetupPlan(inspection); + const configureStep = plan.steps.find((step) => step.id === "configure-publishable-key"); expect(configureStep).toMatchObject({ status: "review", automatable: false }); expect(configureStep?.description).toContain("More than one Clerk.configure"); + const output = formatIOSSetupPlan(inspection, plan); + expect(output).toContain("Publishable key: configuration needs review (value not inspected)"); + expect(output).not.toContain("found but invalid"); + expect(output).not.toContain("Publishable key: not found"); }); test("satisfies configuration and derives the domain from a redacted inline literal", async () => { @@ -534,7 +537,7 @@ struct MyApp: App { expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( "satisfied", ); - expect(inspection.localPublishableKey.found).toBe(false); + expect(inspection.localPublishableKey.state).toBe("unproven"); }); test("preserves an arbitrary named key loader without interpreting it", async () => { diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index ef00cf76c..6d0c3c6f5 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -207,6 +207,7 @@ export function buildIOSSetupPlan( const customConfigureReady = oneStartupConfigure && configureCall?.publishableKeyWiring === "custom"; const publishableKeyBlocked = + oneStartupConfigure && configureCall?.publishableKeyWiring === "inline-literal" && configureCall.inlinePublishableKey?.state === "invalid"; const directConfigPlanApplies = options.directConfigPlan != null; @@ -373,9 +374,10 @@ export function buildIOSSetupPlan( ); } - const expectedDomain = inspection.localPublishableKey.frontendApiHost - ? `webcredentials:${inspection.localPublishableKey.frontendApiHost}` - : undefined; + const expectedDomain = + inspection.localPublishableKey.state === "valid" + ? `webcredentials:${inspection.localPublishableKey.frontendApiHost}` + : undefined; const expectedDomainIsSelectedTargetRuntime = inlineConfigureValid; const entitlements = target.configurations .map((configuration) => configuration.entitlements) @@ -429,9 +431,7 @@ export function buildIOSSetupPlan( ? allEntitlementsPresent && hasUnresolvedAssociatedDomains ? `Some associated-domain values use unresolved build settings. Confirm they expand to ${expectedDomain} in every selected-target configuration.` : `Enable Associated Domains for ${target.name} and add ${expectedDomain} to every selected-target entitlements configuration.` - : inspection.localPublishableKey.conflict - ? "Local publishable-key sources point at different Clerk instances, so the associated domain cannot be chosen safely. Resolve the key conflict and rerun this plan." - : "A valid local publishable key is needed to derive the exact `webcredentials:` Frontend API host. Add the key, then rerun this plan."; + : "A valid local publishable key is needed to derive the exact `webcredentials:` Frontend API host. Add the key, then rerun this plan."; steps.push( step( "add-associated-domain", From d0a33333717ea8b0af820cc3173e807d70cdc1a8 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 12:02:28 -0400 Subject: [PATCH 45/55] fix(plapi): validate application responses --- .../cli-core/src/commands/api/index.test.ts | 2 + .../cli-core/src/commands/config/pull.test.ts | 8 ++- .../cli-core/src/commands/config/push.test.ts | 8 ++- .../src/commands/config/schema.test.ts | 8 ++- .../commands/deploy/status-command.test.ts | 16 +++++- packages/cli-core/src/lib/plapi.test.ts | 41 ++++++++++++++- packages/cli-core/src/lib/plapi.ts | 50 ++++++++++++++++++- 7 files changed, 126 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index 151e765ec..25c3ed87b 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -400,6 +400,7 @@ describe("api command", () => { { instance_id: "ins_dev", environment_type: "development", + publishable_key: "pk_test_fixture", secret_key: "sk_test_derived", }, ], @@ -433,6 +434,7 @@ describe("api command", () => { { instance_id: "ins_dev", environment_type: "development", + publishable_key: "pk_test_fixture", secret_key: "sk_test_oauth", }, ], diff --git a/packages/cli-core/src/commands/config/pull.test.ts b/packages/cli-core/src/commands/config/pull.test.ts index fed5d17d7..6eceb120c 100644 --- a/packages/cli-core/src/commands/config/pull.test.ts +++ b/packages/cli-core/src/commands/config/pull.test.ts @@ -98,7 +98,13 @@ describe("config pull", () => { test("supports --app without a linked profile", async () => { const mockApp = { application_id: "app_1", - instances: [{ instance_id: "ins_dev", environment_type: "development" }], + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_fixture", + }, + ], }; stubFetch(async (input) => { diff --git a/packages/cli-core/src/commands/config/push.test.ts b/packages/cli-core/src/commands/config/push.test.ts index 780edddee..0c61f5701 100644 --- a/packages/cli-core/src/commands/config/push.test.ts +++ b/packages/cli-core/src/commands/config/push.test.ts @@ -205,7 +205,13 @@ describe("config push", () => { return new Response( JSON.stringify({ application_id: "app_1", - instances: [{ instance_id: "ins_dev", environment_type: "development" }], + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_fixture", + }, + ], }), { status: 200 }, ); diff --git a/packages/cli-core/src/commands/config/schema.test.ts b/packages/cli-core/src/commands/config/schema.test.ts index e56bef577..ff97f7bf4 100644 --- a/packages/cli-core/src/commands/config/schema.test.ts +++ b/packages/cli-core/src/commands/config/schema.test.ts @@ -87,7 +87,13 @@ describe("config schema", () => { test("supports --app without a linked profile", async () => { const mockApp = { application_id: "app_1", - instances: [{ instance_id: "ins_dev", environment_type: "development" }], + instances: [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_fixture", + }, + ], }; stubFetch(async (input) => { diff --git a/packages/cli-core/src/commands/deploy/status-command.test.ts b/packages/cli-core/src/commands/deploy/status-command.test.ts index f9f263989..859ed5938 100644 --- a/packages/cli-core/src/commands/deploy/status-command.test.ts +++ b/packages/cli-core/src/commands/deploy/status-command.test.ts @@ -35,8 +35,20 @@ function stripAnsi(value: string): string { } function appWith(production: boolean) { - const instances = [{ instance_id: "ins_dev", environment_type: "development" }]; - if (production) instances.push({ instance_id: "ins_prod", environment_type: "production" }); + const instances = [ + { + instance_id: "ins_dev", + environment_type: "development", + publishable_key: "pk_test_fixture", + }, + ]; + if (production) { + instances.push({ + instance_id: "ins_prod", + environment_type: "production", + publishable_key: "pk_live_fixture", + }); + } return { application_id: "app_1", name: "app", instances }; } diff --git a/packages/cli-core/src/lib/plapi.test.ts b/packages/cli-core/src/lib/plapi.test.ts index c7a511546..2af068f8a 100644 --- a/packages/cli-core/src/lib/plapi.test.ts +++ b/packages/cli-core/src/lib/plapi.test.ts @@ -20,7 +20,7 @@ const { triggerApplicationDomainDNSCheck, listApplicationDomains, } = await import("./plapi.ts"); -const { AuthError, PlapiError } = await import("./errors.ts"); +const { AuthError, ERROR_CODE, PlapiError } = await import("./errors.ts"); describe("plapi", () => { const originalEnv = { ...process.env }; @@ -342,6 +342,45 @@ describe("plapi", () => { expect(result).toEqual(mockApp); }); + test("rejects malformed application JSON", async () => { + stubFetch(async () => new Response("{", { status: 200 })); + + await expect(fetchApplication("app_abc")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + message: "Clerk returned an invalid application response.", + }); + }); + + test.each([ + { name: "missing instances", body: { application_id: "app_abc" } }, + { + name: "non-array instances", + body: { application_id: "app_abc", instances: {} }, + }, + { + name: "a malformed instance", + body: { + application_id: "app_abc", + instances: [ + { + instance_id: "ins_1", + environment_type: "development", + publishable_key: 123, + }, + ], + }, + }, + ])("rejects $name in an application response", async ({ body }) => { + stubFetch(async () => Response.json(body)); + + await expect(fetchApplication("app_abc")).rejects.toMatchObject({ + name: "CliError", + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + message: "Clerk returned an invalid application response.", + }); + }); + test("throws PlapiError on non-2xx response", async () => { stubFetch(async () => new Response("Not Found", { status: 404 })); diff --git a/packages/cli-core/src/lib/plapi.ts b/packages/cli-core/src/lib/plapi.ts index e390127f1..5d2404dbc 100644 --- a/packages/cli-core/src/lib/plapi.ts +++ b/packages/cli-core/src/lib/plapi.ts @@ -170,6 +170,54 @@ export interface Application { instances: ApplicationInstance[]; } +function unexpectedApplicationResponse(): CliError { + return new CliError("Clerk returned an invalid application response.", { + code: ERROR_CODE.PLAPI_UNEXPECTED_RESPONSE, + }); +} + +function validateApplication(value: unknown): Application { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw unexpectedApplicationResponse(); + } + + const application = value as Record; + if ( + typeof application.application_id !== "string" || + (application.name !== undefined && typeof application.name !== "string") || + !Array.isArray(application.instances) + ) { + throw unexpectedApplicationResponse(); + } + + for (const value of application.instances) { + if (value == null || typeof value !== "object" || Array.isArray(value)) { + throw unexpectedApplicationResponse(); + } + const instance = value as Record; + if ( + typeof instance.instance_id !== "string" || + typeof instance.environment_type !== "string" || + typeof instance.publishable_key !== "string" || + (instance.secret_key !== undefined && typeof instance.secret_key !== "string") + ) { + throw unexpectedApplicationResponse(); + } + } + + return value as Application; +} + +async function readApplicationResponse(response: Response): Promise { + let value: unknown; + try { + value = await response.json(); + } catch { + throw unexpectedApplicationResponse(); + } + return validateApplication(value); +} + export type DomainSummary = { id: string; name: string; @@ -413,7 +461,7 @@ export async function fetchApplication( url.searchParams.set("include_secret_keys", "true"); } const response = await plapiFetch("GET", url); - return response.json() as Promise; + return readApplicationResponse(response); } export async function listApplicationDomains( From f223967497db462d5ded05e6c3f087eb008db117 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 17:20:16 -0400 Subject: [PATCH 46/55] style(init): format Swift inspection --- packages/cli-core/src/commands/init/ios/swift.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 06c8c5c7d..9e1e88c58 100644 --- a/packages/cli-core/src/commands/init/ios/swift.ts +++ b/packages/cli-core/src/commands/init/ios/swift.ts @@ -17,8 +17,7 @@ const CLERK_NATIVE_AUTH_FLOW = /\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*(?:signIn(?:With(?:Password|EmailCode|EmailLink|PhoneCode|OAuth|IdToken|Apple|Passkey|EnterpriseSSO|Ticket))?|signUp(?:With(?:OAuth|Apple|IdToken|EnterpriseSSO|Ticket))?|startHostedAuth)\s*\(/; const CLERK_EMAIL_LINK_AUTH_FLOW = /(?:\b(?:Clerk\s*\.\s*shared|clerk)\s*\.\s*auth\s*\.\s*signInWithEmailLink|\.\s*sendEmailLink)\s*\(/; -const CLERK_ENVIRONMENT_INJECTION = - /\.\s*environment\s*\(\s*Clerk\s*\.\s*shared\s*\)/; +const CLERK_ENVIRONMENT_INJECTION = /\.\s*environment\s*\(\s*Clerk\s*\.\s*shared\s*\)/; const CLERK_ENVIRONMENT_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; const CLERK_AUTH_VIEW = /\bAuthView\s*\(/; const CLERK_KIT_IMPORT = From 0131daa2bb2ba6df54dfef3eb3797853cc5562e7 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sat, 29 Aug 2026 23:27:40 -0400 Subject: [PATCH 47/55] fix(init): align dry-run target discovery --- packages/cli-core/src/commands/init/index.ts | 6 +++- .../src/commands/init/ios/dry-run.test.ts | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 133fd6ddd..1a6b245aa 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -230,7 +230,11 @@ export async function init(options: InitOptions = {}) { ); } setTelemetryStage("ios_inspect"); - const inspect = async () => inspectIOSProject(ctx.cwd, { target: options.target }); + const inspect = async () => + inspectIOSProject(ctx.cwd, { + target: options.target, + exhaustiveContainerDiscovery: true, + }); const inspection = machineOutput ? await inspect() : await withSpinner("Inspecting Xcode project...", inspect); diff --git a/packages/cli-core/src/commands/init/ios/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts index d05a87dd3..c0c9fdac6 100644 --- a/packages/cli-core/src/commands/init/ios/dry-run.test.ts +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -155,6 +155,34 @@ describe("clerk init --dry-run", () => { expect(await treeDigest(configDir)).toEqual(configBefore); }); + test("uses exhaustive target discovery before selecting an implicit target", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-exhaustive-dry-run-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + await createIOSFixture(join(root, "packages", "native", "nested", "DeepApp"), { + complete: true, + }); + const configDir = await createIsolatedCLIState(); + const projectBefore = await treeDigest(root); + const configBefore = await treeDigest(configDir); + + const result = await runCLI( + root, + ["--mode", "human", "init", "--dry-run", "--json"], + isolatedCLIEnvironment(configDir), + ); + + expect(result.exitCode).toBe(0); + const output = JSON.parse(result.stdout); + expect(output.inspection.selection.state).toBe("ambiguous"); + expect(output.inspection.projects.map((project: { path: string }) => project.path)).toEqual([ + "MyApp.xcodeproj", + "packages/native/nested/DeepApp/MyApp.xcodeproj", + ]); + expect(await treeDigest(root)).toEqual(projectBefore); + expect(await treeDigest(configDir)).toEqual(configBefore); + }); + test("fresh SwiftUI output advertises direct configuration and environment automation", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-cli-direct-")); temporaryDirectories.push(root); From 2256581ebbfbbfb8a83114b159fb6ac9e7580fdf Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 00:37:35 -0400 Subject: [PATCH 48/55] fix(init): support SwiftUI scene modifiers --- .../commands/init/ios/direct-config.test.ts | 32 +++++++++++++++++ .../src/commands/init/ios/swift-app-root.ts | 14 +++++++- .../src/commands/init/ios/swift.test.ts | 34 ++++++++++++++++++- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 9da7a32c1..654d56f56 100644 --- a/packages/cli-core/src/commands/init/ios/direct-config.test.ts +++ b/packages/cli-core/src/commands/init/ios/direct-config.test.ts @@ -273,6 +273,38 @@ struct MyApp: App { expect(await readFile(appSourcePath(root))).toEqual(beforeSecondApply); }); + test("configures a SwiftData app with a WindowGroup scene modifier", async () => { + const root = await fixture(); + await replaceSource( + root, + `import SwiftData +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: Item.self) + } +} +`, + ); + + expect(hasExactIOSSwiftUIAppContentRoot(await source(root))).toBe(true); + const plan = await planIOSDirectConfig(planOptions(root)); + + expect(plan.status).toBe("ready"); + expect((await applyIOSDirectConfig(plan, DEVELOPMENT_KEY)).status).toBe("applied"); + const configured = await source(root); + expect(configured).toContain(".environment(Clerk.shared)"); + expect(configured).toContain(".modelContainer(for: Item.self)"); + expect(configured.indexOf(".environment(Clerk.shared)")).toBeLessThan( + configured.indexOf(".modelContainer(for: Item.self)"), + ); + }); + test("inserts configuration first in one existing initializer", async () => { const root = await fixture(); await replaceSource( diff --git a/packages/cli-core/src/commands/init/ios/swift-app-root.ts b/packages/cli-core/src/commands/init/ios/swift-app-root.ts index cfb192fa6..e991f203b 100644 --- a/packages/cli-core/src/commands/init/ios/swift-app-root.ts +++ b/packages/cli-core/src/commands/init/ios/swift-app-root.ts @@ -289,7 +289,19 @@ function windowGroupRoot( if (source[cursor] !== "{") return undefined; const groupClosingBrace = matchingBrace(source, cursor); if (groupClosingBrace == null || groupClosingBrace >= body.closingBrace) return undefined; - if (skipWhitespace(source, groupClosingBrace + 1, body.closingBrace) !== body.closingBrace) { + let sceneCursor = groupClosingBrace + 1; + while (true) { + sceneCursor = skipWhitespace(source, sceneCursor, body.closingBrace); + if (source[sceneCursor] !== ".") break; + const nameStart = skipWhitespace(source, sceneCursor + 1, body.closingBrace); + const nameEnd = identifierEnd(source, nameStart); + if (nameEnd == null) return undefined; + sceneCursor = skipWhitespace(source, nameEnd, body.closingBrace); + const suffixEnd = consumeBalancedSuffix(source, sceneCursor, body.closingBrace); + if (suffixEnd == null) return undefined; + sceneCursor = suffixEnd; + } + if (skipWhitespace(source, sceneCursor, body.closingBrace) !== body.closingBrace) { return undefined; } const expressionStart = skipWhitespace(source, cursor + 1, groupClosingBrace); 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 6efd64cfe..2deb14d49 100644 --- a/packages/cli-core/src/commands/init/ios/swift.test.ts +++ b/packages/cli-core/src/commands/init/ios/swift.test.ts @@ -828,6 +828,34 @@ Clerk.configure(publishableKey: key)`, expect(inspection.status).toBe("complete"); }); + test("proves the WindowGroup root through a macOS scene modifier", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-macos-swift-root-")); + temporaryDirectories.push(root); + const path = join(root, "App.swift"); + await Bun.write( + path, + `import ClerkKit + import SwiftUI + @main struct AppMain: App { + var body: some Scene { + WindowGroup { + ContentView() + .environment(Clerk.shared) + } + .defaultSize(width: 1100, height: 800) + .windowResizability(.contentSize) + } + }`, + ); + + const inspection = await inspectSwiftSources([ + { absolutePath: path, relativePath: "App.swift" }, + ]); + + expect(inspection.appRootEvidence).toEqual([{ path: "App.swift" }]); + expect(inspection.rootEnvironmentInjections).toEqual([{ path: "App.swift" }]); + }); + test("does not prove a root when selected-target source evidence is incomplete", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-swift-root-incomplete-")); temporaryDirectories.push(root); @@ -913,7 +941,11 @@ Clerk.configure(publishableKey: key)`, `import ClerkKit import SwiftUI @main struct Unsupported: App { - var body: some Scene { WindowGroup { ContentView() }; Settings { Text("Settings") } } + var body: some Scene { + WindowGroup { ContentView() } + .defaultSize(width: 1100, height: 800) + Settings { Text("Settings") } + } }`, ); const unsupported = await inspectSwiftSources([ From e4203e2fadcd9f772b6299a55a40f8fa4899cdf1 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 11:00:50 -0400 Subject: [PATCH 49/55] fix(init): match Apple bundle IDs case-insensitively --- .../src/commands/deploy/index.test.ts | 47 +++++++++++++++++++ .../cli-core/src/commands/deploy/index.ts | 7 +++ .../src/commands/deploy/providers.test.ts | 9 ++++ .../cli-core/src/commands/deploy/providers.ts | 7 ++- .../src/commands/deploy/status.test.ts | 41 ++++++++++++++++ .../cli-core/src/commands/deploy/status.ts | 8 ++++ .../src/commands/init/index-ios.test.ts | 4 +- packages/cli-core/src/commands/init/index.ts | 11 ++++- .../commands/init/ios/native-apple.test.ts | 39 +++++++++++++++ .../src/commands/init/ios/native-apple.ts | 9 ++-- .../ios/native-registration-retry.test.ts | 12 +++++ .../init/ios/native-registration-retry.ts | 11 +++-- .../commands/init/ios/native-remote.test.ts | 46 ++++++++++++++++++ .../src/commands/init/ios/native-remote.ts | 25 ++++++---- 14 files changed, 257 insertions(+), 19 deletions(-) diff --git a/packages/cli-core/src/commands/deploy/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index b8d520fcd..90eabbb5f 100644 --- a/packages/cli-core/src/commands/deploy/index.test.ts +++ b/packages/cli-core/src/commands/deploy/index.test.ts @@ -1308,6 +1308,53 @@ describe("deploy", () => { expect(err).not.toContain("Configure Apple OAuth for production"); }); + test("refuses case-only Apple registration mismatches without suggesting another registration", async () => { + await linkedProject({ + instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, + }); + mockLiveProduction({ + instanceId: "ins_prod_native_apple", + developmentConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + productionConfig: { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + }, + }); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.Example.Native", + created_at: 1, + updated_at: 1, + }, + ]); + mockIsAgent.mockReturnValue(false); + + const thrown = await runDeploy({}).catch((error: unknown) => error); + + expect(thrown).toBeInstanceOf(CliError); + const message = (thrown as Error).message; + expect(message).toContain("letter casing does not exactly match"); + expect(message).toContain("registration's exact Bundle ID spelling"); + expect(message).toContain("Do not create another registration"); + expect(message).not.toContain("Register it at"); + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockInput).not.toHaveBeenCalled(); + expect(mockPassword).not.toHaveBeenCalled(); + expect(mockPatchInstanceConfig).not.toHaveBeenCalled(); + }); + test("refuses ambiguous App ID Prefix registrations for native-only Apple", async () => { await linkedProject({ instances: { development: "ins_dev_123", production: "ins_prod_native_apple" }, diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 512a1efb3..25f15100e 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -683,6 +683,13 @@ async function nativeAppleCredentialsAreAlreadyConfigured( ); } + if (nativeConfiguration.status === "registration-bundle-case-mismatch") { + throwUsageError( + `Native Sign in with Apple uses Bundle ID ${nativeConfiguration.bundleId}, but its letter casing does not exactly match the existing iOS Native Application registration. ` + + "Update the Apple connection to use the registration's exact Bundle ID spelling in the Clerk Dashboard, then rerun `clerk deploy`. Do not create another registration or add unrelated Apple web credentials.", + ); + } + throwUsageError( `Native Sign in with Apple is configured for ${preliminary.bundleId}, but the production instance does not have an exact iOS Native Application registration for that Bundle ID. ` + "Register it at https://dashboard.clerk.com/~/native-applications, then rerun `clerk deploy`. The CLI will not infer an App ID Prefix or request unrelated Apple web credentials.", diff --git a/packages/cli-core/src/commands/deploy/providers.test.ts b/packages/cli-core/src/commands/deploy/providers.test.ts index c48ddbd9e..cb3d0fb0c 100644 --- a/packages/cli-core/src/commands/deploy/providers.test.ts +++ b/packages/cli-core/src/commands/deploy/providers.test.ts @@ -226,6 +226,15 @@ describe("deploy OAuth provider descriptors", () => { api_enabled: true, }), ).toEqual({ status: "ready", bundleId: "com.example.app" }); + expect( + inspectNativeAppleConfiguration(config, apple, [iosApplication("COM.EXAMPLE.APP")], { + object: "native_settings", + api_enabled: true, + }), + ).toEqual({ + status: "registration-bundle-case-mismatch", + bundleId: "com.example.app", + }); expect( inspectNativeAppleConfiguration(config, apple, [iosApplication("com.example.other")], { object: "native_settings", diff --git a/packages/cli-core/src/commands/deploy/providers.ts b/packages/cli-core/src/commands/deploy/providers.ts index 45816332b..d118ea0c3 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -1,4 +1,5 @@ import { OAUTH_PROVIDERS } from "@clerk/shared/oauth"; +import { bundleIdentifiersEqual } from "../../lib/apple-native-identity.ts"; import { bold, cyan, dim, yellow } from "../../lib/color.ts"; import { clerkSubdomains } from "./copy.ts"; import { log } from "../../lib/log.ts"; @@ -74,6 +75,7 @@ export type NativeAppleConfiguration = | "ready" | "authentication-disabled" | "registration-missing" + | "registration-bundle-case-mismatch" | "registration-ambiguous" | "native-api-disabled" | "verification-unavailable"; @@ -262,7 +264,7 @@ export function inspectNativeAppleConfiguration( const registeredPrefixes = new Set( iosApplications - .filter((application) => application.bundle_id === bundleId) + .filter((application) => bundleIdentifiersEqual(application.bundle_id, bundleId)) .map((application) => application.app_id_prefix), ); if (registeredPrefixes.size === 0) { @@ -271,6 +273,9 @@ export function inspectNativeAppleConfiguration( if (registeredPrefixes.size > 1) { return { status: "registration-ambiguous", bundleId }; } + if (!iosApplications.some((application) => application.bundle_id === bundleId)) { + return { status: "registration-bundle-case-mismatch", bundleId }; + } return nativeSettings?.api_enabled === true ? { status: "ready", bundleId } : { status: "native-api-disabled", bundleId }; diff --git a/packages/cli-core/src/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 86f4c43fe..d79f4d15d 100644 --- a/packages/cli-core/src/commands/deploy/status.test.ts +++ b/packages/cli-core/src/commands/deploy/status.test.ts @@ -292,6 +292,47 @@ describe("resolveDeployState", () => { } }); + test("reports a case-only native Apple registration mismatch without suggesting a duplicate", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: "com.example.native", + }, + } + : { connection_oauth_apple: { enabled: true } }, + ); + mockListIOSApplications.mockResolvedValue([ + { + object: "ios_application", + id: "ios_native", + app_id_prefix: "ABCDE12345", + bundle_id: "com.Example.Native", + created_at: 1, + updated_at: 1, + }, + ]); + + const state = await resolveDeployState({ ...ctx, productionInstanceId: "ins_prod" }); + + expect(state.kind).toBe("active"); + if (state.kind === "active") { + expect(state.snapshot.nativeAppleReadinessIssue).toEqual({ + bundleId: "com.example.native", + reason: "registration-bundle-case-mismatch", + }); + const report = buildDeployStatusReport(state, null); + expect(report.complete).toBe(false); + expect(report.nextAction).toContain("differs only by letter casing"); + expect(report.nextAction).toContain("exact Bundle ID spelling"); + expect(report.nextAction).toContain("do not create another registration"); + expect(report.nextAction).not.toContain("Register that Bundle ID"); + } + }); + test("reports native Apple verification as unavailable when native endpoint reads fail", async () => { mockActiveProductionEnvironment(); mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => diff --git a/packages/cli-core/src/commands/deploy/status.ts b/packages/cli-core/src/commands/deploy/status.ts index fd56677e3..40b29d686 100644 --- a/packages/cli-core/src/commands/deploy/status.ts +++ b/packages/cli-core/src/commands/deploy/status.ts @@ -80,6 +80,7 @@ type NativeAppleReadinessIssue = { reason: | "authentication-disabled" | "registration-missing" + | "registration-bundle-case-mismatch" | "registration-ambiguous" | "native-api-disabled" | "verification-unavailable"; @@ -549,6 +550,7 @@ function isNativeAppleReadinessIssue( return ( status === "authentication-disabled" || status === "registration-missing" || + status === "registration-bundle-case-mismatch" || status === "registration-ambiguous" || status === "native-api-disabled" || status === "verification-unavailable" @@ -568,6 +570,12 @@ function nativeAppleReadinessNextAction(issue: NativeAppleReadinessIssue): strin "Review the existing registrations at https://dashboard.clerk.com/~/native-applications before continuing; do not create another registration." ); } + if (issue.reason === "registration-bundle-case-mismatch") { + return ( + `The Apple connection Bundle ID ${issue.bundleId} differs only by letter casing from its existing iOS Native Application registration. ` + + "Update the Apple connection to use the registration's exact Bundle ID spelling in the Clerk Dashboard; do not create another registration." + ); + } if (issue.reason === "authentication-disabled") { return ( `Apple is not explicitly enabled for authentication on the production instance for ${issue.bundleId}. ` + diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index 9afcc7bd3..a10c9a733 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -1037,7 +1037,7 @@ describe("init iOS", () => { } as never); const preflightLocal = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); const prepareNative = spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( - iosRemotePlan(), + iosRemotePlan({ bundleIdentifier: "com.Example.MyApp" }), ); const applePlan = iosNativeApplePlan(); const prepareApple = spyOn(nativeAppleMod, "prepareIOSNativeAppleConnection").mockResolvedValue( @@ -1063,7 +1063,7 @@ describe("init iOS", () => { expect(prepareApple).toHaveBeenCalledWith({ applicationId: "app_test", instanceId: "ins_test", - bundleIdentifier: "com.example.MyApp", + bundleIdentifier: "com.Example.MyApp", nativeApplicationReady: true, requested: true, agent: false, diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index 1a6b245aa..e38c00b74 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -576,11 +576,20 @@ export async function init(options: InitOptions = {}) { { code: ERROR_CODE.IOS_TARGET_UNRESOLVED }, ); } + if (!nativeRemotePlan.bundleIdentifier) { + throw new CliError( + "The selected iOS Bundle ID could not be matched to its Clerk Native Application registration. No local or Apple connection changes were written.", + { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, + ); + } setTelemetryStage("ios_apple_plan"); const preparedApple = await prepareIOSNativeAppleConnection({ applicationId: keys.applicationId, instanceId: keys.instanceId, - bundleIdentifier: target.bundleIdentifier.value, + // Use the existing registration's stored spelling when its Bundle ID + // differs from Xcode only by case. The backend's native Apple lookup + // currently uses that authoritative value. + bundleIdentifier: nativeRemotePlan.bundleIdentifier, nativeApplicationReady: nativeRemotePlan.status !== "blocked" && nativeRemotePlan.registration !== "blocked", requested: true, 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 98ef6c4b5..7e4fd7230 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 @@ -267,6 +267,45 @@ describe("native Sign in with Apple remote setup", () => { expect(captured.err).toContain("already enabled"); }); + test("normalizes a case-only Apple config difference to the registration's spelling", async () => { + const harness = statefulAPI({ + initial: connection(true, true, { bundle_id: "com.example.nativeapple" }), + }); + const plan = await prepareIOSNativeAppleConnection(baseOptions(), { + api: harness.api, + prompts: unexpectedPrompts(), + }); + + expect(plan).toMatchObject({ + status: "ready", + bundleIdentifier: BUNDLE_IDENTIFIER, + bundleIdentifierConfiguration: "required", + blockers: [], + }); + if (plan.status !== "ready") throw new Error("expected ready plan"); + await applyIOSNativeAppleConnection(plan, harness.api); + + expect(harness.patchCalls).toHaveLength(2); + expect(harness.patchCalls.map((call) => call.config)).toEqual([ + { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }, + { + connection_oauth_apple: { + enabled: true, + authenticatable: true, + bundle_id: BUNDLE_IDENTIFIER, + }, + }, + ]); + expect(harness.actualWrites()).toBe(1); + expect(harness.current().bundle_id).toBe(BUNDLE_IDENTIFIER); + }); + test("keeps a versionless already-satisfied connection read-only", async () => { const harness = statefulAPI({ initial: connection(true, true, { bundle_id: BUNDLE_IDENTIFIER }), 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 06d493576..0e374e0de 100644 --- a/packages/cli-core/src/commands/init/ios/native-apple.ts +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from "node:util"; +import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; import { dim, yellow } from "../../../lib/color.ts"; import { ApiError, @@ -318,7 +319,7 @@ export function buildIOSNativeApplePlan( parsed.status === "valid" && parsed.bundleIdentifier && bundleIdentifier && - parsed.bundleIdentifier !== bundleIdentifier + !bundleIdentifiersEqual(parsed.bundleIdentifier, bundleIdentifier) ) { blockers.push( blocker( @@ -506,7 +507,9 @@ function validatePatchProjection( ? "satisfied" : before.bundleIdentifier == null ? "required" - : "blocked"; + : bundleIdentifiersEqual(before.bundleIdentifier, bundleIdentifier) + ? "required" + : "blocked"; if ( before.status !== "valid" || after.status !== "valid" || @@ -627,7 +630,7 @@ function planIdentityMatches(approved: IOSNativeApplePlan, current: IOSNativeApp return ( current.applicationId === approved.applicationId && current.instanceId === approved.instanceId && - current.bundleIdentifier === approved.bundleIdentifier + bundleIdentifiersEqual(current.bundleIdentifier, approved.bundleIdentifier) ); } diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts index b5e190fb8..46911b105 100644 --- a/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts @@ -70,6 +70,18 @@ describe("iOS native registration retry state", () => { } }); + test("reuses retry state when only Bundle ID casing changes", async () => { + const stateDirectory = await temporaryStateDirectory(); + const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); + const first = await store.getOrCreate(identity()); + const caseOnlyRerun = identity({ bundleIdentifier: "COM.EXAMPLE.nativeapp" }); + + expect(await store.peek(caseOnlyRerun)).toBe(first); + expect(await store.getOrCreate(caseOnlyRerun)).toBe(first); + expect(await store.clear(caseOnlyRerun, first)).toBe(true); + expect(await store.peek(identity())).toBeUndefined(); + }); + test("clears a verified operation so a later registration receives a new key", async () => { const stateDirectory = await temporaryStateDirectory(); const store = createIOSNativeRegistrationRetryStore(() => stateDirectory); diff --git a/packages/cli-core/src/commands/init/ios/native-registration-retry.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts index 481d6a524..4f9d35973 100644 --- a/packages/cli-core/src/commands/init/ios/native-registration-retry.ts +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts @@ -3,6 +3,10 @@ import { lstat, mkdir, readFile, rmdir, unlink, writeFile } from "node:fs/promis import { homedir } from "node:os"; import { setTimeout as sleep } from "node:timers/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { + bundleIdentifiersEqual, + normalizeBundleIdentifierIdentity, +} from "../../../lib/apple-native-identity.ts"; import { getConfigFile } from "../../../lib/config.ts"; import { withHomeFsAccess } from "../../../lib/host-execution.ts"; @@ -76,7 +80,7 @@ function retryFingerprint(identity: IOSNativeRegistrationRetryIdentity): string JSON.stringify({ applicationId: identity.applicationId, instanceId: identity.instanceId, - bundleIdentifier: identity.bundleIdentifier, + bundleIdentifier: normalizeBundleIdentifierIdentity(identity.bundleIdentifier), appIdPrefix: identity.appIdPrefix, }), ) @@ -210,7 +214,8 @@ function isRetryRecord( record.kind === "clerk-ios-native-registration-retry" && record.applicationId === identity.applicationId && record.instanceId === identity.instanceId && - record.bundleIdentifier === identity.bundleIdentifier && + typeof record.bundleIdentifier === "string" && + bundleIdentifiersEqual(record.bundleIdentifier, identity.bundleIdentifier) && record.appIdPrefix === identity.appIdPrefix && typeof record.idempotencyKey === "string" && IDEMPOTENCY_KEY_PATTERN.test(record.idempotencyKey) && @@ -278,7 +283,7 @@ async function getOrCreateRetryKey( kind: "clerk-ios-native-registration-retry", applicationId: identity.applicationId, instanceId: identity.instanceId, - bundleIdentifier: identity.bundleIdentifier, + bundleIdentifier: normalizeBundleIdentifierIdentity(identity.bundleIdentifier), appIdPrefix: identity.appIdPrefix, idempotencyKey: `${IDEMPOTENCY_KEY_PREFIX}${randomUUID()}`, createdAt: new Date().toISOString(), 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 b85b486c4..f94b8485f 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 @@ -956,6 +956,52 @@ describe("Clerk Native Application remote setup", () => { }); }); + test("matches Bundle IDs case-insensitively and preserves the registration's stored spelling", () => { + const storedBundleIdentifier = "com.example.nativeapp"; + const result = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + root: IOS_ROOT, + target: selectedTarget(), + nativeSettings: nativeSettings(true), + registrations: [registration(LOCAL_PREFIX, storedBundleIdentifier)], + }); + + expect(result).toMatchObject({ + status: "satisfied", + bundleIdentifier: storedBundleIdentifier, + registration: "satisfied", + blockers: [], + }); + expect(result.localTarget).toMatchObject({ + bundleIdentifier: { status: "resolved", value: BUNDLE_IDENTIFIER }, + }); + }); + + test("keeps a case-only rerun read-only", async () => { + const storedBundleIdentifier = "com.example.nativeapp"; + const approved = buildIOSNativeRemotePlan({ + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + root: IOS_ROOT, + target: selectedTarget(), + nativeSettings: nativeSettings(true), + registrations: [registration(LOCAL_PREFIX, storedBundleIdentifier)], + }); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [ + [registration(LOCAL_PREFIX, storedBundleIdentifier)], + [registration(LOCAL_PREFIX, storedBundleIdentifier)], + ], + }); + + await applyRemoteSetup(approved, api, approvedTargetReader); + + expect(calls).not.toContain("POST iOS registration"); + expect(calls).not.toContain("PATCH native settings"); + }); + test.each([ { name: "duplicate prefixes for one Bundle ID", 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 df9fa1ebc..eb2b9e038 100644 --- a/packages/cli-core/src/commands/init/ios/native-remote.ts +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; import { dim, yellow } from "../../../lib/color.ts"; import { ApiError, @@ -343,7 +344,7 @@ export function buildIOSNativeRemotePlan(options: { const registrations = validateIOSApplications(options.registrations); const identity = localIdentity(options.target); const blockers = [...identity.blockers]; - const bundleIdentifier = identity.bundleIdentifier; + const localBundleIdentifier = identity.bundleIdentifier; const explicitPrefix = validateAppIdPrefix(options.requestedAppIdPrefix); if (options.requestedAppIdPrefix != null && !explicitPrefix) { blockers.push( @@ -360,14 +361,20 @@ export function buildIOSNativeRemotePlan(options: { blockers.push( blocker( "app-id-prefix-conflict", - `The supplied App ID Prefix does not match the literal prefix proven for ${bundleIdentifier ?? "the selected target"}.`, + `The supplied App ID Prefix does not match the literal prefix proven for ${localBundleIdentifier ?? "the selected target"}.`, ), ); } - const matchingBundle = bundleIdentifier - ? registrations.filter((registration) => registration.bundle_id === bundleIdentifier) + const matchingBundle = localBundleIdentifier + ? registrations.filter((registration) => + bundleIdentifiersEqual(registration.bundle_id, localBundleIdentifier), + ) : []; + // The backend currently uses the registered Bundle ID's original spelling + // for the native Apple lookup. Once a case-insensitive match exists, carry + // that authoritative stored spelling through the rest of reconciliation. + const bundleIdentifier = matchingBundle[0]?.bundle_id ?? localBundleIdentifier; const invalidRegisteredPrefixes = matchingBundle.filter( (registration) => validateAppIdPrefix(registration.app_id_prefix) !== registration.app_id_prefix, @@ -718,12 +725,12 @@ function localTargetStillMatchesApprovedIdentity( !plan.bundleIdentifier || !plan.appIdPrefix || approved.bundleIdentifier.status !== "resolved" || - approved.bundleIdentifier.value !== plan.bundleIdentifier || + !bundleIdentifiersEqual(approved.bundleIdentifier.value, plan.bundleIdentifier) || current.status !== "selected" || current.projectPath !== approved.projectPath || current.targetId !== approved.targetId || current.bundleIdentifier.status !== "resolved" || - current.bundleIdentifier.value !== plan.bundleIdentifier + !bundleIdentifiersEqual(current.bundleIdentifier.value, plan.bundleIdentifier) ) { return false; } @@ -774,7 +781,7 @@ function revalidatedActionSetIsAuthorized( current.status === "blocked" || current.applicationId !== approved.applicationId || current.instanceId !== approved.instanceId || - current.bundleIdentifier !== approved.bundleIdentifier || + !bundleIdentifiersEqual(current.bundleIdentifier, approved.bundleIdentifier) || current.appIdPrefix !== approved.appIdPrefix ) { return false; @@ -890,7 +897,7 @@ export async function applyIOSNativeRemoteSetup( ), ); if ( - created.bundle_id !== plan.bundleIdentifier || + !bundleIdentifiersEqual(created.bundle_id, plan.bundleIdentifier) || created.app_id_prefix !== plan.appIdPrefix ) { throw iosRemoteError( @@ -917,7 +924,7 @@ export async function applyIOSNativeRemoteSetup( } const exact = registrations.some( (registration) => - registration.bundle_id === plan.bundleIdentifier && + bundleIdentifiersEqual(registration.bundle_id, plan.bundleIdentifier) && registration.app_id_prefix === plan.appIdPrefix, ); if (!exact) { From 278537780e9331db673f9a22b6182849c456a931 Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 16:30:57 -0400 Subject: [PATCH 50/55] fix(ios): normalize bundle identifier readiness --- .../init/ios/native-readiness.test.ts | 22 ++++++++++++ .../src/commands/init/ios/native-readiness.ts | 31 +++++++---------- .../src/commands/init/ios/plan.test.ts | 34 +++++++++++++++++++ .../cli-core/src/commands/init/ios/plan.ts | 30 ++++++---------- 4 files changed, 79 insertions(+), 38 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts index 203020988..ff7ffd9b2 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.test.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -297,6 +297,28 @@ describe("buildIOSNativeReadinessAudit", () => { }); }); + test("treats case-only Bundle ID variants as one identity and preserves the first spelling", async () => { + const inspection = await inspectionFor({ complete: true }); + const target = inspection.appTargets[0]!; + target.configurations[0]!.bundleIdentifier = { + state: "resolved", + value: "com.Example.MyApp", + evidence: [], + }; + target.configurations[1]!.bundleIdentifier = { + state: "resolved", + value: "COM.EXAMPLE.MYAPP", + evidence: [], + }; + + const audit = buildIOSNativeReadinessAudit(inspection); + + expect(audit.target).toMatchObject({ + status: "selected", + bundleIdentifier: { status: "resolved", value: "com.Example.MyApp" }, + }); + }); + test("preserves a partial App ID Prefix candidate when one selected configuration lacks it", async () => { const inspection = await inspectionFor({ complete: true }); const releaseEntitlements = inspection.appTargets[0]!.configurations[1]!.entitlements!; diff --git a/packages/cli-core/src/commands/init/ios/native-readiness.ts b/packages/cli-core/src/commands/init/ios/native-readiness.ts index 8ddd011f7..852da0c9c 100644 --- a/packages/cli-core/src/commands/init/ios/native-readiness.ts +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -1,11 +1,7 @@ import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; import { buildIOSSetupPlan } from "./plan.ts"; -import type { - IOSAppTarget, - IOSProjectInspectionResult, - IOSSetupStepStatus, - IOSValueResolution, -} from "./types.ts"; +import { normalizeBundleIdentifierIdentity } from "../../../lib/apple-native-identity.ts"; +import type { IOSAppTarget, IOSProjectInspectionResult, IOSSetupStepStatus } from "./types.ts"; export const IOS_NATIVE_READINESS_PLAPI_BRIDGE_REQUIREMENT = { applicationId: "linked-application-id", @@ -122,18 +118,15 @@ function selectedTarget(inspection: IOSProjectInspectionResult): IOSAppTarget | ); } -function resolvedValues( - target: IOSAppTarget, - select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, -): string[] { - return [ - ...new Set( - target.configurations.flatMap((configuration) => { - const value = select(configuration); - return value.state === "resolved" ? [value.value] : []; - }), - ), - ].sort(); +function resolvedBundleIdentifiers(target: IOSAppTarget): string[] { + const candidatesByIdentity = new Map(); + for (const configuration of target.configurations) { + const value = configuration.bundleIdentifier; + if (value.state !== "resolved") continue; + const identity = normalizeBundleIdentifierIdentity(value.value); + if (!candidatesByIdentity.has(identity)) candidatesByIdentity.set(identity, value.value); + } + return [...candidatesByIdentity.values()].sort(); } export function suggestAppIdPrefixFromDevelopmentTeam( @@ -169,7 +162,7 @@ function bundleIdentifier(target: IOSAppTarget): IOSNativeReadinessBundleIdentif return { status: "unresolved" }; } - const candidates = resolvedValues(target, (configuration) => configuration.bundleIdentifier); + const candidates = resolvedBundleIdentifiers(target); if (candidates.length === 1) return { status: "resolved", value: candidates[0]! }; if (candidates.length === 0) return { status: "missing" }; return { status: "conflicting", candidates }; diff --git a/packages/cli-core/src/commands/init/ios/plan.test.ts b/packages/cli-core/src/commands/init/ios/plan.test.ts index 362707bc5..e08a676c2 100644 --- a/packages/cli-core/src/commands/init/ios/plan.test.ts +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -55,6 +55,40 @@ describe("buildIOSSetupPlan", () => { expect(JSON.stringify(plan)).not.toContain("CLERK_PUBLISHABLE_KEY="); }); + test("does not block native registration for case-only Bundle ID variants", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true }); + const inspection = await inspectIOSProject(root); + const target = inspection.appTargets[0]!; + target.configurations[0]!.bundleIdentifier = { + state: "resolved", + value: "com.Example.MyApp", + evidence: [], + }; + target.configurations[1]!.bundleIdentifier = { + state: "resolved", + value: "COM.EXAMPLE.MYAPP", + evidence: [], + }; + + const registration = buildIOSSetupPlan(inspection).steps.find( + (step) => step.id === "register-native-application", + ); + + expect(registration).toMatchObject({ status: "review" }); + expect(registration?.description).toContain("com.Example.MyApp"); + expect(registration?.description).not.toContain("COM.EXAMPLE.MYAPP"); + }); + + test("continues to block genuinely different Bundle IDs", async () => { + const plan = await planFor({ complete: true, conflictingBundle: true }); + + expect(plan.steps.find((step) => step.id === "register-native-application")).toMatchObject({ + status: "blocked", + }); + }); + test("classifies a LocalSecrets loader as a preserved custom key source", async () => { const root = await mkdtemp(join(tmpdir(), "clerk-ios-plan-")); temporaryDirectories.push(root); diff --git a/packages/cli-core/src/commands/init/ios/plan.ts b/packages/cli-core/src/commands/init/ios/plan.ts index 6d0c3c6f5..88db89e62 100644 --- a/packages/cli-core/src/commands/init/ios/plan.ts +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -13,6 +13,7 @@ import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associa import type { IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; import type { IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; import type { IOSSDKInstallPlan } from "./install-sdk.ts"; +import { normalizeBundleIdentifierIdentity } from "../../../lib/apple-native-identity.ts"; const NATIVE_APPLICATIONS_URL = "https://dashboard.clerk.com/~/native-applications"; const QUICKSTART_URL = "https://clerk.com/docs/ios/getting-started/quickstart"; @@ -31,21 +32,15 @@ function selectedEvidence(target: IOSAppTarget | undefined): IOSSourceEvidence[] return target ? [{ path: target.projectPath, objectId: target.id }] : []; } -function distinctResolved( - target: IOSAppTarget, - select: (configuration: IOSAppTarget["configurations"][number]) => IOSValueResolution, -): string[] { - return [ - ...new Set( - target.configurations - .map(select) - .filter( - (value): value is Extract => - value.state === "resolved", - ) - .map((value) => value.value), - ), - ].sort(); +function distinctResolvedBundleIdentifiers(target: IOSAppTarget): string[] { + const candidatesByIdentity = new Map(); + for (const configuration of target.configurations) { + const value = configuration.bundleIdentifier; + if (value.state !== "resolved") continue; + const identity = normalizeBundleIdentifierIdentity(value.value); + if (!candidatesByIdentity.has(identity)) candidatesByIdentity.set(identity, value.value); + } + return [...candidatesByIdentity.values()].sort(); } function allEvidence( @@ -316,10 +311,7 @@ export function buildIOSSetupPlan( ), ); - const bundleIdentifiers = distinctResolved( - target, - (configuration) => configuration.bundleIdentifier, - ); + const bundleIdentifiers = distinctResolvedBundleIdentifiers(target); const appPrefixes = [ ...new Set( target.configurations From a86c44f8d0ea131940cd598c70261f688c509a3d Mon Sep 17 00:00:00 2001 From: seanperez Date: Sun, 30 Aug 2026 23:02:09 -0400 Subject: [PATCH 51/55] fix(init): bound Apple entitlement reads --- .../init/ios/apple-entitlement.test.ts | 31 ++++++++++++ .../commands/init/ios/apple-entitlement.ts | 49 +++++++------------ 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts index dfc88c4f3..00c350d77 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts @@ -174,6 +174,21 @@ describe("iOS Sign in with Apple entitlement setup", () => { } }); + test("blocks an oversized entitlements file without changing it", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const oversized = Buffer.alloc(1_000_001, 0x20); + await writeFile(path, oversized); + + const plan = await planIOSAppleEntitlement(planOptions(root)); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan.status).toBe("blocked"); + expect(plan.blockers[0]?.code).toBe("unsupported-entitlements"); + expect(result.status).toBe("blocked"); + expect(await readFile(path)).toEqual(oversized); + }); + test("updates every distinct entitlements variant selected by target configurations", async () => { const root = await fixture(); const projectPath = join(root, "MyApp.xcodeproj", "project.pbxproj"); @@ -261,6 +276,22 @@ describe("iOS Sign in with Apple entitlement setup", () => { expect(await readFile(path, "utf8")).toBe("newer user bytes\n"); }); + test("returns stale without touching an entitlements file that grows beyond the limit", async () => { + const root = await fixture(); + const path = join(root, "MyApp", "MyApp.entitlements"); + const plan = await planIOSAppleEntitlement(planOptions(root)); + const oversized = Buffer.alloc(1_000_001, 0x20); + await writeFile(path, oversized); + + const prepared = await prepareIOSAppleEntitlementMutation(plan); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan.status).toBe("ready"); + expect(prepared.status).toBe("stale"); + expect(result.status).toBe("stale"); + expect(await readFile(path)).toEqual(oversized); + }); + test("creates and attaches a missing synchronized-root entitlements file", async () => { const root = await fixture(); await convertIOSFixtureToSynchronizedMissingEntitlements(root); diff --git a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts index 0b43c22ab..edb4020fe 100644 --- a/packages/cli-core/src/commands/init/ios/apple-entitlement.ts +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -1,10 +1,11 @@ -import { lstat, readFile } from "node:fs/promises"; +import { lstat } from "node:fs/promises"; import { dirname, isAbsolute, resolve } from "node:path"; import { isDeepStrictEqual } from "node:util"; import { planIOSAssociatedDomain, type IOSAssociatedDomainBlockerCode, } from "./associated-domain.ts"; +import { readBoundedRegularFile } from "./bounded-file.ts"; import { pathIsSafelyWithinIOSRoot, relativeIOSPath } from "./discovery.ts"; import { applyIOSFileTransaction, @@ -302,24 +303,17 @@ async function inspectEntitlementsFile( ), }; } - try { - const info = await lstat(absolutePath); - if (!info.isFile() || info.isSymbolicLink()) { - return { - status: "blocked", - blocker: blocker( - "unsupported-entitlements", - `${relativeIOSPath(root, absolutePath)} must be a regular, non-symlink XML plist.`, - ), - }; - } - return inspectEntitlementsBytes( - root, - absolutePath, - new Uint8Array(await readFile(absolutePath)), - info.mode & 0o7777, - ); - } catch { + const file = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (file.status === "not-regular" || file.status === "too-large") { + return { + status: "blocked", + blocker: blocker( + "unsupported-entitlements", + `${relativeIOSPath(root, absolutePath)} must be a regular, non-symlink XML plist no larger than 1 MB.`, + ), + }; + } + if (file.status !== "ok") { return { status: "blocked", blocker: blocker( @@ -328,6 +322,7 @@ async function inspectEntitlementsFile( ), }; } + return inspectEntitlementsBytes(root, absolutePath, file.bytes, file.mode); } function lineIndentAt(source: string, index: number): string { @@ -583,18 +578,10 @@ export async function prepareIOSAppleEntitlementMutation( } continue; } - try { - if (!file.expectedHash) - return blockPrepared(plan, "invalid-plan", "A planned file hash is missing."); - const info = await lstat(absolutePath); - if ( - !info.isFile() || - info.isSymbolicLink() || - hashIOSFileBytes(await readFile(absolutePath)) !== file.expectedHash - ) { - return { status: "stale", plan }; - } - } catch { + if (!file.expectedHash) + return blockPrepared(plan, "invalid-plan", "A planned file hash is missing."); + const current = await readBoundedRegularFile(absolutePath, MAX_ENTITLEMENTS_BYTES); + if (current.status !== "ok" || hashIOSFileBytes(current.bytes) !== file.expectedHash) { return { status: "stale", plan }; } } From 6a47befddab0bfb912488f1359bc88986068fce4 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 11:21:35 -0400 Subject: [PATCH 52/55] refactor(init): extract Apple native coordinator --- packages/cli-core/src/commands/init/index.ts | 511 ++-------------- .../src/commands/init/ios/coordinator.ts | 561 ++++++++++++++++++ 2 files changed, 605 insertions(+), 467 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/coordinator.ts diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index e38c00b74..083da12a1 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -8,7 +8,6 @@ import { dim, bold } from "../../lib/color.js"; import { throwUserAbort, throwUsageError, - ApiError, CliError, ERROR_CODE, errorMessage, @@ -37,7 +36,6 @@ import { import { readSdkKeylessApp } from "../../lib/keyless-target.ts"; import { interruptedExitCode } from "../../lib/signals.ts"; import { listApplications } from "../../lib/plapi.ts"; -import { decodePublishableKey, fetchUserSettings } from "../../lib/fapi.ts"; import { printNextSteps } from "../../lib/next-steps.js"; import { gatherContext, hasPackageJson } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; @@ -65,40 +63,12 @@ import { } from "./bootstrap.js"; import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; -import { inspectIOSProject } from "./ios/inspect.ts"; -import { recoverIOSFileTransactions } from "./ios/file-transaction.ts"; -import { buildIOSSetupPlan } from "./ios/plan.ts"; -import { planIOSDirectConfig } from "./ios/direct-config.ts"; +import { validateAppIdPrefix } from "./ios/native-remote.ts"; import { - clerkKitUIInstallDecision, - hasSupportedIOSCustomConfigure, - shouldPlanIOSDirectConfig, -} from "./ios/products.ts"; -import { planIOSAssociatedDomain } from "./ios/associated-domain.ts"; -import { planIOSAppleEntitlement } from "./ios/apple-entitlement.ts"; -import { planIOSPrebuiltAuth } from "./ios/prebuilt-auth.ts"; -import { planIOSSDKInstall } from "./ios/install-sdk.ts"; -import { resolveIOSDevelopmentPublicKey } from "./ios/development-key.ts"; -import { createIOSDryRunOutput, formatIOSSetupPlan } from "./ios/output.ts"; -import { - applyIOSLocalSetup, - applyIOSPlannedLocalSetup, - normalizeIOSSDKInstallPlanForSetup, - planIOSPrebuiltAuthRuntimeBlockers, - type IOSLocalSetupResult, -} from "./ios/apply.ts"; -import { - applyIOSNativeRemoteSetup, - assertIOSAppIdPrefixBeforeApplicationCreation, - prepareIOSNativeRemoteSetup, - validateAppIdPrefix, -} from "./ios/native-remote.ts"; -import { - applyIOSNativeAppleConnection, - prepareIOSNativeAppleConnection, - type IOSNativeApplePlan, -} from "./ios/native-apple.ts"; -import { auditIOSPrebuiltAuthEnvironment } from "./ios/prebuilt-auth-environment.ts"; + prepareAppleNativeSetup, + runAppleNativeDryRun, + type AppleNativeSetupCoordinator, +} from "./ios/coordinator.ts"; type InitOptions = { /** Framework to set up (skips auto-detection). */ @@ -229,163 +199,20 @@ export async function init(options: InitOptions = {}) { `--dry-run currently supports native iOS projects only; detected ${ctx.framework.name}.`, ); } - setTelemetryStage("ios_inspect"); - const inspect = async () => - inspectIOSProject(ctx.cwd, { - target: options.target, - exhaustiveContainerDiscovery: true, - }); - const inspection = machineOutput - ? await inspect() - : await withSpinner("Inspecting Xcode project...", inspect); - const dryRunSelection = inspection.selection; - const selectedTarget = - dryRunSelection.state === "selected" - ? inspection.appTargets.find( - (target) => - target.id === dryRunSelection.targetId && - target.projectPath === dryRunSelection.projectPath, - ) - : undefined; - const productDecision = selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined; - const hasSupportedCustomConfigure = - selectedTarget != null && hasSupportedIOSCustomConfigure(selectedTarget); - const inspectedPrebuiltAuthPlan = - dryRunSelection.state === "selected" - ? await planIOSPrebuiltAuth({ - root: ctx.cwd, - projectPath: dryRunSelection.projectPath, - targetId: dryRunSelection.targetId, - }) - : undefined; - const prebuiltAuthActive = - inspectedPrebuiltAuthPlan != null && - inspectedPrebuiltAuthPlan.status !== "blocked" && - (options.prebuiltAuthUI === true || inspectedPrebuiltAuthPlan.status === "satisfied"); - const directConfigPlan = - dryRunSelection.state === "selected" && - selectedTarget && - productDecision && - shouldPlanIOSDirectConfig( - inspection, - selectedTarget, - prebuiltAuthActive ? "prebuilt" : productDecision, - ) - ? await planIOSDirectConfig({ - root: ctx.cwd, - projectPath: dryRunSelection.projectPath, - targetId: dryRunSelection.targetId, - }) - : undefined; - const prebuiltRuntimeBlockers = prebuiltAuthActive - ? planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan) - : []; - const prebuiltAuthPlan = - inspectedPrebuiltAuthPlan && prebuiltRuntimeBlockers.length > 0 - ? { - ...inspectedPrebuiltAuthPlan, - status: "blocked" as const, - actions: [], - blockers: [ - ...inspectedPrebuiltAuthPlan.blockers, - { - code: "runtime-prerequisites" as const, - message: prebuiltRuntimeBlockers.join(" "), - }, - ], - } - : inspectedPrebuiltAuthPlan; - const associatedDomainPlan = - dryRunSelection.state === "selected" - ? await planIOSAssociatedDomain({ - root: ctx.cwd, - projectPath: dryRunSelection.projectPath, - targetId: dryRunSelection.targetId, - deferToPublishableKey: - directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, - allowMissingEntitlementsCreation: true, - }) - : undefined; - const hasLocalAppleIntent = selectedTarget?.configurations.some( - (configuration) => - configuration.entitlements !== undefined && - configuration.entitlements.signInWithAppleState !== "absent", - ); - const appleEntitlementPlan = - dryRunSelection.state === "selected" && - (options.signInWithApple === true || hasLocalAppleIntent === true) - ? await planIOSAppleEntitlement({ - root: ctx.cwd, - projectPath: dryRunSelection.projectPath, - targetId: dryRunSelection.targetId, - allowMissingEntitlementsCreation: true, - }) - : undefined; - const strictSDKInstallPlan = - dryRunSelection.state === "selected" && selectedTarget != null - ? await planIOSSDKInstall({ - root: ctx.cwd, - projectPath: dryRunSelection.projectPath, - targetId: dryRunSelection.targetId, - includeClerkKitUI: productDecision === "prebuilt" || prebuiltAuthActive, - requirePrebuiltAuthCompatibility: prebuiltAuthActive, - }) - : undefined; - const sdkInstallPlan = - strictSDKInstallPlan && selectedTarget - ? normalizeIOSSDKInstallPlanForSetup({ - installPlan: strictSDKInstallPlan, - selectedTarget, - prebuiltAuthActive, - }).sdkInstallPlan - : undefined; - const plan = buildIOSSetupPlan(inspection, { - sdkInstallPlan, - directConfigPlan, - associatedDomainPlan, - appleEntitlementPlan, - prebuiltAuthPlan, - prebuiltAuthSelected: options.prebuiltAuthUI === true, + await runAppleNativeDryRun({ + root: ctx.cwd, + target: options.target, + signInWithApple: options.signInWithApple, + prebuiltAuthUI: options.prebuiltAuthUI, + machineOutput, }); - if (machineOutput) { - log.data( - JSON.stringify(createIOSDryRunOutput(inspection, plan, { associatedDomainPlan }), null, 2), - ); - } else { - log.info(formatIOSSetupPlan(inspection, plan, { associatedDomainPlan })); - await outro(plan.status === "ready" ? "Setup looks ready" : "Setup incomplete"); - } - setTelemetryStage("done"); return; } setTelemetryStage("strategy"); - let iosLocalSetup: IOSLocalSetupResult | undefined; - let iosProfile: Awaited> | undefined; - let preauthenticatedIOSLabel: string | undefined; + let appleNativeSetup: AppleNativeSetupCoordinator | undefined; if (ctx.framework.dep === "ios") { - // A normal init is explicitly mutating and may finish a durable file - // transaction left by an interrupted earlier run. Dry-run returns above, - // so read-only inspection only reports recovery as required. - await recoverIOSFileTransactions(ctx.cwd); - - // Resolve the local link before the redacted preview. No application key - // is fetched and no local file is written until the user has authorized - // the complete semantic plan. - iosProfile = await resolveProfile(ctx.cwd); - if (agent && validatedAgentAuthLabel === undefined) { - validatedAgentAuthLabel = await validateAgentAuthentication(); - } - if (agent && validatedAgentAuthLabel === null) { - throwUsageError( - "Native iOS setup in agent mode requires valid Clerk authentication before any Xcode files can be changed. Ask the user to run `clerk auth login` or provide a valid Platform API key, then rerun `clerk init`.", - ); - } - - preauthenticatedIOSLabel = agent ? validatedAgentAuthLabel! : undefined; - - setTelemetryStage("ios_inspect"); - iosLocalSetup = await applyIOSLocalSetup({ + appleNativeSetup = await prepareAppleNativeSetup({ root: ctx.cwd, target: options.target, yes: options.yes === true, @@ -393,12 +220,11 @@ export async function init(options: InitOptions = {}) { allowDirty: options.allowDirty === true, signInWithApple: options.signInWithApple, prebuiltAuthUI: options.prebuiltAuthUI, + requestedApplicationId: options.app, + validatedAgentAuthLabel, + validateAgentAuthentication, }); - if (agent && iosLocalSetup.requiresExplicitApplication && !options.app) { - throwUsageError( - "This iOS target already contains a publishable-key configuration that requires explicit Clerk application selection. Ask the developer which existing application it belongs to, then rerun with --app . No local files were changed.", - ); - } + validatedAgentAuthLabel = appleNativeSetup.validatedAgentAuthLabel; } await enrichProjectContext(ctx); @@ -422,12 +248,12 @@ export async function init(options: InitOptions = {}) { : await isAuthenticated(); const linkedProfile = ctx.framework.dep === "ios" - ? iosProfile + ? appleNativeSetup?.linkedProfile : !optsKeyless && agent && authed && !options.app ? await resolveProfile(ctx.cwd) : undefined; const hasRealAppTarget = Boolean( - options.app || linkedProfile || iosLocalSetup?.requiresLinkedApp, + options.app || linkedProfile || appleNativeSetup?.requiresLinkedApp, ); const strategy = pickStrategy({ @@ -443,282 +269,48 @@ export async function init(options: InitOptions = {}) { assertKeylessOnlyFlags(options, strategy); let authenticatedAppId: string | undefined; - let iosApplicationLinkChange: "created-and-linked" | "link-updated" | undefined; + let appleNativeApplicationLinkChange: "created-and-linked" | "link-updated" | undefined; if (strategy === "authenticate") { setTelemetryStage("link"); - if (agent && iosLocalSetup?.requiresLinkedApp && !iosProfile && !options.app) { - assertIOSAppIdPrefixBeforeApplicationCreation({ - target: iosLocalSetup.nativeReadiness.target, - appIdPrefix: options.appIdPrefix, - ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion - ? { - unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion, - } - : {}), - }); - } + appleNativeSetup?.assertApplicationCreationReady({ + requestedApplicationId: options.app, + appIdPrefix: options.appIdPrefix, + }); bar(); const mayCreateApplication = - agent && (ctx.framework.dep !== "ios" || (!iosProfile && !options.app)); + agent && + (ctx.framework.dep !== "ios" || appleNativeSetup?.shouldCreateApplication(options.app)); const createIfMissing = mayCreateApplication - ? await deriveProjectName(ctx.cwd, bootstrap?.projectName ?? iosLocalSetup?.targetName) + ? await deriveProjectName(ctx.cwd, bootstrap?.projectName ?? appleNativeSetup?.targetName) : undefined; const authenticated = await authenticateAndLink( ctx.cwd, options.app, createIfMissing, - iosLocalSetup?.requiresLinkedApp === true, - iosLocalSetup?.requiresExplicitApplication === true, - preauthenticatedIOSLabel, + appleNativeSetup?.requiresLinkedApp === true, + appleNativeSetup?.requiresExplicitApplication === true, + appleNativeSetup?.preauthenticatedLabel, ); authenticatedAppId = authenticated.applicationId; if (ctx.framework.dep === "ios") { - iosApplicationLinkChange = authenticated.applicationLinkChange; + appleNativeApplicationLinkChange = authenticated.applicationLinkChange; } } - let authenticatedKeysHandled = false; - if (iosLocalSetup?.requiresLinkedApp) { - if (strategy !== "authenticate") { - throw new CliError( - "The approved iOS configuration requires a linked Clerk application, but authentication did not complete. No local setup changes were written.", - { code: ERROR_CODE.NOT_LINKED }, - ); - } - if (!authenticatedAppId) { - throw new CliError( - "The Clerk application link could not be verified. No local setup changes were written.", - { code: ERROR_CODE.NOT_LINKED }, - ); - } - setTelemetryStage("keys"); - const keys = await withSpinner("Fetching the development publishable key...", async () => - resolveIOSDevelopmentPublicKey(authenticatedAppId), - ); - if (keys.applicationId !== authenticatedAppId) { - throw new CliError( - "The linked Clerk application changed while its iOS publishable key was being resolved. No local setup changes were written; rerun clerk init.", - { code: ERROR_CODE.IOS_SETUP_STALE }, - ); - } - let iosSetupForCommit = iosLocalSetup; - let inspectedAuthViewAppleRequirement: "required" | "not-required" | undefined; - if (iosLocalSetup.prebuiltAuthActive) { - const authEnvironment = await withSpinner( - "Inspecting AuthView authentication methods...", - async () => { - try { - const { fapiHost } = decodePublishableKey(keys.publishableKey); - const settings = await fetchUserSettings(fapiHost, {}); - return auditIOSPrebuiltAuthEnvironment(settings); - } catch (error) { - if (interruptedExitCode() !== null) throw error; - if (error instanceof ApiError || error instanceof CliError) throw error; - log.debug( - "Could not inspect AuthView authentication methods; underlying error details were omitted.", - ); - throw new CliError( - "The linked Clerk application's AuthView methods could not be inspected safely. No local setup changes were written; rerun clerk init.", - { code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED }, - ); - } - }, - ); - if (authEnvironment.apple === "blocked") { - throw new CliError(`${authEnvironment.message} No local setup changes were written.`, { - code: ERROR_CODE.IOS_SETUP_BLOCKED, - }); - } - inspectedAuthViewAppleRequirement = authEnvironment.apple; - if (authEnvironment.apple === "required") { - const conditionalPlan = iosLocalSetup.prebuiltAuthAppleEntitlementPlan; - if (!conditionalPlan || conditionalPlan.status === "blocked") { - const reasons = conditionalPlan?.blockers - .map((blocker) => ` • ${blocker.message}`) - .join("\n"); - throw new CliError( - `AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be prepared safely. No local setup changes were written${ - reasons ? `:\n${reasons}` : "." - }`, - { code: ERROR_CODE.IOS_SETUP_BLOCKED }, - ); - } - } - } - setTelemetryStage("ios_native_plan"); - const nativeRemotePlan = await prepareIOSNativeRemoteSetup({ - applicationId: keys.applicationId, - instanceId: keys.instanceId, - root: iosLocalSetup.nativeReadiness.root, - target: iosLocalSetup.nativeReadiness.target, - appIdPrefix: options.appIdPrefix, - ...(iosLocalSetup.unverifiedAppIdPrefixSuggestion - ? { - unverifiedAppIdPrefixSuggestion: iosLocalSetup.unverifiedAppIdPrefixSuggestion, - } - : {}), - ...(iosApplicationLinkChange ? { applicationLinkChange: iosApplicationLinkChange } : {}), - agent, - yes: options.yes === true, - }); - let nativeApplePlan: IOSNativeApplePlan | undefined; - if (iosLocalSetup.nativeAppleRequested) { - if (!iosLocalSetup.appleEntitlementPlan) { - throw new CliError( - "Native Sign in with Apple was requested without a validated local entitlement plan. No local or Apple connection changes were written; rerun clerk init.", - { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, - ); - } - const target = iosLocalSetup.nativeReadiness.target; - if (target.status !== "selected" || target.bundleIdentifier.status !== "resolved") { - throw new CliError( - "The selected iOS Bundle ID could not be revalidated for native Sign in with Apple. No local or Apple connection changes were written.", - { code: ERROR_CODE.IOS_TARGET_UNRESOLVED }, - ); - } - if (!nativeRemotePlan.bundleIdentifier) { - throw new CliError( - "The selected iOS Bundle ID could not be matched to its Clerk Native Application registration. No local or Apple connection changes were written.", - { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, - ); - } - setTelemetryStage("ios_apple_plan"); - const preparedApple = await prepareIOSNativeAppleConnection({ - applicationId: keys.applicationId, - instanceId: keys.instanceId, - // Use the existing registration's stored spelling when its Bundle ID - // differs from Xcode only by case. The backend's native Apple lookup - // currently uses that authoritative value. - bundleIdentifier: nativeRemotePlan.bundleIdentifier, - nativeApplicationReady: - nativeRemotePlan.status !== "blocked" && nativeRemotePlan.registration !== "blocked", - requested: true, - agent, - yes: options.yes === true, - }); - if (preparedApple.status === "skipped") { - throw new CliError( - "Native Sign in with Apple was selected locally but its Clerk connection plan was skipped. No local or Apple connection changes were written; rerun clerk init.", - { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, - ); - } - nativeApplePlan = preparedApple; - } - const commitProfile = await resolveProfile(ctx.cwd); - if (commitProfile?.profile.appId !== authenticatedAppId) { - throw new CliError( - "The local Clerk application link changed before the approved iOS setup could be committed. No local or remote setup changes were written; rerun clerk init.", - { code: ERROR_CODE.IOS_SETUP_STALE }, - ); - } - - if (iosLocalSetup.prebuiltAuthActive) { - const authEnvironment = await withSpinner( - "Revalidating AuthView authentication methods...", - async () => { - try { - const { fapiHost } = decodePublishableKey(keys.publishableKey); - const settings = await fetchUserSettings(fapiHost, {}); - return auditIOSPrebuiltAuthEnvironment(settings); - } catch (error) { - if (interruptedExitCode() !== null) throw error; - if (error instanceof ApiError || error instanceof CliError) throw error; - log.debug( - "Could not revalidate AuthView authentication methods; underlying error details were omitted.", - ); - throw new CliError( - "The linked Clerk application's AuthView methods could not be revalidated safely. No local or remote setup changes were written; rerun clerk init.", - { code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED }, - ); - } - }, - ); - if (authEnvironment.apple === "blocked") { - throw new CliError( - `${authEnvironment.message} No local or remote setup changes were written.`, - { code: ERROR_CODE.IOS_SETUP_BLOCKED }, - ); - } - if (authEnvironment.apple !== inspectedAuthViewAppleRequirement) { - throw new CliError( - "The linked Clerk application's AuthView methods changed while the approved iOS setup was being prepared. No local or remote setup changes were written; rerun clerk init.", - { code: ERROR_CODE.IOS_SETUP_STALE }, - ); - } - - if (authEnvironment.apple === "required") { - const conditionalPlan = iosLocalSetup.prebuiltAuthAppleEntitlementPlan; - if (!conditionalPlan || conditionalPlan.status === "blocked") { - throw new CliError( - "AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be revalidated safely. No local or remote setup changes were written; rerun clerk init.", - { code: ERROR_CODE.IOS_SETUP_STALE }, - ); - } - iosSetupForCommit = { - ...iosLocalSetup, - appleEntitlementPlan: iosLocalSetup.appleEntitlementPlan ?? conditionalPlan, - prebuiltAuthAppleEntitlementPlan: undefined, - }; - } else { - iosSetupForCommit = { - ...iosLocalSetup, - prebuiltAuthAppleEntitlementPlan: undefined, - }; - } - } - - setTelemetryStage("ios_local_setup"); - await applyIOSPlannedLocalSetup( - iosSetupForCommit, - iosSetupForCommit.requiresDevelopmentKey ? keys.publishableKey : undefined, - ); - await assertIOSApplicationLinkStillMatches({ - cwd: ctx.cwd, - applicationId: nativeRemotePlan.applicationId, - phase: "native-application", - }); - try { - setTelemetryStage("ios_native_setup"); - await applyIOSNativeRemoteSetup(nativeRemotePlan); - } catch (error) { - if (interruptedExitCode() !== null) throw error; - if (error instanceof ApiError || error instanceof CliError) throw error; - log.debug( - "Could not reconcile Clerk Native Application settings; underlying error details were omitted.", - ); - throw new CliError( - "The local iOS setup completed, but Clerk Native Application settings could not be completed remotely. Local changes remain intact; rerun clerk init to safely reconcile the additive remote steps.", - { code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED }, - ); - } - log.success("Clerk Native API and iOS application registration verified"); + const appleNativeResult = appleNativeSetup + ? await appleNativeSetup.complete({ + authenticationCompleted: strategy === "authenticate", + applicationId: authenticatedAppId, + applicationLinkChange: appleNativeApplicationLinkChange, + appIdPrefix: options.appIdPrefix, + }) + : undefined; + const authenticatedKeysHandled = appleNativeResult?.authenticatedKeysHandled ?? false; + if (appleNativeResult?.nativeRemoteReady) { ctx.iosNativeRemoteReady = true; - if (nativeApplePlan) { - await assertIOSApplicationLinkStillMatches({ - cwd: ctx.cwd, - applicationId: nativeApplePlan.applicationId, - phase: "native-apple", - }); - try { - setTelemetryStage("ios_apple_setup"); - await applyIOSNativeAppleConnection(nativeApplePlan); - } catch (error) { - if (interruptedExitCode() !== null) throw error; - if (error instanceof ApiError || error instanceof CliError) throw error; - log.debug( - "Could not reconcile the native Apple connection; underlying error details were omitted.", - ); - throw new CliError( - "The local iOS setup and Clerk Native Application registration completed, but the native Apple connection could not be completed. Those completed changes remain intact; rerun clerk init to reconcile Sign in with Apple safely.", - { code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED }, - ); - } - ctx.iosNativeAppleReady = true; - } - authenticatedKeysHandled = true; - } else if (iosLocalSetup) { - setTelemetryStage("ios_local_setup"); - await applyIOSPlannedLocalSetup(iosLocalSetup); + } + if (appleNativeResult?.nativeAppleReady) { + ctx.iosNativeAppleReady = true; } // Short-circuit on a fully-clean re-run so env pull / skills prompt don't @@ -1203,21 +795,6 @@ async function authenticateAndLink( }; } -async function assertIOSApplicationLinkStillMatches(options: { - cwd: string; - applicationId: string; - phase: "native-application" | "native-apple"; -}): Promise { - const linked = await resolveProfile(options.cwd); - if (linked?.profile.appId === options.applicationId) return; - - const message = - options.phase === "native-application" - ? "The local Clerk application link changed after the approved iOS setup was committed. Local changes remain intact, but no Clerk Native Application changes were made; rerun clerk init." - : "The local Clerk application link changed after Clerk Native Application setup completed. The completed local and Clerk Native Application changes remain intact, but no native Apple connection changes were made; rerun clerk init."; - throw new CliError(message, { code: ERROR_CODE.IOS_SETUP_STALE }); -} - // --- Keyless app setup --- /** diff --git a/packages/cli-core/src/commands/init/ios/coordinator.ts b/packages/cli-core/src/commands/init/ios/coordinator.ts new file mode 100644 index 000000000..1a95bfb74 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/coordinator.ts @@ -0,0 +1,561 @@ +import { ApiError, CliError, ERROR_CODE, throwUsageError } from "../../../lib/errors.js"; +import { resolveProfile } from "../../../lib/config.js"; +import { decodePublishableKey, fetchUserSettings } from "../../../lib/fapi.ts"; +import { log } from "../../../lib/log.js"; +import { interruptedExitCode } from "../../../lib/signals.ts"; +import { outro, withSpinner } from "../../../lib/spinner.js"; +import { setTelemetryStage, type TelemetryStage } from "../../../lib/telemetry.ts"; +import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; +import { + applyIOSNativeAppleConnection, + prepareIOSNativeAppleConnection, + type IOSNativeApplePlan, +} from "./native-apple.ts"; +import { + applyIOSNativeRemoteSetup, + assertIOSAppIdPrefixBeforeApplicationCreation, + prepareIOSNativeRemoteSetup, +} from "./native-remote.ts"; +import { auditIOSPrebuiltAuthEnvironment } from "./prebuilt-auth-environment.ts"; +import { recoverIOSFileTransactions } from "./file-transaction.ts"; +import { resolveIOSDevelopmentPublicKey } from "./development-key.ts"; +import { inspectIOSProject } from "./inspect.ts"; +import { clerkKitUIInstallDecision, hasSupportedIOSCustomConfigure } from "./products.ts"; +import { planIOSPrebuiltAuth } from "./prebuilt-auth.ts"; +import { planIOSDirectConfig } from "./direct-config.ts"; +import { shouldPlanIOSDirectConfig } from "./products.ts"; +import { planIOSAssociatedDomain } from "./associated-domain.ts"; +import { planIOSAppleEntitlement } from "./apple-entitlement.ts"; +import { planIOSSDKInstall } from "./install-sdk.ts"; +import { + normalizeIOSSDKInstallPlanForSetup, + planIOSPrebuiltAuthRuntimeBlockers, + type IOSLocalSetupResult, +} from "./apply.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { createIOSDryRunOutput, formatIOSSetupPlan } from "./output.ts"; + +type LinkedProfile = Awaited>; + +export type AppleNativeDryRunOptions = { + root: string; + target?: string; + signInWithApple?: boolean; + prebuiltAuthUI?: boolean; + machineOutput: boolean; +}; + +export type PrepareAppleNativeSetupOptions = { + root: string; + target?: string; + yes: boolean; + agent: boolean; + allowDirty: boolean; + signInWithApple?: boolean; + prebuiltAuthUI?: boolean; + requestedApplicationId?: string; + validatedAgentAuthLabel?: string | null; + validateAgentAuthentication: () => Promise; +}; + +export type CompleteAppleNativeSetupOptions = { + authenticationCompleted: boolean; + applicationId?: string; + applicationLinkChange?: "created-and-linked" | "link-updated"; + appIdPrefix?: string; +}; + +export type AppleNativeSetupResult = { + authenticatedKeysHandled: boolean; + nativeRemoteReady: boolean; + nativeAppleReady: boolean; +}; + +export type AppleNativeSetupCoordinator = { + linkedProfile: LinkedProfile; + validatedAgentAuthLabel?: string; + preauthenticatedLabel?: string; + targetName: string; + requiresLinkedApp: boolean; + requiresExplicitApplication: boolean; + shouldCreateApplication(requestedApplicationId?: string): boolean; + assertApplicationCreationReady(options: { + requestedApplicationId?: string; + appIdPrefix?: string; + }): void; + complete(options: CompleteAppleNativeSetupOptions): Promise; +}; + +/** + * Owns the complete read-only native planning flow. Generic init only decides + * that the detected framework is Apple-native and delegates the native plan. + */ +export async function runAppleNativeDryRun(options: AppleNativeDryRunOptions): Promise { + setTelemetryStage("ios_inspect"); + const inspect = async () => + inspectIOSProject(options.root, { + target: options.target, + exhaustiveContainerDiscovery: true, + }); + const inspection = options.machineOutput + ? await inspect() + : await withSpinner("Inspecting Xcode project...", inspect); + const selection = inspection.selection; + const selectedTarget = + selection.state === "selected" + ? inspection.appTargets.find( + (target) => + target.id === selection.targetId && target.projectPath === selection.projectPath, + ) + : undefined; + const productDecision = selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined; + const hasSupportedCustomConfigure = + selectedTarget != null && hasSupportedIOSCustomConfigure(selectedTarget); + const inspectedPrebuiltAuthPlan = + selection.state === "selected" + ? await planIOSPrebuiltAuth({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const prebuiltAuthActive = + inspectedPrebuiltAuthPlan != null && + inspectedPrebuiltAuthPlan.status !== "blocked" && + (options.prebuiltAuthUI === true || inspectedPrebuiltAuthPlan.status === "satisfied"); + const directConfigPlan = + selection.state === "selected" && + selectedTarget && + productDecision && + shouldPlanIOSDirectConfig( + inspection, + selectedTarget, + prebuiltAuthActive ? "prebuilt" : productDecision, + ) + ? await planIOSDirectConfig({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + }) + : undefined; + const prebuiltRuntimeBlockers = prebuiltAuthActive + ? planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan) + : []; + const prebuiltAuthPlan = + inspectedPrebuiltAuthPlan && prebuiltRuntimeBlockers.length > 0 + ? { + ...inspectedPrebuiltAuthPlan, + status: "blocked" as const, + actions: [], + blockers: [ + ...inspectedPrebuiltAuthPlan.blockers, + { + code: "runtime-prerequisites" as const, + message: prebuiltRuntimeBlockers.join(" "), + }, + ], + } + : inspectedPrebuiltAuthPlan; + const associatedDomainPlan = + selection.state === "selected" + ? await planIOSAssociatedDomain({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + deferToPublishableKey: + directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, + allowMissingEntitlementsCreation: true, + }) + : undefined; + const hasLocalAppleIntent = selectedTarget?.configurations.some( + (configuration) => + configuration.entitlements !== undefined && + configuration.entitlements.signInWithAppleState !== "absent", + ); + const appleEntitlementPlan = + selection.state === "selected" && + (options.signInWithApple === true || hasLocalAppleIntent === true) + ? await planIOSAppleEntitlement({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowMissingEntitlementsCreation: true, + }) + : undefined; + const strictSDKInstallPlan = + selection.state === "selected" && selectedTarget != null + ? await planIOSSDKInstall({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + includeClerkKitUI: productDecision === "prebuilt" || prebuiltAuthActive, + requirePrebuiltAuthCompatibility: prebuiltAuthActive, + }) + : undefined; + const sdkInstallPlan = + strictSDKInstallPlan && selectedTarget + ? normalizeIOSSDKInstallPlanForSetup({ + installPlan: strictSDKInstallPlan, + selectedTarget, + prebuiltAuthActive, + }).sdkInstallPlan + : undefined; + const plan = buildIOSSetupPlan(inspection, { + sdkInstallPlan, + directConfigPlan, + associatedDomainPlan, + appleEntitlementPlan, + prebuiltAuthPlan, + prebuiltAuthSelected: options.prebuiltAuthUI === true, + }); + if (options.machineOutput) { + log.data( + JSON.stringify(createIOSDryRunOutput(inspection, plan, { associatedDomainPlan }), null, 2), + ); + } else { + log.info(formatIOSSetupPlan(inspection, plan, { associatedDomainPlan })); + await outro(plan.status === "ready" ? "Setup looks ready" : "Setup incomplete"); + } + setTelemetryStage("done"); +} + +/** + * Prepares and authorizes native local work without committing it. Application + * authentication/linking remains generic and happens between prepare and + * complete. + */ +export async function prepareAppleNativeSetup( + options: PrepareAppleNativeSetupOptions, +): Promise { + await recoverIOSFileTransactions(options.root); + + const linkedProfile = await resolveProfile(options.root); + let validatedAgentAuthLabel = options.validatedAgentAuthLabel; + if (options.agent && validatedAgentAuthLabel === undefined) { + validatedAgentAuthLabel = await options.validateAgentAuthentication(); + } + if (options.agent && validatedAgentAuthLabel === null) { + throwUsageError( + "Native iOS setup in agent mode requires valid Clerk authentication before any Xcode files can be changed. Ask the user to run `clerk auth login` or provide a valid Platform API key, then rerun `clerk init`.", + ); + } + + setTelemetryStage("ios_inspect"); + const localSetup = await applyIOSLocalSetup({ + root: options.root, + target: options.target, + yes: options.yes, + agent: options.agent, + allowDirty: options.allowDirty, + signInWithApple: options.signInWithApple, + prebuiltAuthUI: options.prebuiltAuthUI, + }); + if (options.agent && localSetup.requiresExplicitApplication && !options.requestedApplicationId) { + throwUsageError( + "This iOS target already contains a publishable-key configuration that requires explicit Clerk application selection. Ask the developer which existing application it belongs to, then rerun with --app . No local files were changed.", + ); + } + + const authLabel = validatedAgentAuthLabel ?? undefined; + return { + linkedProfile, + validatedAgentAuthLabel: authLabel, + preauthenticatedLabel: options.agent ? authLabel : undefined, + targetName: localSetup.targetName, + requiresLinkedApp: localSetup.requiresLinkedApp, + requiresExplicitApplication: localSetup.requiresExplicitApplication, + shouldCreateApplication(requestedApplicationId) { + return !linkedProfile && !requestedApplicationId; + }, + assertApplicationCreationReady({ requestedApplicationId, appIdPrefix }) { + if ( + options.agent && + localSetup.requiresLinkedApp && + !linkedProfile && + !requestedApplicationId + ) { + assertIOSAppIdPrefixBeforeApplicationCreation({ + target: localSetup.nativeReadiness.target, + appIdPrefix, + ...(localSetup.unverifiedAppIdPrefixSuggestion + ? { + unverifiedAppIdPrefixSuggestion: localSetup.unverifiedAppIdPrefixSuggestion, + } + : {}), + }); + } + }, + complete: async (completeOptions) => + completeAppleNativeSetup(options, localSetup, completeOptions), + }; +} + +async function completeAppleNativeSetup( + preparation: PrepareAppleNativeSetupOptions, + localSetup: IOSLocalSetupResult, + options: CompleteAppleNativeSetupOptions, +): Promise { + if (!localSetup.requiresLinkedApp) { + setTelemetryStage("ios_local_setup"); + await applyIOSPlannedLocalSetup(localSetup); + return { + authenticatedKeysHandled: false, + nativeRemoteReady: false, + nativeAppleReady: false, + }; + } + if (!options.authenticationCompleted) { + throw new CliError( + "The approved iOS configuration requires a linked Clerk application, but authentication did not complete. No local setup changes were written.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + if (!options.applicationId) { + throw new CliError( + "The Clerk application link could not be verified. No local setup changes were written.", + { code: ERROR_CODE.NOT_LINKED }, + ); + } + + setTelemetryStage("keys"); + const keys = await withSpinner("Fetching the development publishable key...", async () => + resolveIOSDevelopmentPublicKey(options.applicationId!), + ); + if (keys.applicationId !== options.applicationId) { + throw new CliError( + "The linked Clerk application changed while its iOS publishable key was being resolved. No local setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_STALE }, + ); + } + + let setupForCommit = localSetup; + let inspectedAuthViewAppleRequirement: "required" | "not-required" | undefined; + if (localSetup.prebuiltAuthActive) { + const authEnvironment = await inspectAuthViewEnvironment( + keys.publishableKey, + "Inspecting AuthView authentication methods...", + "inspected", + "No local setup changes were written", + ); + if (authEnvironment.apple === "blocked") { + throw new CliError(`${authEnvironment.message} No local setup changes were written.`, { + code: ERROR_CODE.IOS_SETUP_BLOCKED, + }); + } + inspectedAuthViewAppleRequirement = authEnvironment.apple; + if (authEnvironment.apple === "required") { + const conditionalPlan = localSetup.prebuiltAuthAppleEntitlementPlan; + if (!conditionalPlan || conditionalPlan.status === "blocked") { + const reasons = conditionalPlan?.blockers + .map((blocker) => ` • ${blocker.message}`) + .join("\n"); + throw new CliError( + `AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be prepared safely. No local setup changes were written${ + reasons ? `:\n${reasons}` : "." + }`, + { code: ERROR_CODE.IOS_SETUP_BLOCKED }, + ); + } + } + } + + setTelemetryStage("ios_native_plan"); + const nativeRemotePlan = await prepareIOSNativeRemoteSetup({ + applicationId: keys.applicationId, + instanceId: keys.instanceId, + root: localSetup.nativeReadiness.root, + target: localSetup.nativeReadiness.target, + appIdPrefix: options.appIdPrefix, + ...(localSetup.unverifiedAppIdPrefixSuggestion + ? { unverifiedAppIdPrefixSuggestion: localSetup.unverifiedAppIdPrefixSuggestion } + : {}), + ...(options.applicationLinkChange + ? { applicationLinkChange: options.applicationLinkChange } + : {}), + agent: preparation.agent, + yes: preparation.yes, + }); + let nativeApplePlan: IOSNativeApplePlan | undefined; + if (localSetup.nativeAppleRequested) { + if (!localSetup.appleEntitlementPlan) { + throw new CliError( + "Native Sign in with Apple was requested without a validated local entitlement plan. No local or Apple connection changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, + ); + } + const target = localSetup.nativeReadiness.target; + if (target.status !== "selected" || target.bundleIdentifier.status !== "resolved") { + throw new CliError( + "The selected iOS Bundle ID could not be revalidated for native Sign in with Apple. No local or Apple connection changes were written.", + { code: ERROR_CODE.IOS_TARGET_UNRESOLVED }, + ); + } + if (!nativeRemotePlan.bundleIdentifier) { + throw new CliError( + "The selected iOS Bundle ID could not be matched to its Clerk Native Application registration. No local or Apple connection changes were written.", + { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, + ); + } + setTelemetryStage("ios_apple_plan"); + const preparedApple = await prepareIOSNativeAppleConnection({ + applicationId: keys.applicationId, + instanceId: keys.instanceId, + bundleIdentifier: nativeRemotePlan.bundleIdentifier, + nativeApplicationReady: + nativeRemotePlan.status !== "blocked" && nativeRemotePlan.registration !== "blocked", + requested: true, + agent: preparation.agent, + yes: preparation.yes, + }); + if (preparedApple.status === "skipped") { + throw new CliError( + "Native Sign in with Apple was selected locally but its Clerk connection plan was skipped. No local or Apple connection changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_PLAN_INVALID }, + ); + } + nativeApplePlan = preparedApple; + } + + const commitProfile = await resolveProfile(preparation.root); + if (commitProfile?.profile.appId !== options.applicationId) { + throw new CliError( + "The local Clerk application link changed before the approved iOS setup could be committed. No local or remote setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_STALE }, + ); + } + + if (localSetup.prebuiltAuthActive) { + const authEnvironment = await inspectAuthViewEnvironment( + keys.publishableKey, + "Revalidating AuthView authentication methods...", + "revalidated", + "No local or remote setup changes were written", + ); + if (authEnvironment.apple === "blocked") { + throw new CliError( + `${authEnvironment.message} No local or remote setup changes were written.`, + { code: ERROR_CODE.IOS_SETUP_BLOCKED }, + ); + } + if (authEnvironment.apple !== inspectedAuthViewAppleRequirement) { + throw new CliError( + "The linked Clerk application's AuthView methods changed while the approved iOS setup was being prepared. No local or remote setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_STALE }, + ); + } + if (authEnvironment.apple === "required") { + const conditionalPlan = localSetup.prebuiltAuthAppleEntitlementPlan; + if (!conditionalPlan || conditionalPlan.status === "blocked") { + throw new CliError( + "AuthView exposes Sign in with Apple for the linked development instance, but the required selected-target entitlement could not be revalidated safely. No local or remote setup changes were written; rerun clerk init.", + { code: ERROR_CODE.IOS_SETUP_STALE }, + ); + } + setupForCommit = { + ...localSetup, + appleEntitlementPlan: localSetup.appleEntitlementPlan ?? conditionalPlan, + prebuiltAuthAppleEntitlementPlan: undefined, + }; + } else { + setupForCommit = { + ...localSetup, + prebuiltAuthAppleEntitlementPlan: undefined, + }; + } + } + + setTelemetryStage("ios_local_setup"); + await applyIOSPlannedLocalSetup( + setupForCommit, + setupForCommit.requiresDevelopmentKey ? keys.publishableKey : undefined, + ); + await assertApplicationLinkStillMatches({ + root: preparation.root, + applicationId: nativeRemotePlan.applicationId, + phase: "native-application", + }); + await applyRemoteStep( + "ios_native_setup", + async () => applyIOSNativeRemoteSetup(nativeRemotePlan), + "Could not reconcile Clerk Native Application settings; underlying error details were omitted.", + "The local iOS setup completed, but Clerk Native Application settings could not be completed remotely. Local changes remain intact; rerun clerk init to safely reconcile the additive remote steps.", + ); + log.success("Clerk Native API and iOS application registration verified"); + + if (nativeApplePlan) { + await assertApplicationLinkStillMatches({ + root: preparation.root, + applicationId: nativeApplePlan.applicationId, + phase: "native-apple", + }); + await applyRemoteStep( + "ios_apple_setup", + async () => applyIOSNativeAppleConnection(nativeApplePlan), + "Could not reconcile the native Apple connection; underlying error details were omitted.", + "The local iOS setup and Clerk Native Application registration completed, but the native Apple connection could not be completed. Those completed changes remain intact; rerun clerk init to reconcile Sign in with Apple safely.", + ); + } + + return { + authenticatedKeysHandled: true, + nativeRemoteReady: true, + nativeAppleReady: nativeApplePlan != null, + }; +} + +async function inspectAuthViewEnvironment( + publishableKey: string, + spinner: string, + verb: "inspected" | "revalidated", + unchangedMessage: string, +): Promise> { + return withSpinner(spinner, async () => { + try { + const { fapiHost } = decodePublishableKey(publishableKey); + const settings = await fetchUserSettings(fapiHost, {}); + return auditIOSPrebuiltAuthEnvironment(settings); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + if (error instanceof ApiError || error instanceof CliError) throw error; + log.debug( + `Could not ${verb === "inspected" ? "inspect" : "revalidate"} AuthView authentication methods; underlying error details were omitted.`, + ); + throw new CliError( + `The linked Clerk application's AuthView methods could not be ${verb} safely. ${unchangedMessage}; rerun clerk init.`, + { code: ERROR_CODE.IOS_REMOTE_VERIFY_FAILED }, + ); + } + }); +} + +async function applyRemoteStep( + stage: TelemetryStage, + apply: () => Promise, + debugMessage: string, + failureMessage: string, +): Promise { + try { + setTelemetryStage(stage); + await apply(); + } catch (error) { + if (interruptedExitCode() !== null) throw error; + if (error instanceof ApiError || error instanceof CliError) throw error; + log.debug(debugMessage); + throw new CliError(failureMessage, { code: ERROR_CODE.IOS_REMOTE_APPLY_FAILED }); + } +} + +async function assertApplicationLinkStillMatches(options: { + root: string; + applicationId: string; + phase: "native-application" | "native-apple"; +}): Promise { + const linked = await resolveProfile(options.root); + if (linked?.profile.appId === options.applicationId) return; + + const message = + options.phase === "native-application" + ? "The local Clerk application link changed after the approved iOS setup was committed. Local changes remain intact, but no Clerk Native Application changes were made; rerun clerk init." + : "The local Clerk application link changed after Clerk Native Application setup completed. The completed local and Clerk Native Application changes remain intact, but no native Apple connection changes were made; rerun clerk init."; + throw new CliError(message, { code: ERROR_CODE.IOS_SETUP_STALE }); +} From df59cd9e6f95e9e919ea9e2a8430b21a2a72f4d7 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 12:25:18 -0400 Subject: [PATCH 53/55] refactor(init): share native local setup planning --- .../src/commands/init/index-ios.test.ts | 15 + .../cli-core/src/commands/init/ios/apply.ts | 294 ++++------------ .../src/commands/init/ios/coordinator.ts | 145 ++------ .../src/commands/init/ios/local-plan.test.ts | 112 ++++++ .../src/commands/init/ios/local-plan.ts | 331 ++++++++++++++++++ .../cli-core/src/commands/init/ios/output.ts | 7 +- .../cli-core/src/test/lib/init-harness.ts | 15 + 7 files changed, 576 insertions(+), 343 deletions(-) create mode 100644 packages/cli-core/src/commands/init/ios/local-plan.test.ts create mode 100644 packages/cli-core/src/commands/init/ios/local-plan.ts diff --git a/packages/cli-core/src/commands/init/index-ios.test.ts b/packages/cli-core/src/commands/init/index-ios.test.ts index a10c9a733..3fb9ededa 100644 --- a/packages/cli-core/src/commands/init/index-ios.test.ts +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -136,6 +136,21 @@ function iosPrebuiltAuthPlan(overrides: Partial = {}): IOSP function iosSetupResult(overrides: Partial = {}): IOSLocalSetupResult { return { targetName: "MyApp", + setupPlan: { + schemaVersion: 1, + kind: "clerk-ios-setup", + root: "/tmp/test", + status: "ready", + selection: { + state: "selected", + targetId: "TARGET", + targetName: "MyApp", + projectPath: "MyApp.xcodeproj", + }, + summary: { satisfied: 0, required: 0, review: 0, blocked: 0 }, + steps: [], + diagnostics: [], + }, nativeReadiness: FAKE_IOS_NATIVE_READINESS, prebuiltAuthRequested: false, prebuiltAuthActive: false, diff --git a/packages/cli-core/src/commands/init/ios/apply.ts b/packages/cli-core/src/commands/init/ios/apply.ts index 737dea36e..e07dce2c5 100644 --- a/packages/cli-core/src/commands/init/ios/apply.ts +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -13,7 +13,6 @@ import { confirm } from "../../../lib/prompts.ts"; import { withSpinner } from "../../../lib/spinner.ts"; import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; import { - planIOSSDKInstall, prepareIOSSDKInstallMutation, validateIOSSDKInstallPostcondition, type IOSSDKInstallPlan, @@ -21,12 +20,6 @@ import { } from "./install-sdk.ts"; import { buildIOSSetupPlan } from "./plan.ts"; import { - clerkKitUIInstallDecision, - hasSupportedIOSCustomConfigure, - shouldPlanIOSDirectConfig, -} from "./products.ts"; -import { - planIOSDirectConfig, prepareIOSDirectConfigMutation, validatePreparedIOSDirectConfig, type IOSDirectConfigPlan, @@ -51,12 +44,6 @@ import { type IOSAppleEntitlementPlan, type PreparedIOSAppleEntitlementMutation, } from "./apple-entitlement.ts"; -import { - buildIOSNativeReadinessAudit, - suggestAppIdPrefixFromDevelopmentTeam, - type IOSNativeReadinessAudit, - type IOSUnverifiedAppIdPrefixSuggestion, -} from "./native-readiness.ts"; import { planIOSPrebuiltAuth, prepareIOSPrebuiltAuthMutation, @@ -64,7 +51,12 @@ import { type IOSPrebuiltAuthPlan, type PreparedIOSPrebuiltAuthMutation, } from "./prebuilt-auth.ts"; -import type { IOSAppTarget } from "./types.ts"; +import { + buildIOSLocalSetupProposal, + createIOSLocalSetupContext, + planIOSPrebuiltAuthRuntimeBlockers, + type IOSLocalSetupProposal, +} from "./local-plan.ts"; function iosSetupError(message: string, code: ErrorCode = ERROR_CODE.IOS_SETUP_BLOCKED): CliError { return new CliError(message, { code }); @@ -82,66 +74,29 @@ export interface ApplyIOSLocalSetupOptions { prebuiltAuthUI?: boolean; } -/** Keep legacy, fully linked product graphs review-only while preserving every other SDK blocker. */ -export function normalizeIOSSDKInstallPlanForSetup(options: { - installPlan: IOSSDKInstallPlan; - selectedTarget: IOSAppTarget; - prebuiltAuthActive: boolean; -}): { - sdkInstallPlan?: IOSSDKInstallPlan; - reviewOnlyUnattributedInstall: boolean; -} { - const { installPlan, selectedTarget, prebuiltAuthActive } = options; - const reviewOnlyUnattributedInstall = - !prebuiltAuthActive && - installPlan.requirePrebuiltAuthCompatibility !== true && - installPlan.status === "blocked" && - installPlan.blockers.length > 0 && - installPlan.blockers.every((blocker) => blocker.code === "unattributed-product") && - installPlan.products.every((product) => - product === "ClerkKit" - ? selectedTarget.packages.clerkKit === "linked" - : selectedTarget.packages.clerkKitUI === "linked", - ); - return { - sdkInstallPlan: reviewOnlyUnattributedInstall ? undefined : installPlan, - reviewOnlyUnattributedInstall, - }; -} - -export interface IOSLocalSetupResult { +export type IOSLocalSetupResult = Pick< + IOSLocalSetupProposal, + | "setupPlan" + | "nativeReadiness" + | "unverifiedAppIdPrefixSuggestion" + | "sdkInstallPlan" + | "directConfigPlan" + | "associatedDomainPlan" + | "appleEntitlementPlan" + | "prebuiltAuthPlan" + | "prebuiltAuthAppleEntitlementPlan" + | "prebuiltAuthRequested" + | "prebuiltAuthActive" + | "nativeAppleRequested" +> & { targetName: string; - /** Redacted local identity used to audit the linked instance after authentication. */ - nativeReadiness: IOSNativeReadinessAudit; - /** Human-only Xcode signing-team suggestion; never treated as proven prefix evidence. */ - unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; - sdkInstallPlan?: IOSSDKInstallPlan; - /** Fresh/default direct Swift configuration or existing inline verification. */ - directConfigPlan?: IOSDirectConfigPlan; - /** Existing entitlements files that can receive the exact linked webcredentials host. */ - associatedDomainPlan?: IOSAssociatedDomainPlan; - /** Selected-target Sign in with Apple entitlement setup or verification. */ - appleEntitlementPlan?: IOSAppleEntitlementPlan; - /** Optional prebuilt AuthView source setup or exact generated-flow verification. */ - prebuiltAuthPlan?: IOSPrebuiltAuthPlan; - /** - * Pre-authorized local Apple capability candidate for the selected AuthView flow. - * It is applied only when a later environment audit proves Apple is enabled. - */ - prebuiltAuthAppleEntitlementPlan?: IOSAppleEntitlementPlan; - /** Explicit flag or AuthView-specific human confirmation; never inferred from --yes. */ - prebuiltAuthRequested: boolean; - /** Explicitly selected or byte-identical generated AuthView flow present on a rerun. */ - prebuiltAuthActive: boolean; - /** Explicit flag or Apple-specific human confirmation; never inferred from --yes. */ - nativeAppleRequested: boolean; /** Authentication must return an exact app ID and development key before commit. */ requiresLinkedApp: boolean; /** The approved local transaction consumes the linked development publishable key. */ requiresDevelopmentKey: boolean; /** A preserved runtime configuration requires the developer to choose its Clerk application. */ requiresExplicitApplication: boolean; -} +}; /** @internal Test-only hook used to prove aggregate post-write rollback. */ export interface ApplyIOSPlannedLocalSetupOptions { @@ -245,35 +200,6 @@ function blockerList(blockers: Array<{ message: string }>): string { return blockers.map((blocker) => ` • ${blocker.message}`).join("\n"); } -export function planIOSPrebuiltAuthRuntimeBlockers( - inspection: Awaited>, - directConfigPlan: IOSDirectConfigPlan | undefined, -): string[] { - const setupPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); - const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); - const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); - const directConfigurationReady = - directConfigPlan?.status === "ready" && configureStep?.automatable === true; - const directEnvironmentReady = - directConfigPlan?.status === "ready" && - (directConfigPlan.changes?.environment === "insert" || - directConfigPlan.changes?.environment === "satisfied"); - const blockers: string[] = []; - - if (configureStep?.status !== "satisfied" && !directConfigurationReady) { - blockers.push( - "Clerk.configure(publishableKey:) is neither proven at runtime nor included in the safe direct-configuration plan.", - ); - } - if (environmentStep?.status !== "satisfied" && !directEnvironmentReady) { - blockers.push( - "Clerk.shared is not proven in the shipping SwiftUI root environment, and the existing runtime abstraction cannot be rewritten safely.", - ); - } - - return blockers; -} - async function validatePrebuiltAuthRuntimePostcondition( setup: IOSLocalSetupResult, ): Promise { @@ -312,6 +238,7 @@ export async function applyIOSLocalSetup( exhaustiveContainerDiscovery: true, }), ); + const context = createIOSLocalSetupContext(inspection); if (hasIncompleteIOSContainerDiscovery(inspection)) { throw iosSetupError( "Xcode project discovery was incomplete, so Clerk cannot safely select an iOS application target. Run the command from the intended project's directory, make nested project directories readable, or reduce excessive project nesting or count.", @@ -344,41 +271,70 @@ export async function applyIOSLocalSetup( ); } - const selectedTarget = inspection.appTargets.find( - (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, - ); + const selectedTarget = context.selectedTarget; if (!selectedTarget) { throw iosSetupError( "The selected iOS target could not be resolved safely.", ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } - const unverifiedAppIdPrefixSuggestion = suggestAppIdPrefixFromDevelopmentTeam(selectedTarget); - const productDecision = clerkKitUIInstallDecision(selectedTarget); + const productDecision = context.productDecision; + if (!productDecision) { + throw iosSetupError( + "The selected iOS target could not be planned safely.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } if (productDecision === "unknown") { throw iosSetupError( "The selected target's Swift source membership could not be inspected completely, so Clerk cannot safely choose between the prebuilt ClerkKitUI path and a core-only custom flow. Resolve the Xcode source-membership diagnostics, then rerun clerk init.", ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } - const inspectedPrebuiltAuthPlan = await planIOSPrebuiltAuth({ + + const proposal = await buildIOSLocalSetupProposal(context, { root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, allowDirty: options.allowDirty, + prebuiltAuthUI: options.prebuiltAuthUI, + signInWithApple: options.signInWithApple, + ...(!options.agent && !options.yes + ? { + resolvePrebuiltAuthRequest: async ({ targetName }: { targetName: string }) => + confirm({ + message: `Add ClerkKitUI's prebuilt authentication UI to ${targetName}?`, + default: false, + }), + resolveNativeAppleRequest: async ({ bundleIdentifier }: { bundleIdentifier: string }) => + confirm({ + message: `Enable native Sign in with Apple for ${bundleIdentifier}?`, + default: false, + }), + } + : {}), }); - let prebuiltAuthRequested = options.prebuiltAuthUI === true; - if ( - !prebuiltAuthRequested && - options.prebuiltAuthUI == null && - inspectedPrebuiltAuthPlan.status === "ready" && - !options.agent && - !options.yes - ) { - prebuiltAuthRequested = await confirm({ - message: `Add ClerkKitUI's prebuilt authentication UI to ${selection.targetName}?`, - default: false, - }); + const { + inspectedPrebuiltAuthPlan, + prebuiltAuthPlan, + prebuiltAuthRequested, + prebuiltAuthActive, + installPlan, + reviewOnlyUnattributedInstall, + directConfigPlan, + plannedAssociatedDomain, + associatedDomainPlan, + appleEntitlementPlan, + prebuiltAuthAppleEntitlementPlan, + nativeAppleRequested, + nativeReadiness, + hasCustomConfigure, + hasSupportedCustomConfigure, + prebuiltRuntimeBlockers, + } = proposal; + if (!installPlan || !plannedAssociatedDomain || !inspectedPrebuiltAuthPlan) { + throw iosSetupError( + "The selected iOS target did not produce one complete local setup proposal.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); } if (prebuiltAuthRequested && inspectedPrebuiltAuthPlan.status === "blocked") { throw iosSetupError( @@ -387,40 +343,6 @@ export async function applyIOSLocalSetup( )}`, ); } - const prebuiltAuthActive = - prebuiltAuthRequested || inspectedPrebuiltAuthPlan.status === "satisfied"; - const prebuiltAuthPlan = prebuiltAuthActive ? inspectedPrebuiltAuthPlan : undefined; - - // A source-proven custom flow remains core-only by default, but an explicit - // or interactive AuthView selection must link the product that generated - // source imports before the aggregate transaction is authorized. - const includeClerkKitUI = productDecision === "prebuilt" || prebuiltAuthActive; - - const installPlan = await planIOSSDKInstall({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - includeClerkKitUI, - requirePrebuiltAuthCompatibility: prebuiltAuthActive, - }); - - const hasCustomConfigure = selectedTarget.swift.configureCalls.some( - (call) => call.publishableKeyWiring === "custom", - ); - const hasSupportedCustomConfigure = hasSupportedIOSCustomConfigure(selectedTarget); - const shouldPlanDirectConfig = shouldPlanIOSDirectConfig( - inspection, - selectedTarget, - prebuiltAuthActive ? "prebuilt" : productDecision, - ); - const directConfigPlan = shouldPlanDirectConfig - ? await planIOSDirectConfig({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - allowDirty: options.allowDirty, - }) - : undefined; if ( directConfigNeedsWrite(directConfigPlan) && prebuiltAuthPlan?.status === "ready" && @@ -431,21 +353,6 @@ export async function applyIOSLocalSetup( ERROR_CODE.IOS_SETUP_PLAN_INVALID, ); } - const plannedAssociatedDomain = await planIOSAssociatedDomain({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - deferToPublishableKey: directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, - allowMissingEntitlementsCreation: true, - }); - // Associated Domains is an independent additive improvement. Unsupported - // or ambiguous entitlements must not prevent the already-proven SDK/source - // setup; those cases remain an actionable manual step in the final plan. - const associatedDomainPlan = - plannedAssociatedDomain.status === "blocked" ? undefined : plannedAssociatedDomain; - const nativeReadiness = buildIOSNativeReadinessAudit(inspection, { - associatedDomainPlan: plannedAssociatedDomain, - }); if ( nativeReadiness.target.status !== "selected" || nativeReadiness.target.bundleIdentifier.status !== "resolved" @@ -455,40 +362,6 @@ export async function applyIOSLocalSetup( ERROR_CODE.IOS_TARGET_UNRESOLVED, ); } - const hasLocalAppleEntitlement = selectedTarget.configurations.some( - (configuration) => - configuration.entitlements !== undefined && - configuration.entitlements.signInWithAppleState !== "absent", - ); - let nativeAppleRequested = options.signInWithApple === true; - if (!nativeAppleRequested && options.signInWithApple == null && !options.agent && !options.yes) { - nativeAppleRequested = await confirm({ - message: `Enable native Sign in with Apple for ${nativeReadiness.target.bundleIdentifier.value}?`, - default: false, - }); - } - const inspectedAppleEntitlementPlan = - nativeAppleRequested || hasLocalAppleEntitlement || prebuiltAuthActive - ? await planIOSAppleEntitlement({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - allowMissingEntitlementsCreation: true, - }) - : undefined; - // Existing entitlement evidence remains available for a read-only satisfied - // verification, but incomplete local Apple setup is never completed unless - // this invocation explicitly opted into the strategy. - const appleEntitlementPlan = nativeAppleRequested - ? inspectedAppleEntitlementPlan - : hasLocalAppleEntitlement && inspectedAppleEntitlementPlan?.status === "blocked" - ? inspectedAppleEntitlementPlan - : inspectedAppleEntitlementPlan?.status === "satisfied" - ? inspectedAppleEntitlementPlan - : undefined; - const prebuiltAuthAppleEntitlementPlan = prebuiltAuthActive - ? inspectedAppleEntitlementPlan - : undefined; if (appleEntitlementPlan?.status === "blocked") { throw iosSetupError( `Native Sign in with Apple could not be configured safely. No local files were changed:\n${blockerList( @@ -496,12 +369,6 @@ export async function applyIOSLocalSetup( )}`, ); } - const { sdkInstallPlan, reviewOnlyUnattributedInstall } = normalizeIOSSDKInstallPlanForSetup({ - installPlan, - selectedTarget, - prebuiltAuthActive, - }); - if (directConfigPlan?.status === "blocked") { throw iosSetupError( `The selected SwiftUI app could not be configured automatically. No local files were changed:\n${blockerList( @@ -524,10 +391,9 @@ export async function applyIOSLocalSetup( ); } if (prebuiltAuthActive) { - const runtimeBlockers = planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan); - if (runtimeBlockers.length > 0) { + if (prebuiltRuntimeBlockers.length > 0) { throw iosSetupError( - `The prebuilt AuthView flow requires a proven Clerk runtime and SwiftUI environment before its source can be added. No local files were changed:\n${runtimeBlockers + `The prebuilt AuthView flow requires a proven Clerk runtime and SwiftUI environment before its source can be added. No local files were changed:\n${prebuiltRuntimeBlockers .map((message) => ` • ${message}`) .join("\n")}`, ); @@ -788,18 +654,8 @@ export async function applyIOSLocalSetup( } return { + ...proposal, targetName: selection.targetName, - nativeReadiness, - ...(unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion } : {}), - sdkInstallPlan, - directConfigPlan, - associatedDomainPlan, - appleEntitlementPlan, - prebuiltAuthPlan, - prebuiltAuthAppleEntitlementPlan, - prebuiltAuthRequested, - prebuiltAuthActive, - nativeAppleRequested, requiresLinkedApp: true, requiresDevelopmentKey: directConfigPlan != null || associatedDomainPlan?.requiresPublishableKey === true, diff --git a/packages/cli-core/src/commands/init/ios/coordinator.ts b/packages/cli-core/src/commands/init/ios/coordinator.ts index 1a95bfb74..037420f07 100644 --- a/packages/cli-core/src/commands/init/ios/coordinator.ts +++ b/packages/cli-core/src/commands/init/ios/coordinator.ts @@ -20,20 +20,9 @@ import { auditIOSPrebuiltAuthEnvironment } from "./prebuilt-auth-environment.ts" import { recoverIOSFileTransactions } from "./file-transaction.ts"; import { resolveIOSDevelopmentPublicKey } from "./development-key.ts"; import { inspectIOSProject } from "./inspect.ts"; -import { clerkKitUIInstallDecision, hasSupportedIOSCustomConfigure } from "./products.ts"; -import { planIOSPrebuiltAuth } from "./prebuilt-auth.ts"; -import { planIOSDirectConfig } from "./direct-config.ts"; -import { shouldPlanIOSDirectConfig } from "./products.ts"; -import { planIOSAssociatedDomain } from "./associated-domain.ts"; -import { planIOSAppleEntitlement } from "./apple-entitlement.ts"; -import { planIOSSDKInstall } from "./install-sdk.ts"; -import { - normalizeIOSSDKInstallPlanForSetup, - planIOSPrebuiltAuthRuntimeBlockers, - type IOSLocalSetupResult, -} from "./apply.ts"; -import { buildIOSSetupPlan } from "./plan.ts"; +import { type IOSLocalSetupResult } from "./apply.ts"; import { createIOSDryRunOutput, formatIOSSetupPlan } from "./output.ts"; +import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "./local-plan.ts"; type LinkedProfile = Awaited>; @@ -100,120 +89,32 @@ export async function runAppleNativeDryRun(options: AppleNativeDryRunOptions): P const inspection = options.machineOutput ? await inspect() : await withSpinner("Inspecting Xcode project...", inspect); - const selection = inspection.selection; - const selectedTarget = - selection.state === "selected" - ? inspection.appTargets.find( - (target) => - target.id === selection.targetId && target.projectPath === selection.projectPath, - ) - : undefined; - const productDecision = selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined; - const hasSupportedCustomConfigure = - selectedTarget != null && hasSupportedIOSCustomConfigure(selectedTarget); - const inspectedPrebuiltAuthPlan = - selection.state === "selected" - ? await planIOSPrebuiltAuth({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - }) - : undefined; - const prebuiltAuthActive = - inspectedPrebuiltAuthPlan != null && - inspectedPrebuiltAuthPlan.status !== "blocked" && - (options.prebuiltAuthUI === true || inspectedPrebuiltAuthPlan.status === "satisfied"); - const directConfigPlan = - selection.state === "selected" && - selectedTarget && - productDecision && - shouldPlanIOSDirectConfig( - inspection, - selectedTarget, - prebuiltAuthActive ? "prebuilt" : productDecision, - ) - ? await planIOSDirectConfig({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - }) - : undefined; - const prebuiltRuntimeBlockers = prebuiltAuthActive - ? planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan) - : []; - const prebuiltAuthPlan = - inspectedPrebuiltAuthPlan && prebuiltRuntimeBlockers.length > 0 - ? { - ...inspectedPrebuiltAuthPlan, - status: "blocked" as const, - actions: [], - blockers: [ - ...inspectedPrebuiltAuthPlan.blockers, - { - code: "runtime-prerequisites" as const, - message: prebuiltRuntimeBlockers.join(" "), - }, - ], - } - : inspectedPrebuiltAuthPlan; - const associatedDomainPlan = - selection.state === "selected" - ? await planIOSAssociatedDomain({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - deferToPublishableKey: - directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, - allowMissingEntitlementsCreation: true, - }) - : undefined; - const hasLocalAppleIntent = selectedTarget?.configurations.some( - (configuration) => - configuration.entitlements !== undefined && - configuration.entitlements.signInWithAppleState !== "absent", - ); - const appleEntitlementPlan = - selection.state === "selected" && - (options.signInWithApple === true || hasLocalAppleIntent === true) - ? await planIOSAppleEntitlement({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - allowMissingEntitlementsCreation: true, - }) - : undefined; - const strictSDKInstallPlan = - selection.state === "selected" && selectedTarget != null - ? await planIOSSDKInstall({ - root: options.root, - projectPath: selection.projectPath, - targetId: selection.targetId, - includeClerkKitUI: productDecision === "prebuilt" || prebuiltAuthActive, - requirePrebuiltAuthCompatibility: prebuiltAuthActive, - }) - : undefined; - const sdkInstallPlan = - strictSDKInstallPlan && selectedTarget - ? normalizeIOSSDKInstallPlanForSetup({ - installPlan: strictSDKInstallPlan, - selectedTarget, - prebuiltAuthActive, - }).sdkInstallPlan - : undefined; - const plan = buildIOSSetupPlan(inspection, { - sdkInstallPlan, - directConfigPlan, - associatedDomainPlan, - appleEntitlementPlan, - prebuiltAuthPlan, - prebuiltAuthSelected: options.prebuiltAuthUI === true, + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root: options.root, + allowDirty: false, + prebuiltAuthUI: options.prebuiltAuthUI, + signInWithApple: options.signInWithApple, }); + const plan = proposal.setupPlan; + const associatedDomainPlan = proposal.plannedAssociatedDomain; if (options.machineOutput) { log.data( - JSON.stringify(createIOSDryRunOutput(inspection, plan, { associatedDomainPlan }), null, 2), + JSON.stringify( + createIOSDryRunOutput(inspection, plan, { + associatedDomainPlan, + nativeReadiness: proposal.nativeReadiness, + }), + null, + 2, + ), ); } else { - log.info(formatIOSSetupPlan(inspection, plan, { associatedDomainPlan })); + log.info( + formatIOSSetupPlan(inspection, plan, { + associatedDomainPlan, + nativeReadiness: proposal.nativeReadiness, + }), + ); await outro(plan.status === "ready" ? "Setup looks ready" : "Setup incomplete"); } setTelemetryStage("done"); diff --git a/packages/cli-core/src/commands/init/ios/local-plan.test.ts b/packages/cli-core/src/commands/init/ios/local-plan.test.ts new file mode 100644 index 000000000..cfcbd8133 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/local-plan.test.ts @@ -0,0 +1,112 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspectIOSProject } from "./inspect.ts"; +import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "./local-plan.ts"; +import { applyIOSLocalSetup, applyIOSPlannedLocalSetup } from "./apply.ts"; +import { createIOSFixture, treeDigest } from "./test-helpers.ts"; +import { createIOSDryRunOutput } from "./output.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +const temporaryDirectories: string[] = []; +const publishableKey = `pk_test_${Buffer.from("local-plan.clerk.example$").toString("base64")}`; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true }))); +}); + +describe("iOS local setup lifecycle", () => { + useCaptureLog(); + + test("does not ask about Apple after an explicitly requested AuthView plan is blocked", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-plan-blocked-auth-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { complete: true, includeKey: false }); + const inspection = await inspectIOSProject(root, { + target: "MyApp", + exhaustiveContainerDiscovery: true, + }); + let applePromptCount = 0; + + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root, + allowDirty: true, + prebuiltAuthUI: true, + resolveNativeAppleRequest: async () => { + applePromptCount += 1; + return true; + }, + }); + + expect(proposal.inspectedPrebuiltAuthPlan?.status).toBe("blocked"); + expect(applePromptCount).toBe(0); + expect(proposal.nativeAppleRequested).toBe(false); + }); + + test("uses the same read-only proposal for preview and apply", async () => { + const root = await mkdtemp(join(tmpdir(), "clerk-ios-local-plan-")); + temporaryDirectories.push(root); + await createIOSFixture(root, { clerkSDK: false, includeKey: false }); + const initialBytes = await treeDigest(root); + + const inspection = await inspectIOSProject(root, { + target: "MyApp", + exhaustiveContainerDiscovery: true, + }); + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + + expect(await treeDigest(root)).toEqual(initialBytes); + const dryRun = createIOSDryRunOutput(proposal.inspection, proposal.setupPlan, { + associatedDomainPlan: proposal.plannedAssociatedDomain, + nativeReadiness: proposal.nativeReadiness, + }); + expect(dryRun.plan).toBe(proposal.setupPlan); + expect(dryRun.nativeReadiness).toBe(proposal.nativeReadiness); + expect( + proposal.setupPlan.steps + .filter((step) => step.status === "required" && step.automatable) + .map((step) => step.id), + ).toEqual( + expect.arrayContaining([ + "install-clerk-sdk", + "configure-publishable-key", + "inject-clerk-environment", + "add-associated-domain", + ]), + ); + + const approved = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + expect(approved.setupPlan).toEqual(proposal.setupPlan); + expect(await treeDigest(root)).toEqual(initialBytes); + + await applyIOSPlannedLocalSetup(approved, publishableKey); + const appliedBytes = await treeDigest(root); + expect(appliedBytes).not.toEqual(initialBytes); + + const rerun = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); + await applyIOSPlannedLocalSetup(rerun, publishableKey); + expect(await treeDigest(root)).toEqual(appliedBytes); + }); +}); diff --git a/packages/cli-core/src/commands/init/ios/local-plan.ts b/packages/cli-core/src/commands/init/ios/local-plan.ts new file mode 100644 index 000000000..c3b18648e --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/local-plan.ts @@ -0,0 +1,331 @@ +import type { IOSProjectInspectionResult, IOSSetupPlan } from "./types.ts"; +import type { IOSAppTarget } from "./types.ts"; +import { + clerkKitUIInstallDecision, + hasSupportedIOSCustomConfigure, + shouldPlanIOSDirectConfig, +} from "./products.ts"; +import { planIOSPrebuiltAuth, type IOSPrebuiltAuthPlan } from "./prebuilt-auth.ts"; +import { planIOSDirectConfig, type IOSDirectConfigPlan } from "./direct-config.ts"; +import { planIOSAssociatedDomain, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { planIOSAppleEntitlement, type IOSAppleEntitlementPlan } from "./apple-entitlement.ts"; +import { planIOSSDKInstall, type IOSSDKInstallPlan } from "./install-sdk.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { + buildIOSNativeReadinessAudit, + suggestAppIdPrefixFromDevelopmentTeam, + type IOSNativeReadinessAudit, + type IOSUnverifiedAppIdPrefixSuggestion, +} from "./native-readiness.ts"; + +type ProductDecision = ReturnType; + +export interface IOSLocalSetupContext { + inspection: IOSProjectInspectionResult; + selectedTarget?: IOSAppTarget; + productDecision?: ProductDecision; +} + +export interface BuildIOSLocalSetupProposalOptions { + root: string; + allowDirty: boolean; + /** Explicit AuthView choice. Undefined allows the caller to resolve a human choice. */ + prebuiltAuthUI?: boolean; + /** Explicit native Apple choice. Undefined allows the caller to resolve a human choice. */ + signInWithApple?: boolean; + resolvePrebuiltAuthRequest?: (options: { + targetName: string; + plan: IOSPrebuiltAuthPlan; + }) => Promise; + resolveNativeAppleRequest?: (options: { bundleIdentifier: string }) => Promise; +} + +/** + * One credential-free, mutation-free proposal shared by dry-run and apply. + * Candidate bytes and prepared mutations never enter this structure. + */ +export interface IOSLocalSetupProposal { + inspection: IOSProjectInspectionResult; + selectedTarget?: IOSAppTarget; + productDecision?: ProductDecision; + setupPlan: IOSSetupPlan; + nativeReadiness: IOSNativeReadinessAudit; + unverifiedAppIdPrefixSuggestion?: IOSUnverifiedAppIdPrefixSuggestion; + inspectedPrebuiltAuthPlan?: IOSPrebuiltAuthPlan; + prebuiltAuthPlanForSetup?: IOSPrebuiltAuthPlan; + prebuiltAuthPlan?: IOSPrebuiltAuthPlan; + prebuiltRuntimeBlockers: string[]; + prebuiltAuthRequested: boolean; + prebuiltAuthActive: boolean; + installPlan?: IOSSDKInstallPlan; + sdkInstallPlan?: IOSSDKInstallPlan; + reviewOnlyUnattributedInstall: boolean; + directConfigPlan?: IOSDirectConfigPlan; + plannedAssociatedDomain?: IOSAssociatedDomainPlan; + associatedDomainPlan?: IOSAssociatedDomainPlan; + inspectedAppleEntitlementPlan?: IOSAppleEntitlementPlan; + appleEntitlementPlan?: IOSAppleEntitlementPlan; + prebuiltAuthAppleEntitlementPlan?: IOSAppleEntitlementPlan; + nativeAppleRequested: boolean; + hasCustomConfigure: boolean; + hasSupportedCustomConfigure: boolean; +} + +export function createIOSLocalSetupContext( + inspection: IOSProjectInspectionResult, +): IOSLocalSetupContext { + const selection = inspection.selection; + if (selection.state !== "selected") return { inspection }; + const selectedTarget = inspection.appTargets.find( + (target) => target.id === selection.targetId && target.projectPath === selection.projectPath, + ); + return { + inspection, + selectedTarget, + productDecision: selectedTarget ? clerkKitUIInstallDecision(selectedTarget) : undefined, + }; +} + +/** Keep legacy, fully linked product graphs review-only while preserving every other SDK blocker. */ +export function normalizeIOSSDKInstallPlanForSetup(options: { + installPlan: IOSSDKInstallPlan; + selectedTarget: IOSAppTarget; + prebuiltAuthActive: boolean; +}): { + sdkInstallPlan?: IOSSDKInstallPlan; + reviewOnlyUnattributedInstall: boolean; +} { + const { installPlan, selectedTarget, prebuiltAuthActive } = options; + const reviewOnlyUnattributedInstall = + !prebuiltAuthActive && + installPlan.requirePrebuiltAuthCompatibility !== true && + installPlan.status === "blocked" && + installPlan.blockers.length > 0 && + installPlan.blockers.every((blocker) => blocker.code === "unattributed-product") && + installPlan.products.every((product) => + product === "ClerkKit" + ? selectedTarget.packages.clerkKit === "linked" + : selectedTarget.packages.clerkKitUI === "linked", + ); + return { + sdkInstallPlan: reviewOnlyUnattributedInstall ? undefined : installPlan, + reviewOnlyUnattributedInstall, + }; +} + +export function planIOSPrebuiltAuthRuntimeBlockers( + inspection: IOSProjectInspectionResult, + directConfigPlan: IOSDirectConfigPlan | undefined, +): string[] { + const setupPlan = buildIOSSetupPlan(inspection, { directConfigPlan }); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); + const directConfigurationReady = + directConfigPlan?.status === "ready" && configureStep?.automatable === true; + const directEnvironmentReady = + directConfigPlan?.status === "ready" && + (directConfigPlan.changes?.environment === "insert" || + directConfigPlan.changes?.environment === "satisfied"); + const blockers: string[] = []; + + if (configureStep?.status !== "satisfied" && !directConfigurationReady) { + blockers.push( + "Clerk.configure(publishableKey:) is neither proven at runtime nor included in the safe direct-configuration plan.", + ); + } + if (environmentStep?.status !== "satisfied" && !directEnvironmentReady) { + blockers.push( + "Clerk.shared is not proven in the shipping SwiftUI root environment, and the existing runtime abstraction cannot be rewritten safely.", + ); + } + + return blockers; +} + +export async function buildIOSLocalSetupProposal( + context: IOSLocalSetupContext, + options: BuildIOSLocalSetupProposalOptions, +): Promise { + const { inspection, selectedTarget, productDecision } = context; + const selection = inspection.selection; + if (selection.state !== "selected" || !selectedTarget || !productDecision) { + const setupPlan = buildIOSSetupPlan(inspection, { + prebuiltAuthSelected: options.prebuiltAuthUI === true, + }); + return { + inspection, + selectedTarget, + productDecision, + setupPlan, + nativeReadiness: buildIOSNativeReadinessAudit(inspection), + prebuiltAuthRequested: options.prebuiltAuthUI === true, + prebuiltAuthActive: false, + prebuiltRuntimeBlockers: [], + reviewOnlyUnattributedInstall: false, + nativeAppleRequested: options.signInWithApple === true, + hasCustomConfigure: false, + hasSupportedCustomConfigure: false, + }; + } + + const inspectedPrebuiltAuthPlan = await planIOSPrebuiltAuth({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowDirty: options.allowDirty, + }); + let prebuiltAuthRequested = options.prebuiltAuthUI === true; + if ( + !prebuiltAuthRequested && + options.prebuiltAuthUI == null && + inspectedPrebuiltAuthPlan.status === "ready" && + options.resolvePrebuiltAuthRequest + ) { + prebuiltAuthRequested = await options.resolvePrebuiltAuthRequest({ + targetName: selection.targetName, + plan: inspectedPrebuiltAuthPlan, + }); + } + const prebuiltAuthActive = + inspectedPrebuiltAuthPlan.status !== "blocked" && + (prebuiltAuthRequested || inspectedPrebuiltAuthPlan.status === "satisfied"); + + const includeClerkKitUI = productDecision === "prebuilt" || prebuiltAuthActive; + const installPlan = await planIOSSDKInstall({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + includeClerkKitUI, + requirePrebuiltAuthCompatibility: prebuiltAuthActive, + }); + + const hasCustomConfigure = selectedTarget.swift.configureCalls.some( + (call) => call.publishableKeyWiring === "custom", + ); + const hasSupportedCustomConfigure = hasSupportedIOSCustomConfigure(selectedTarget); + const directConfigPlan = shouldPlanIOSDirectConfig( + inspection, + selectedTarget, + prebuiltAuthActive ? "prebuilt" : productDecision, + ) + ? await planIOSDirectConfig({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowDirty: options.allowDirty, + }) + : undefined; + + const prebuiltRuntimeBlockers = prebuiltAuthActive + ? planIOSPrebuiltAuthRuntimeBlockers(inspection, directConfigPlan) + : []; + const prebuiltAuthPlanForSetup = + prebuiltRuntimeBlockers.length > 0 + ? { + ...inspectedPrebuiltAuthPlan, + status: "blocked" as const, + actions: [], + blockers: [ + ...inspectedPrebuiltAuthPlan.blockers, + { + code: "runtime-prerequisites" as const, + message: prebuiltRuntimeBlockers.join(" "), + }, + ], + } + : inspectedPrebuiltAuthPlan; + const prebuiltAuthPlan = prebuiltAuthActive ? prebuiltAuthPlanForSetup : undefined; + + const plannedAssociatedDomain = await planIOSAssociatedDomain({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + deferToPublishableKey: directConfigPlan?.status === "ready" || hasSupportedCustomConfigure, + allowMissingEntitlementsCreation: true, + }); + const associatedDomainPlan = + plannedAssociatedDomain.status === "blocked" ? undefined : plannedAssociatedDomain; + const nativeReadiness = buildIOSNativeReadinessAudit(inspection, { + associatedDomainPlan: plannedAssociatedDomain, + }); + + const hasLocalAppleEntitlement = selectedTarget.configurations.some( + (configuration) => + configuration.entitlements !== undefined && + configuration.entitlements.signInWithAppleState !== "absent", + ); + let nativeAppleRequested = options.signInWithApple === true; + if ( + !nativeAppleRequested && + options.signInWithApple == null && + options.resolveNativeAppleRequest && + !(prebuiltAuthRequested && inspectedPrebuiltAuthPlan.status === "blocked") && + nativeReadiness.target.status === "selected" && + nativeReadiness.target.bundleIdentifier.status === "resolved" + ) { + nativeAppleRequested = await options.resolveNativeAppleRequest({ + bundleIdentifier: nativeReadiness.target.bundleIdentifier.value, + }); + } + const inspectedAppleEntitlementPlan = + nativeAppleRequested || hasLocalAppleEntitlement || prebuiltAuthActive + ? await planIOSAppleEntitlement({ + root: options.root, + projectPath: selection.projectPath, + targetId: selection.targetId, + allowMissingEntitlementsCreation: true, + }) + : undefined; + const appleEntitlementPlan = nativeAppleRequested + ? inspectedAppleEntitlementPlan + : hasLocalAppleEntitlement && inspectedAppleEntitlementPlan?.status === "blocked" + ? inspectedAppleEntitlementPlan + : inspectedAppleEntitlementPlan?.status === "satisfied" + ? inspectedAppleEntitlementPlan + : undefined; + const prebuiltAuthAppleEntitlementPlan = prebuiltAuthActive + ? inspectedAppleEntitlementPlan + : undefined; + + const { sdkInstallPlan, reviewOnlyUnattributedInstall } = normalizeIOSSDKInstallPlanForSetup({ + installPlan, + selectedTarget, + prebuiltAuthActive, + }); + const setupPlan = buildIOSSetupPlan(inspection, { + sdkInstallPlan, + directConfigPlan, + associatedDomainPlan: plannedAssociatedDomain, + appleEntitlementPlan, + prebuiltAuthPlan: prebuiltAuthPlanForSetup, + prebuiltAuthSelected: prebuiltAuthRequested, + }); + const unverifiedAppIdPrefixSuggestion = suggestAppIdPrefixFromDevelopmentTeam(selectedTarget); + + return { + inspection, + selectedTarget, + productDecision, + setupPlan, + nativeReadiness, + ...(unverifiedAppIdPrefixSuggestion ? { unverifiedAppIdPrefixSuggestion } : {}), + inspectedPrebuiltAuthPlan, + prebuiltAuthPlanForSetup, + prebuiltAuthPlan, + prebuiltRuntimeBlockers, + prebuiltAuthRequested, + prebuiltAuthActive, + installPlan, + sdkInstallPlan, + reviewOnlyUnattributedInstall, + directConfigPlan, + plannedAssociatedDomain, + associatedDomainPlan, + inspectedAppleEntitlementPlan, + appleEntitlementPlan, + prebuiltAuthAppleEntitlementPlan, + nativeAppleRequested, + hasCustomConfigure, + hasSupportedCustomConfigure, + }; +} diff --git a/packages/cli-core/src/commands/init/ios/output.ts b/packages/cli-core/src/commands/init/ios/output.ts index 08b5615b5..efbc3f316 100644 --- a/packages/cli-core/src/commands/init/ios/output.ts +++ b/packages/cli-core/src/commands/init/ios/output.ts @@ -21,6 +21,8 @@ export interface IOSDryRunOutput { export interface IOSOutputOptions { associatedDomainPlan?: IOSAssociatedDomainPlan; + /** Exact readiness audit from the shared local setup proposal. */ + nativeReadiness?: IOSNativeReadinessAudit; } export function createIOSDryRunOutput( @@ -34,7 +36,7 @@ export function createIOSDryRunOutput( status: plan.status, inspection, plan, - nativeReadiness: buildIOSNativeReadinessAudit(inspection, options), + nativeReadiness: options.nativeReadiness ?? buildIOSNativeReadinessAudit(inspection, options), }; } @@ -114,7 +116,8 @@ export function formatIOSSetupPlan( } } - const nativeReadiness = buildIOSNativeReadinessAudit(inspection, options); + const nativeReadiness = + options.nativeReadiness ?? buildIOSNativeReadinessAudit(inspection, options); lines.push("", " Native iOS readiness:"); lines.push( ` - Associated Domains: ${nativeReadiness.associatedDomain.status}${nativeReadiness.associatedDomain.automatable ? " (clerk init can apply)" : ""}`, diff --git a/packages/cli-core/src/test/lib/init-harness.ts b/packages/cli-core/src/test/lib/init-harness.ts index 94e906979..8ca08aa7c 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -214,6 +214,21 @@ export function useInitHarness(): InitHarness { spyOn(bootstrapModule, "confirmOverwrite").mockResolvedValue(undefined), spyOn(iosApplyModule, "applyIOSLocalSetup").mockResolvedValue({ targetName: "MyApp", + setupPlan: { + schemaVersion: 1, + kind: "clerk-ios-setup", + root: "/tmp/test", + status: "ready", + selection: { + state: "selected", + targetId: "TARGET", + targetName: "MyApp", + projectPath: "MyApp.xcodeproj", + }, + summary: { satisfied: 0, required: 0, review: 0, blocked: 0 }, + steps: [], + diagnostics: [], + }, nativeReadiness: FAKE_IOS_NATIVE_READINESS, prebuiltAuthRequested: false, prebuiltAuthActive: false, From 5f3eafef2e500984f1ed5cceac8e522a8a943b16 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 13:10:27 -0400 Subject: [PATCH 54/55] refactor(init): reuse native setup proposal for guidance --- .../src/commands/init/frameworks/ios.ts | 61 +++++-------------- 1 file changed, 15 insertions(+), 46 deletions(-) diff --git a/packages/cli-core/src/commands/init/frameworks/ios.ts b/packages/cli-core/src/commands/init/frameworks/ios.ts index 03ea0b5d7..84e0b808b 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -1,13 +1,6 @@ import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; -import { planIOSDirectConfig } from "../ios/direct-config.ts"; import { inspectIOSProject } from "../ios/inspect.ts"; -import { buildIOSSetupPlan } from "../ios/plan.ts"; -import { - clerkKitUIInstallDecision, - hasSupportedIOSCustomConfigure, - shouldPlanIOSDirectConfig, -} from "../ios/products.ts"; -import { planIOSAssociatedDomain } from "../ios/associated-domain.ts"; +import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "../ios/local-plan.ts"; /** * iOS (Swift) support for `clerk init`. @@ -29,46 +22,22 @@ export const ios: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "ios", async scaffold(ctx: ProjectContext): Promise { - const inspection = await inspectIOSProject(ctx.cwd, { target: ctx.iosTarget }); + const inspection = await inspectIOSProject(ctx.cwd, { + target: ctx.iosTarget, + exhaustiveContainerDiscovery: true, + }); + const proposal = await buildIOSLocalSetupProposal(createIOSLocalSetupContext(inspection), { + root: ctx.cwd, + allowDirty: true, + prebuiltAuthUI: false, + signInWithApple: false, + }); const selection = inspection.selection; - const target = - selection.state === "selected" - ? inspection.appTargets.find( - (candidate) => - candidate.id === selection.targetId && - candidate.projectPath === selection.projectPath, - ) - : undefined; - const productDecision = target ? clerkKitUIInstallDecision(target) : "prebuilt"; + const target = proposal.selectedTarget; + const productDecision = proposal.productDecision ?? "prebuilt"; const includeClerkKitUI = productDecision === "prebuilt"; - const hasCustomConfigure = target != null && hasSupportedIOSCustomConfigure(target); - const shouldPlanDirectConfig = - selection.state === "selected" && - target != null && - shouldPlanIOSDirectConfig(inspection, target, productDecision); - const directConfigPlan = - shouldPlanDirectConfig && selection.state === "selected" - ? await planIOSDirectConfig({ - root: ctx.cwd, - projectPath: selection.projectPath, - targetId: selection.targetId, - }) - : undefined; - const associatedDomainPlan = - selection.state === "selected" - ? await planIOSAssociatedDomain({ - root: ctx.cwd, - projectPath: selection.projectPath, - targetId: selection.targetId, - deferToPublishableKey: - directConfigPlan?.status === "ready" || hasCustomConfigure === true, - allowMissingEntitlementsCreation: true, - }) - : undefined; - const setupPlan = buildIOSSetupPlan(inspection, { - directConfigPlan, - associatedDomainPlan, - }); + const hasCustomConfigure = proposal.hasSupportedCustomConfigure; + const setupPlan = proposal.setupPlan; const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); const needsAttention = (id: string) => { const setupStep = setupPlan.steps.find((step) => step.id === id); From e82358461f9017041b2fa7bdf059289700619078 Mon Sep 17 00:00:00 2001 From: seanperez Date: Mon, 31 Aug 2026 19:03:06 -0400 Subject: [PATCH 55/55] docs(changeset): describe native iOS reconciliation --- .changeset/calm-apples-inspect.md | 5 ----- .changeset/ios-native-reconciliation.md | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 .changeset/calm-apples-inspect.md create mode 100644 .changeset/ios-native-reconciliation.md diff --git a/.changeset/calm-apples-inspect.md b/.changeset/calm-apples-inspect.md deleted file mode 100644 index af21bdb3a..000000000 --- a/.changeset/calm-apples-inspect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"clerk": minor ---- - -Add native iOS project inspection and setup support to `clerk init`: a strictly read-only `--dry-run` plan with structured JSON and a one-command fresh SwiftUI setup that links ClerkKit and ClerkKitUI, configures the selected application's development publishable key directly in the shipping `@main` initializer, injects `Clerk.shared` into the proven root view, and adds the exact Associated Domain. Eligible existing XML entitlements are updated in place; a modern target with one exclusive filesystem-synchronized source root can receive a new iOS-only entitlements file and SDK-qualified build settings. Local edits are previewed with the key redacted, committed through a rollback-aware transaction, and byte-idempotent on rerun. Existing custom `Clerk.configure(...)` sources remain unchanged and require explicit application selection; their backing values are not inspected or claimed to match. After separate consent, the authenticated flow also registers the exact iOS Bundle ID and App ID Prefix through the Platform API and enables Native API for the selected development instance, with conflict detection, idempotent retries, and post-write verification. The opt-in `--sign-in-with-apple` path safely adds the native Apple entitlement, enables the matching Clerk Apple connection for the exact Bundle ID, preserves hosted Apple configuration, and never requests or exposes Apple private credentials. The separate opt-in `--prebuilt-auth-ui` path can replace only a provably untouched SwiftUI starter screen with ClerkKitUI's `UserButton` and an `AuthView` sheet; established or partially integrated application UI is never rewritten. diff --git a/.changeset/ios-native-reconciliation.md b/.changeset/ios-native-reconciliation.md new file mode 100644 index 000000000..808a4ec63 --- /dev/null +++ b/.changeset/ios-native-reconciliation.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add native iOS application registration and optional AuthView and Sign in with Apple setup to `clerk init`.