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`. 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/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/index.test.ts b/packages/cli-core/src/commands/deploy/index.test.ts index 4ccd8c9d6..90eabbb5f 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,368 @@ 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 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" }, + }); + 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" }, + }); + 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 disabled native-only Apple without requesting hosted credentials", 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: false, + authenticatable: false, + 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 +1676,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..25f15100e 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,93 @@ 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 nativeConfiguration: ReturnType; + try { + const [iosApplications, nativeSettings] = await withSpinner( + "Checking production Native Application settings...", + async () => + Promise.all([ + listIOSApplications(ctx.appId, productionInstanceId), + 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 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.", + ); + } + + 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.", + ); + } + + 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.", + ); + } + + 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.", + ); +} + 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..cb3d0fb0c 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,21 @@ function descriptorByProvider( return descriptor; } +function iosApplication( + bundleId: string, + appIdPrefix = "ABCDE12345", + id = `ios_${appIdPrefix}_${bundleId}`, +): IOSApplication { + return { + object: "ios_application", + id, + app_id_prefix: appIdPrefix, + 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 +178,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 +206,166 @@ 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.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", + api_enabled: true, + }), + ).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"], + 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: 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( + { + 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: false, + 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..d118ea0c3 100644 --- a/packages/cli-core/src/commands/deploy/providers.ts +++ b/packages/cli-core/src/commands/deploy/providers.ts @@ -1,9 +1,15 @@ 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"; 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 +68,20 @@ export type OAuthProviderDescriptorResult = { unsupported: string[]; }; +export type NativeAppleConfiguration = + | { status: "not-apple" | "hosted-or-unconfigured" } + | { + status: + | "ready" + | "authentication-disabled" + | "registration-missing" + | "registration-bundle-case-mismatch" + | "registration-ambiguous" + | "native-api-disabled" + | "verification-unavailable"; + bundleId: string; + }; + type ProviderOverride = { credentialLabel?: string; redirectLabel?: string; @@ -212,6 +232,62 @@ 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 (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.enabled !== true || providerConfig.authenticatable !== true) { + return { status: "authentication-disabled", bundleId }; + } + + const registeredPrefixes = new Set( + iosApplications + .filter((application) => bundleIdentifiersEqual(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 }; + } + 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 }; +} + +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-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/commands/deploy/status.test.ts b/packages/cli-core/src/commands/deploy/status.test.ts index 62435c391..d79f4d15d 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,320 @@ 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("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) => + 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: "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"); + } + }); + + 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("reports disabled native Apple without reading native endpoints", async () => { + mockActiveProductionEnvironment(); + mockFetchInstanceConfig.mockImplementation((_appId: string, instanceId: string) => + instanceId === "ins_prod" + ? { + connection_oauth_apple: { + enabled: false, + authenticatable: false, + 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..40b29d686 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,17 @@ export interface DeployStatusReport { nextAction: string; } +type NativeAppleReadinessIssue = { + bundleId: string; + reason: + | "authentication-disabled" + | "registration-missing" + | "registration-bundle-case-mismatch" + | "registration-ambiguous" + | "native-api-disabled" + | "verification-unavailable"; +}; + export type LiveDeploySnapshot = Omit< DeployOperationState, "pending" | "oauthProviders" | "completedOAuthProviders" @@ -84,6 +98,7 @@ export type LiveDeploySnapshot = Omit< componentStatus: DeployComponentStatus; unsupportedOAuthProviderCount: number; unsupportedOAuthProviders: string[]; + nativeAppleReadinessIssue?: NativeAppleReadinessIssue; }; export type DeployState = @@ -214,8 +229,48 @@ 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)}`); + nativeAppleConfiguration = { + status: "verification-unavailable", + bundleId: preliminaryNativeAppleConfiguration.bundleId, + }; + } + } 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 +290,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 +451,7 @@ export function buildDeployStatusReport( snapshot.productionInstanceId ? domainsDashboardUrl(snapshot.appId, snapshot.productionInstanceId) : null, + snapshot.nativeAppleReadinessIssue, ), }; } @@ -426,13 +492,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 +531,66 @@ 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 === "registration-bundle-case-mismatch" || + 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 === "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}. ` + + "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/init/README.md b/packages/cli-core/src/commands/init/README.md index 5da4c01b3..9e061e537 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, and entitlements. It then prints an ordered setup plan with a top-level status of `ready`, `action-required`, or `blocked`. + +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. + +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. + +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`. + +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 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) +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 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)) ## 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 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. @@ -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 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. @@ -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; 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 2df6f4670..33ab974a1 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,33 @@ -import { 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-")); + +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 +59,175 @@ 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("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 }); + + 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("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 }); + 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"))).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); + 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("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 }); + 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(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, + ); + 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..84e0b808b 100644 --- a/packages/cli-core/src/commands/init/frameworks/ios.ts +++ b/packages/cli-core/src/commands/init/frameworks/ios.ts @@ -1,14 +1,17 @@ import type { FrameworkScaffold, ProjectContext, ScaffoldPlan } from "./types.js"; +import { inspectIOSProject } from "../ios/inspect.ts"; +import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "../ios/local-plan.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 custom key sources are + * preserved and require the developer to select their Clerk application. * * Docs: https://clerk.com/docs/ios/getting-started/quickstart */ @@ -19,14 +22,116 @@ export const ios: FrameworkScaffold = { matches: (ctx) => ctx.framework.dep === "ios", async scaffold(ctx: ProjectContext): Promise { + 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 = proposal.selectedTarget; + const productDecision = proposal.productDecision ?? "prebuilt"; + const includeClerkKitUI = productDecision === "prebuilt"; + 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); + return setupStep != null && setupStep.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] + : 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") + ? [ + 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()`", + ] + : []; 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, "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..3fb9ededa --- /dev/null +++ b/packages/cli-core/src/commands/init/index-ios.test.ts @@ -0,0 +1,1587 @@ +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, + iosDevelopmentKeyMod, + plapiMod, + fapiMod, + 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"; +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$")}`; + +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", + 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, + nativeAppleRequested: false, + requiresLinkedApp: false, + requiresDevelopmentKey: + overrides.requiresDevelopmentKey ?? overrides.requiresLinkedApp ?? false, + requiresExplicitApplication: false, + ...overrides, + }; +} + +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(); + + 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("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(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).not.toHaveBeenCalled(); + expect(iosApplyMod.applyIOSPlannedLocalSetup).not.toHaveBeenCalled(); + }); + + 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); + spyOn(config, "resolveProfile").mockResolvedValue(undefined); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: false, + requiresExplicitApplication: true, + }), + ); + + await expect(init({ yes: true })).rejects.toThrow( + "requires explicit Clerk application selection", + ); + + 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(); + 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: "CONFIRM123" }); + + expect(linkMod.link).toHaveBeenCalledWith({ + skipIfLinked: true, + app: undefined, + cwd: iosCtx.cwd, + createIfMissing: "AnotherPromptTest", + skipAutolink: true, + }); + expect(nativeRemoteMod.prepareIOSNativeRemoteSetup).toHaveBeenCalledWith( + expect.objectContaining({ + root: FAKE_IOS_NATIVE_READINESS.root, + appIdPrefix: "CONFIRM123", + 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(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_existing", + instanceId: "ins_existing", + publishableKey: VALID_DEVELOPMENT_KEY, + }); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan({ + applicationId: "app_existing", + instanceId: "ins_existing", + appIdPrefix: "REGIST1234", + 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({ + root: FAKE_IOS_NATIVE_READINESS.root, + 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(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); + }); + + 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 exactly 10 ASCII letters or numbers", + ); + + 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); + const recover = spyOn(iosFileTransactionMod, "recoverIOSFileTransactions").mockResolvedValue( + undefined, + ); + spyOn(scaffoldMod, "scaffold").mockResolvedValue({ + actions: [], + postInstructions: ["Add the Clerk iOS SDK via Swift Package Manager"], + }); + + await init({ yes: true }); + + expect(recover).toHaveBeenCalledWith(iosCtx.cwd); + 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(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).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(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + 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(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + 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(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + 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(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + 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(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + 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("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 = { + ...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(config, "resolveProfile") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockResolvedValue({ profile: { appId: "app_test" } } as never); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresDevelopmentKey: true, + }); + const preflightSpy = spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const linkSpy = spyOn(linkMod, "link").mockResolvedValue(undefined); + const resolveKeysSpy = spyOn( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + 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(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).toHaveBeenCalledWith("app_test"); + expect(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).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( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + 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", + root: setupResult.nativeReadiness.root, + 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({ bundleIdentifier: "com.Example.MyApp" }), + ); + 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 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(); + 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("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 () => { + const { captured } = 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, + ); + const sensitiveBearer = "Bearer ak_NATIVE_RECONCILIATION_TOKEN_MUST_NOT_ESCAPE"; + spyOn(nativeRemoteMod, "applyIOSNativeRemoteSetup").mockRejectedValue( + new Error(`remote mutation failed with ${sensitiveBearer}`), + ); + + 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 () => { + 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(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_linked" }, + } as never); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue( + iosSetupResult({ requiresLinkedApp: true, requiresDevelopmentKey: true }), + ); + spyOn(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_changed", + instanceId: "ins_changed", + 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(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"); + + 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(iosDevelopmentKeyMod, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_selected", + instanceId: "ins_selected", + 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("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("selected.clerk.example$").toString("base64")}`; + spyOn(context, "gatherContext").mockResolvedValue(iosCtx); + spyOn(config, "resolveProfile").mockResolvedValue({ + profile: { appId: "app_selected" }, + } as never); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresExplicitApplication: true, + }); + spyOn(iosApplyMod, "applyIOSLocalSetup").mockResolvedValue(setupResult); + const resolveKeys = spyOn( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_selected", + instanceId: "ins_selected", + publishableKey: linkedKey, + }); + spyOn(nativeRemoteMod, "prepareIOSNativeRemoteSetup").mockResolvedValue( + iosRemotePlan({ + applicationId: "app_selected", + instanceId: "ins_selected", + }), + ); + await init({ yes: true, app: "app_selected" }); + + expect(resolveKeys).toHaveBeenCalledTimes(1); + 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); + }); + + 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( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_requested", + instanceId: "ins_requested", + publishableKey: key, + }); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + requiresExplicitApplication: 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" }); + + 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, + requireExistingAppSelection: 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")}`; + 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( + iosDevelopmentKeyMod, + "resolveIOSDevelopmentPublicKey", + ).mockResolvedValue({ + applicationId: "app_requested", + instanceId: "ins_requested", + publishableKey: key, + }); + const setupResult = iosSetupResult({ + requiresLinkedApp: true, + 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" }); + + expect(resolveKeys).toHaveBeenCalledTimes(1); + expect(resolveKeys).toHaveBeenCalledWith("app_requested"); + 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(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 55b9c7099..2a2c3607c 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, + iosDevelopmentKeyMod, } 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,21 @@ 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(iosDevelopmentKeyMod.resolveIOSDevelopmentPublicKey).not.toHaveBeenCalled(); + expect(pullMod.pull).not.toHaveBeenCalled(); + }); + test("agent mode runs existing-project flow without prompts", async () => { setup({ isAgent: true }); spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); @@ -475,95 +493,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..083da12a1 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -11,6 +11,7 @@ import { CliError, ERROR_CODE, errorMessage, + isAuthError, } from "../../lib/errors.js"; import { lookupFramework, @@ -34,6 +35,7 @@ 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 { printNextSteps } from "../../lib/next-steps.js"; import { gatherContext, hasPackageJson } from "./context.js"; import { scaffold, enrichProjectContext } from "./scaffold.js"; @@ -61,6 +63,12 @@ import { } from "./bootstrap.js"; import type { ProjectContext } from "./frameworks/types.js"; import { type PackageManager, PACKAGE_MANAGERS } from "../../lib/package-manager.ts"; +import { validateAppIdPrefix } from "./ios/native-remote.ts"; +import { + prepareAppleNativeSetup, + runAppleNativeDryRun, + type AppleNativeSetupCoordinator, +} from "./ios/coordinator.ts"; type InitOptions = { /** Framework to set up (skips auto-detection). */ @@ -82,18 +90,62 @@ 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 +154,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 +175,58 @@ 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}.`, + ); + } + await runAppleNativeDryRun({ + root: ctx.cwd, + target: options.target, + signInWithApple: options.signInWithApple, + prebuiltAuthUI: options.prebuiltAuthUI, + machineOutput, + }); + return; + } + + setTelemetryStage("strategy"); + let appleNativeSetup: AppleNativeSetupCoordinator | undefined; + if (ctx.framework.dep === "ios") { + appleNativeSetup = await prepareAppleNativeSetup({ + root: ctx.cwd, + target: options.target, + yes: options.yes === true, + agent, + allowDirty: options.allowDirty === true, + signInWithApple: options.signInWithApple, + prebuiltAuthUI: options.prebuiltAuthUI, + requestedApplicationId: options.app, + validatedAgentAuthLabel, + validateAgentAuthentication, + }); + validatedAgentAuthLabel = appleNativeSetup.validatedAgentAuthLabel; + } + await enrichProjectContext(ctx); const optsKeyless = options.keyless === true; @@ -128,15 +238,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" + ? appleNativeSetup?.linkedProfile + : !optsKeyless && agent && authed && !options.app + ? await resolveProfile(ctx.cwd) + : undefined; + const hasRealAppTarget = Boolean( + options.app || linkedProfile || appleNativeSetup?.requiresLinkedApp, + ); const strategy = pickStrategy({ optsKeyless, @@ -150,13 +268,49 @@ export async function init(options: InitOptions = {}) { assertKeylessOnlyFlags(options, strategy); + let authenticatedAppId: string | undefined; + let appleNativeApplicationLinkChange: "created-and-linked" | "link-updated" | undefined; if (strategy === "authenticate") { setTelemetryStage("link"); + appleNativeSetup?.assertApplicationCreationReady({ + requestedApplicationId: options.app, + appIdPrefix: options.appIdPrefix, + }); bar(); - const createIfMissing = agent - ? await deriveProjectName(ctx.cwd, bootstrap?.projectName) + const mayCreateApplication = + agent && + (ctx.framework.dep !== "ios" || appleNativeSetup?.shouldCreateApplication(options.app)); + const createIfMissing = mayCreateApplication + ? await deriveProjectName(ctx.cwd, bootstrap?.projectName ?? appleNativeSetup?.targetName) : undefined; - await authenticateAndLink(ctx.cwd, options.app, createIfMissing); + const authenticated = await authenticateAndLink( + ctx.cwd, + options.app, + createIfMissing, + appleNativeSetup?.requiresLinkedApp === true, + appleNativeSetup?.requiresExplicitApplication === true, + appleNativeSetup?.preauthenticatedLabel, + ); + authenticatedAppId = authenticated.applicationId; + if (ctx.framework.dep === "ios") { + appleNativeApplicationLinkChange = authenticated.applicationLinkChange; + } + } + + 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 (appleNativeResult?.nativeAppleReady) { + ctx.iosNativeAppleReady = true; } // Short-circuit on a fully-clean re-run so env pull / skills prompt don't @@ -182,6 +336,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 +362,48 @@ 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 exactly 10 ASCII letters or numbers 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 +420,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( + "--template only applies to keyless applications, but iOS (Swift) does not support keyless mode. Drop --template.", + ); + } + if (options.fresh) { throwUsageError( - "--login requires an interactive terminal to complete the browser login. Ask the user to run `clerk auth login`, then re-run `clerk init`.", + "--fresh only applies to keyless applications, but iOS (Swift) does not support keyless mode. Drop --fresh.", ); } } @@ -240,13 +451,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 +570,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 +622,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 custom Clerk.configure(...) sources remain unchanged; select the existing Clerk application they belong to with --app .", + ]; + 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 +694,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 +708,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 +739,60 @@ async function authenticateAndLink( cwd: string, app: string | undefined, createIfMissing: string | undefined, -): Promise { - const label = await resolveAuthLabel(); + requireLinkedAppId: boolean, + requireExplicitApplication: boolean, + preauthenticatedLabel?: string, +): Promise<{ + applicationId?: string; + applicationLinkChange?: "created-and-linked" | "link-updated"; +}> { + const label = preauthenticatedLabel ?? (await resolveAuthLabel()); const profile = await resolveProfile(cwd); 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; + return { applicationId: 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 }), + ...(requireExplicitApplication && { requireExistingAppSelection: 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, + }); + } + 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 --- @@ -582,8 +894,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,10 +986,29 @@ 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 ", + "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( + "--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([ - { 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)", @@ -685,7 +1017,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", @@ -706,8 +1041,22 @@ 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 -y", description: "Skip all confirmation prompts" }, - { command: "clerk init --no-skills", description: "Skip the agent skills install prompt" }, + { + 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", + }, ]) .action(init); } 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..00c350d77 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.test.ts @@ -0,0 +1,403 @@ +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"); + 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(""); + 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("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"); + 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("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); + const path = join(root, "MyApp", "MyApp.entitlements"); + + const plan = await planIOSAppleEntitlement({ + ...planOptions(root), + allowMissingEntitlementsCreation: true, + }); + const prepared = await prepareIOSAppleEntitlementMutation(plan); + const result = await applyIOSAppleEntitlement(plan); + + expect(plan).toMatchObject({ + status: "ready", + 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); + 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(), + ); + 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, [ + () => 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..edb4020fe --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apple-entitlement.ts @@ -0,0 +1,816 @@ +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, + hashIOSFileBytes, + prepareIOSFileMutationBoundary, + type IOSCreateFileMutation, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.ts"; +import { + prepareIOSMissingEntitlementsSettingsMutation, + validateIOSMissingEntitlementsSettingsPostcondition, + 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"; +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 = 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); + 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.`, + ), + }; + } + 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( + "unreadable-entitlements", + `${relativeIOSPath(root, absolutePath)} could not be read as a UTF-8 XML plist dictionary.`, + ), + }; + } + return inspectEntitlementsBytes(root, absolutePath, file.bytes, file.mode); +} + +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; + } + 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 }; + } + } + + 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.", + ); + } + + 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) { + if (!isDeepStrictEqual(baseEntitlements.boundary, boundary)) { + 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, + boundary: baseEntitlements.boundary, + candidateBytes, + candidateHash: hashIOSFileBytes(candidateBytes), + }; + } else { + const candidateBytes = newEntitlementsBytes(); + createMutation = { + kind: "create", + path: entitlementsPath, + boundary, + 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 }; + const boundary = await prepareIOSFileMutationBoundary(plan.root, absolutePath); + if (!boundary || (base && !isDeepStrictEqual(base.boundary, boundary))) { + 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, + boundary: base?.boundary ?? boundary, + 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..0420254f2 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply-cli-runtime.test.ts @@ -0,0 +1,748 @@ +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 { build as buildPbxProject, 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 { + authFixtureKey, + canonicalSwiftUIFixture, + createIsolatedCLIState, + createUnconfiguredFixture, + developmentPublishableKey, + runCLI, + runCommand, + temporaryDirectories, +} from "./apply-cli.test-helpers.ts"; +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 () => { + 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("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, { + complete: true, + includeKey: false, + localSecrets: true, + clerkSDK: false, + }); + await Bun.write(join(root, "MyApp", "LocalSecrets.plist"), ""); + const before = await treeDigest(root); + + 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 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, { + 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 applyIOSPlannedLocalSetup(setup, expectedKey); + + expect(setup.requiresExplicitApplication).toBe(true); + expect(await treeDigest(root)).not.toEqual(before); + expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); + }); + + 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, { + 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 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, { + 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, + requiresExplicitApplication: true, + }); + 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 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, { + 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 applyIOSPlannedLocalSetup(setup, expectedKey, { + beforePostWriteValidation: async () => { + await Bun.write(schemePath, runSchemeSource(concurrentKey)); + }, + }); + + 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("does not serialize a custom Run-scheme key into its setup plan", async () => { + const expectedKey = developmentPublishableKey("clerk.example.test"); + const { root } = await createProcessInfoFixture(expectedKey); + const setup = await applyIOSLocalSetup({ + root, + target: "MyApp", + yes: true, + agent: false, + allowDirty: false, + }); + expect(setup.requiresExplicitApplication).toBe(true); + expect(JSON.stringify(setup)).not.toContain(expectedKey); + expect(`${captured.out}\n${captured.err}`).not.toContain(expectedKey); + }); + + 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, + requiresExplicitApplication: 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 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, { + 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 applyIOSPlannedLocalSetup(setup, expectedKey, { + beforePostWriteValidation: async () => { + await Bun.write(localSecretsPath, plist(concurrentKey)); + }, + }); + + 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); + }); + + 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("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..01b589c32 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply-cli.test.ts @@ -0,0 +1,1017 @@ +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 { ERROR_CODE } from "../../../lib/errors.ts"; +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("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); + 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("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 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.requiresExplicitApplication).toBe(false); + 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 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); + 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"); + 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()", + ); + 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"), + `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", + "app_ios_apply", + "--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", + "app_ios_apply", + "--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", + "--app", + "app_ios_apply", + "--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", "--app", "app_ios_apply"], + 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("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(); + 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, + ["--mode", "agent", "init", "--yes", "--target", "MyApp", "--app", "app_ios_apply"], + 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); + 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({ + 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(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")).text()).toBe(existingEnv); + 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", "--app", "app_ios_apply"], + 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 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, { + 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: true, + agent: true, + allowDirty: false, + }); + + expect(result.requiresExplicitApplication).toBe(true); + expect(confirmation).not.toHaveBeenCalled(); + expect(await treeDigest(root)).toEqual(before); + } finally { + confirmation.mockRestore(); + } + }); + + 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, { + complete: true, + includeKey: false, + localSecrets: true, + }); + 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.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 new file mode 100644 index 000000000..e07dce2c5 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/apply.ts @@ -0,0 +1,1201 @@ +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 { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; +import { + prepareIOSSDKInstallMutation, + validateIOSSDKInstallPostcondition, + type IOSSDKInstallPlan, + type PreparedIOSSDKInstallMutation, +} from "./install-sdk.ts"; +import { buildIOSSetupPlan } from "./plan.ts"; +import { + prepareIOSDirectConfigMutation, + validatePreparedIOSDirectConfig, + type IOSDirectConfigPlan, + type IOSDirectConfigPreparedMutation, +} from "./direct-config.ts"; +import { + applyIOSFileTransaction, + type IOSExistingFileMutation, + type IOSFileMutation, +} from "./file-transaction.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 { + planIOSPrebuiltAuth, + prepareIOSPrebuiltAuthMutation, + validatePreparedIOSPrebuiltAuth, + type IOSPrebuiltAuthPlan, + type PreparedIOSPrebuiltAuthMutation, +} from "./prebuilt-auth.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 }); +} + +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; +} + +export type IOSLocalSetupResult = Pick< + IOSLocalSetupProposal, + | "setupPlan" + | "nativeReadiness" + | "unverifiedAppIdPrefixSuggestion" + | "sdkInstallPlan" + | "directConfigPlan" + | "associatedDomainPlan" + | "appleEntitlementPlan" + | "prebuiltAuthPlan" + | "prebuiltAuthAppleEntitlementPlan" + | "prebuiltAuthRequested" + | "prebuiltAuthActive" + | "nativeAppleRequested" +> & { + targetName: string; + /** 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 { + 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"); +} + +async function validatePrebuiltAuthRuntimePostcondition( + setup: IOSLocalSetupResult, +): 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, + 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); + const configureStep = setupPlan.steps.find((step) => step.id === "configure-publishable-key"); + const environmentStep = setupPlan.steps.find((step) => step.id === "inject-clerk-environment"); + return configureStep?.status === "satisfied" && 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, + 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.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + 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 = context.selectedTarget; + if (!selectedTarget) { + throw iosSetupError( + "The selected iOS target could not be resolved safely.", + ERROR_CODE.IOS_TARGET_UNRESOLVED, + ); + } + 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 proposal = await buildIOSLocalSetupProposal(context, { + root: options.root, + 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, + }), + } + : {}), + }); + 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( + `The prebuilt AuthView flow could not be added safely. No local files were changed:\n${blockerList( + inspectedPrebuiltAuthPlan.blockers, + )}`, + ); + } + 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, + ); + } + 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, + ); + } + 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, + )}`, + ); + } + 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 + ) { + throw iosSetupError( + "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 (hasCustomConfigure && !hasSupportedCustomConfigure) { + throw iosSetupError( + "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) { + 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${prebuiltRuntimeBlockers + .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 (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" || + 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 || 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 (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 (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}`); + 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 { + ...proposal, + targetName: selection.targetName, + requiresLinkedApp: true, + requiresDevelopmentKey: + directConfigPlan != null || associatedDomainPlan?.requiresPublishableKey === true, + requiresExplicitApplication: + hasSupportedCustomConfigure || directConfigPlan?.changes?.configuration === "verify-existing", + }; +} + +function directFileMutation( + prepared: Extract, +): IOSExistingFileMutation { + return { + path: prepared.mutation.absolutePath, + boundary: prepared.mutation.boundary, + 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, + boundary: prepared.mutation.boundary, + originalBytes: prepared.mutation.originalBytes, + originalHash: prepared.mutation.expectedHash, + candidateBytes: prepared.mutation.candidateBytes, + candidateHash: prepared.mutation.candidateHash, + mode: prepared.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 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; +} + +function requireDevelopmentKey( + setup: IOSLocalSetupResult, + publishableKey: string | undefined, +): string { + const planNeedsKey = Boolean( + setup.directConfigPlan || 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, + ); + } + const runtimePlans = [setup.directConfigPlan].filter((plan) => plan != null); + 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 custom key sources are preserved and + * are never rewritten or interpreted. + */ +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, + 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); + 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); + + 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)); + } + 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 (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 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)] : []), + ...(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)] + : []), + ]; + if (options.beforePostWriteValidation) { + postconditions.push(async () => { + await options.beforePostWriteValidation?.(); + return true; + }); + } + 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 (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}`); + } + } + + if (localMutations.length === 0) await options.beforePostWriteValidation?.(); +} 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..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"; @@ -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( @@ -1019,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/build-settings.test.ts b/packages/cli-core/src/commands/init/ios/build-settings.test.ts index 0609f0864..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 @@ -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,50 @@ 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" }, + swift: { + sourceFilesScanned: 0, + evidenceComplete: true, + entryPoints: [], + importsClerkKit: [], + importsClerkKitUI: [], + configureCalls: [], + appRootEvidence: [], + environmentInjections: [], + rootEnvironmentInjections: [], + environmentConsumers: [], + authFlowReferences: [], + openURLHandlers: [], + status: "absent", + }, + }, + ], + selection: { + state: "selected", + targetId: "target", + targetName: "Example", + projectPath: "Example.xcodeproj", + }, + localPublishableKey: { state: "missing" }, + 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/compiled-cli.test.ts b/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts new file mode 100644 index 000000000..18cd3bdeb --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/compiled-cli.test.ts @@ -0,0 +1,144 @@ +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, + }); + const publishableKey = `pk_test_${Buffer.from("clerk.example.test$").toString("base64")}`; + await Bun.write( + join(fixtureRoot, "MyApp", "LocalSecrets.plist"), + `CLERK_PUBLISHABLE_KEY${publishableKey}`, + ); + 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: "satisfied", + automatable: false, + }), + ); + 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 }); + } +}); 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..037420f07 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/coordinator.ts @@ -0,0 +1,462 @@ +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 { type IOSLocalSetupResult } from "./apply.ts"; +import { createIOSDryRunOutput, formatIOSSetupPlan } from "./output.ts"; +import { buildIOSLocalSetupProposal, createIOSLocalSetupContext } from "./local-plan.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 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, + nativeReadiness: proposal.nativeReadiness, + }), + null, + 2, + ), + ); + } else { + log.info( + formatIOSSetupPlan(inspection, plan, { + associatedDomainPlan, + nativeReadiness: proposal.nativeReadiness, + }), + ); + 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 }); +} 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/commands/init/ios/direct-config.test.ts b/packages/cli-core/src/commands/init/ios/direct-config.test.ts index 9f3d5c66e..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( @@ -340,6 +372,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( @@ -754,6 +814,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 9f903b127..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,17 @@ 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, + type SwiftUIAppRootStructure, + type SwiftUIRootExpression, +} from "./swift-app-root.ts"; import { sanitizeSwiftSourceWithStatus } from "./swift.ts"; const MAX_SWIFT_FILE_BYTES = 1_000_000; @@ -145,10 +155,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 +239,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 +585,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 +639,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 +650,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 +662,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 +770,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 +813,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 +822,7 @@ function parseAppStructure( }, }; } - const body = bodyRange(sanitized, appType, structuralIndex); - if (!body) { + if (appRootInspection.status === "unsupported-body") { return { blocker: { code: "unsupported-scene", @@ -1055,8 +830,7 @@ function parseAppStructure( }, }; } - const root = windowGroupRoot(sanitized, body); - if (!root) { + if (appRootInspection.status === "unsupported-scene") { return { blocker: { code: "unsupported-scene", @@ -1065,6 +839,8 @@ function parseAppStructure( }, }; } + const appRoot = appRootInspection.structure; + const { appType, body, root } = appRoot; const initializerMatches = initializerCandidates(sanitized, appType, structuralIndex); if ( @@ -1166,7 +942,7 @@ function parseAppStructure( }; } - const environment = exactEnvironmentModifier(sanitized, root); + const environment = appRoot.clerkEnvironment; if (environment.conflicting) { return { blocker: { @@ -1311,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/dry-run.test.ts b/packages/cli-core/src/commands/init/ios/dry-run.test.ts new file mode 100644 index 000000000..c0c9fdac6 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/dry-run.test.ts @@ -0,0 +1,796 @@ +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, 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); + 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: "action-required", + inspection: { platform: "ios", selection: { state: "selected", targetName: "MyApp" } }, + plan: { kind: "clerk-ios-setup", status: "action-required" }, + 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("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); + 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); + + 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(output.nativeReadiness.associatedDomain.expectedDomain).toBeUndefined(); + 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("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); + 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("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); + 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 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 }); + 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: "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); + }); + + 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 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); + 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/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 14f777d82..3177bc4cc 100644 --- a/packages/cli-core/src/commands/init/ios/inspect.ts +++ b/packages/cli-core/src/commands/init/ios/inspect.ts @@ -93,7 +93,9 @@ function emptySwiftInspection() { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], + appRootEvidence: [], environmentInjections: [], + rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], openURLHandlers: [], @@ -1285,17 +1287,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({ @@ -1319,7 +1336,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) { @@ -1381,6 +1398,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 { + 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/native-apple.test.ts b/packages/cli-core/src/commands/init/ios/native-apple.test.ts new file mode 100644 index 000000000..7e4fd7230 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-apple.test.ts @@ -0,0 +1,851 @@ +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 | null = 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; + version?: string | null; + failFetch?: unknown; + failDryRun?: unknown; + failActual?: unknown; + malformedDryRun?: boolean; + replaceProjection?: boolean; + dryRunProjectionOverride?: Record; + actualProjectionOverride?: Record; + persistedActualState?: AppleConnection; + 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 === null ? undefined : (options.version ?? CONFIG_VERSION); + let writes = 0; + const calls: string[] = []; + const patchCalls: PatchCall[] = []; + + const api: IOSNativeAppleAPI = { + 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 ?? null); + }, + 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 !== 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; + 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 = options.persistedActualState + ? structuredClone(options.persistedActualState) + : 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("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 }), + 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 }), + }); + 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 }); + 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("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(); + 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("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(), { + 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("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(), { + 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("requires a config version for writes but allows a versionless no-op", () => { + 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("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({ + 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..0e374e0de --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-apple.ts @@ -0,0 +1,744 @@ +import { isDeepStrictEqual } from "node:util"; +import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; +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}$/; +const NATIVE_APPLE_PATCH_FIELDS = new Set(["enabled", "authenticatable", "bundle_id"]); + +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-config-version-unavailable" + | "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; + +const preservedAppleFieldFingerprints = new WeakMap< + IOSNativeApplePlan, + ReadonlyMap +>(); + +export interface IOSNativeApplePatchOptions { + dryRun: boolean; + /** Required for every mutation attempt, including the server dry run. */ + ifMatch: string; +} + +export interface IOSNativeAppleAPI { + 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 = { + 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 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 }; +} + +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 && + !bundleIdentifiersEqual(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 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 = + 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.`, + ] + : []; + + const plan: IOSNativeApplePlan = { + 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, + }; + const fingerprints = preservedFieldFingerprints(options.config); + if (fingerprints) preservedAppleFieldFingerprints.set(plan, fingerprints); + return plan; +} + +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(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, + 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.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 or changed 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" + : bundleIdentifiersEqual(before.bundleIdentifier, bundleIdentifier) + ? "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(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 && + bundleIdentifiersEqual(current.bundleIdentifier, approved.bundleIdentifier) + ); +} + +function planVersionMatches(approved: IOSNativeApplePlan, current: IOSNativeApplePlan): boolean { + 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 || + (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, + ); + } + 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" || !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, + ); + } + 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..ff7ffd9b2 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-readiness.test.ts @@ -0,0 +1,378 @@ +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 }); +} + +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 }))); +}); + +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: "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", + reason: "dry-run-does-not-read-remote-state", + 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", + 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 inspectionWithInlineKey(); + 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 inspectionWithInlineKey(); + 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("does not satisfy readiness with a differently cased service token", async () => { + const inspection = await inspectionWithInlineKey(); + 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 inspectionWithInlineKey(); + 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 inspectionWithInlineKey(); + 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("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!; + 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..852da0c9c --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-readiness.ts @@ -0,0 +1,364 @@ +import { associatedDomainMatches, type IOSAssociatedDomainPlan } from "./associated-domain.ts"; +import { buildIOSSetupPlan } from "./plan.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", + 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 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( + 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 = resolvedBundleIdentifiers(target); + 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.state === "valid" + ? inspection.localPublishableKey.frontendApiHost + : undefined; + 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) => + associatedDomainMatches(domain, expectedDomain), + ), + ); + // 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, + })) ?? []; + const plannedExpectedDomain = + associatedDomainPlan?.requiresPublishableKey === true + ? undefined + : (associatedDomainPlan?.expectedDomain ?? expectedDomain); + return { + status, + expectedDomain: plannedExpectedDomain, + 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-registration-retry.test.ts b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts new file mode 100644 index 000000000..46911b105 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + IOSNativeRegistrationRetryLockError, + 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("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); + const target = identity(); + const first = await store.getOrCreate(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); + 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"); + }); + + 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, + 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-"); + 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 new file mode 100644 index 000000000..4f9d35973 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-registration-retry.ts @@ -0,0 +1,374 @@ +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, 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"; + +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; +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; + instanceId: string; + bundleIdentifier: string; + appIdPrefix: string; +} + +export interface IOSNativeRegistrationRetryStore { + getOrCreate(identity: IOSNativeRegistrationRetryIdentity): Promise; + peek(identity: IOSNativeRegistrationRetryIdentity): Promise; + 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"; + 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: normalizeBundleIdentifierIdentity(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 lockPath(baseDirectory: string, identity: IOSNativeRegistrationRetryIdentity): string { + 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, + 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 IOSNativeRegistrationRetryLockError( + stale ? "stale" : "busy", + publicLockPath(baseDirectory, path), + ); + } + 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, +): 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 && + typeof record.bundleIdentifier === "string" && + bundleIdentifiersEqual(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: normalizeBundleIdentifierIdentity(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, + 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 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 () => + withIdentityLock(baseDirectory, identity, lockOptions, async () => + getOrCreateRetryKey(baseDirectory, identity), + ), + ); + }, + async peek(identity) { + const baseDirectory = resolveBaseDirectory(); + const path = retryPath(baseDirectory, identity); + 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 () => + withIdentityLock(baseDirectory, identity, lockOptions, async () => + clearRetryKey(baseDirectory, identity, expectedKey), + ), + ); + }, + }; +} + +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 new file mode 100644 index 000000000..f94b8485f --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-remote.test.ts @@ -0,0 +1,1712 @@ +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 { + applyIOSNativeRemoteSetup, + buildIOSNativeRemotePlan, + prepareIOSNativeRemoteSetup, + validateAppIdPrefix, + validateBundleIdentifier, + type IOSNativeRemoteAPI, + type IOSNativeRemotePlan, + type IOSNativeRemotePrompts, + type IOSNativeRemoteTargetReader, + type IOSNativeRemoteTargetSnapshot, +} from "./native-remote.ts"; +import { + validateNativeSettings, + type IOSApplication, + type NativeSettings, +} from "../../../lib/plapi.ts"; +import { + IOSNativeRegistrationRetryLockError, + type IOSNativeRegistrationRetryIdentity, + type IOSNativeRegistrationRetryStore, +} from "./native-registration-retry.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 IOS_ROOT = "/tmp/NativeApp"; + +const captured = useCaptureLog(); + +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, + 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 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; + appIdPrefix?: string | null; + appIdPrefixCandidates?: string[]; + projectPath?: string; + targetId?: string; + } = {}, +): IOSNativeReadinessTarget { + const appIdPrefix = options.appIdPrefix === undefined ? LOCAL_PREFIX : options.appIdPrefix; + return { + status: "selected", + projectPath: options.projectPath ?? "NativeApp.xcodeproj", + targetId: options.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 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 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 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) { + 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"; + 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", + status: + options.nativeApi === "satisfied" && options.registration === "satisfied" + ? "satisfied" + : "ready", + applicationId: APPLICATION_ID, + instanceId: INSTANCE_ID, + localTarget: targetSnapshot(localTarget), + 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[]; + registrationIdempotencyKeys: string[]; +} { + const calls: string[] = []; + const registrationIdempotencyKeys: 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, + registrationIdempotencyKeys, + 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-"); + registrationIdempotencyKeys.push(mutationOptions.idempotencyKey); + 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, + root: IOS_ROOT, + 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 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")).toBeUndefined(); + 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)], + 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("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("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", + 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 () => { + const exactRegistration = registration(); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true)], + registrationReads: [[exactRegistration]], + }); + let inspections = 0; + + const result = await prepareIOSNativeRemoteSetup(prepareOptions({ yes: false }), { + api, + prompts: prompts(), + }); + + expect(result).toMatchObject({ + 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: [], + blockers: [], + }); + await applyRemoteSetup(result, api, async (snapshot) => { + inspections += 1; + return approvedTargetReader(snapshot); + }); + expect(inspections).toBe(1); + 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: "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", + 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(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.", + }); + + 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("offers an unverified Xcode suggestion in agent mode instead of prompting", async () => { + const { api } = scriptedAPI({ + nativeReads: [nativeSettings(false)], + registrationReads: [[]], + }); + + 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], + }), + 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", () => { + 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("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", + 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 applyRemoteSetup( + plan({ nativeApi: "required", registration: "required" }), + api, + approvedTargetReader, + ); + + 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( + applyRemoteSetup( + 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(applyRemoteSetup(approved, api, async () => current)).rejects.toMatchObject({ + code: ERROR_CODE.IOS_SETUP_STALE, + message: expect.stringContaining("Xcode target identity changed"), + }); + + expect(calls).toEqual([]); + 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( + applyRemoteSetup( + 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([]); + 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( + applyRemoteSetup(plan({ nativeApi: "required", registration: "satisfied" }), api, async () => + selectedTarget({ bundleIdentifier: "com.example.Changed" }), + ), + ).rejects.toMatchObject({ code: ERROR_CODE.IOS_SETUP_STALE }); + + expect(calls).toEqual([]); + 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 applyRemoteSetup(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("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({ + nativeReads: [nativeSettings(false), nativeSettings(true)], + registrationReads: [[], [exactRegistration]], + }); + + await applyRemoteSetup( + plan({ nativeApi: "required", registration: "required" }), + api, + approvedTargetReader, + ); + + expect(calls.indexOf("POST iOS registration")).toBeGreaterThan(-1); + expect(calls.indexOf("POST iOS registration")).toBeLessThan( + calls.indexOf("PATCH native settings"), + ); + }); + + 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"); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(true), nativeSettings(true)], + registrationReads: [[], [exactRegistration], [exactRegistration]], + create: async () => { + throw ambiguousError; + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "satisfied", registration: "required" }), + api, + approvedTargetReader, + ), + ).resolves.toBeUndefined(); + 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({ + 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("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; + 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"); + const { api, calls } = scriptedAPI({ + nativeReads: [nativeSettings(false), nativeSettings(true), nativeSettings(true)], + registrationReads: [[exactRegistration], [exactRegistration]], + enable: async () => { + throw ambiguousError; + }, + }); + + await expect( + applyRemoteSetup( + plan({ nativeApi: "required", registration: "satisfied" }), + api, + approvedTargetReader, + ), + ).resolves.toBeUndefined(); + 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)], + registrationReads: [[], []], + }); + + await expect( + applyRemoteSetup( + 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"), + }); + }); + + 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; + const previousLogLevel = getLogLevel(); + try { + 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 new file mode 100644 index 000000000..eb2b9e038 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/native-remote.ts @@ -0,0 +1,1039 @@ +import { randomUUID } from "node:crypto"; +import { bundleIdentifiersEqual } from "../../../lib/apple-native-identity.ts"; +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 { select } from "../../../lib/listage.ts"; +import { + createIOSApplication, + enableNativeApi, + getNativeSettings, + listIOSApplications, + validateIOSApplication, + validateIOSApplications, + validateNativeSettings, + type IOSApplication, + type NativeSettings, +} from "../../../lib/plapi.ts"; +import { confirm, text } from "../../../lib/prompts.ts"; +import { withSpinner } from "../../../lib/spinner.ts"; +import { hasIncompleteIOSContainerDiscovery, inspectIOSProject } from "./inspect.ts"; +import type { + IOSNativeReadinessTarget, + IOSUnverifiedAppIdPrefixSuggestion, +} from "./native-readiness.ts"; +import { buildIOSNativeReadinessAudit } from "./native-readiness.ts"; +import { + IOSNativeRegistrationRetryLockError, + cliStateIOSNativeRegistrationRetryStore, + type IOSNativeRegistrationRetryIdentity, + type IOSNativeRegistrationRetryStore, +} from "./native-registration-retry.ts"; + +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, + 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; +} + +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.`); +} + +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" + | "bundle-identifier-invalid" + | "app-id-prefix-required" + | "app-id-prefix-invalid" + | "app-id-prefix-conflict" + | "duplicate-bundle-registration"; + +export interface IOSNativeRemoteBlocker { + code: IOSNativeRemoteBlockerCode; + 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"; + 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; + root: string; + 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; +} + +export type IOSNativeRemoteAppIdPrefixSuggestion = + | IOSUnverifiedAppIdPrefixSuggestion + | { source: "partial-literal-entitlements"; value: string }; + +export type IOSNativeRemoteTargetReader = ( + snapshot: IOSNativeRemoteTargetSnapshot, +) => Promise; + +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) != 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 () => + 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 && 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.split(".").every((component) => component.length > 0) + ? value + : 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, + exhaustiveContainerDiscovery: true, + }); + if (hasIncompleteIOSContainerDiscovery(inspection)) { + return { status: "blocked", reason: "target-not-selected" }; + } + return buildIOSNativeReadinessAudit(inspection).target; +}; + +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 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, with no empty dot-separated components.`, + ), + ); + } + + const appIdPrefixCandidates = + target.appIdPrefix.status === "resolved" + ? [target.appIdPrefix.value] + : target.appIdPrefix.status === "conflicting" + ? target.appIdPrefix.candidates + : (target.appIdPrefix.candidates ?? []); + 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( + "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" && + validateAppIdPrefix(target.appIdPrefix.value) === target.appIdPrefix.value + ? target.appIdPrefix.value + : undefined, + appIdPrefixCandidates, + blockers, + }; +} + +export function buildIOSNativeRemotePlan(options: { + applicationId: string; + instanceId: string; + root?: string; + target: IOSNativeReadinessTarget; + requestedAppIdPrefix?: string; + nativeSettings: NativeSettings; + registrations: IOSApplication[]; +}): IOSNativeRemotePlan { + const nativeSettings = validateNativeSettings(options.nativeSettings); + const registrations = validateIOSApplications(options.registrations); + const identity = localIdentity(options.target); + const blockers = [...identity.blockers]; + const localBundleIdentifier = identity.bundleIdentifier; + const explicitPrefix = validateAppIdPrefix(options.requestedAppIdPrefix); + if (options.requestedAppIdPrefix != null && !explicitPrefix) { + blockers.push( + blocker( + "app-id-prefix-invalid", + `The supplied Apple App ID Prefix must contain exactly ${APP_ID_PREFIX_LENGTH} ASCII letters or numbers.`, + ), + ); + } + 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 ${localBundleIdentifier ?? "the selected target"}.`, + ), + ); + } + + 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, + ); + 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"; + + 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) { + 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 = 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, + localTarget: copyTargetSnapshot(options.root, options.target), + 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: validateNativeSettings(nativeSettings), + registrations: validateIOSApplications(registrations), + }; +} + +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: { + 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) { + 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.`, + ERROR_CODE.IOS_REMOTE_VERIFY_FAILED, + ); + } + let plan = buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + root: options.root, + 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) { + const suggestion = appIdPrefixSuggestion( + options.target, + options.unverifiedAppIdPrefixSuggestion, + ); + if (options.agent) { + throwUsageError( + agentAppIdPrefixRequiredMessage( + plan.bundleIdentifier!, + suggestion, + options.applicationLinkChange, + ), + ); + } + const appIdPrefix = await prompts.appIdPrefix(plan.bundleIdentifier!, suggestion); + plan = buildIOSNativeRemotePlan({ + applicationId: options.applicationId, + instanceId: options.instanceId, + root: options.root, + target: options.target, + requestedAppIdPrefix: appIdPrefix, + ...state, + }); + } + + if (plan.status === "blocked") { + throw iosRemoteError( + `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, + ); + } + + 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); + const reconciled = 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, + }); + 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" || + !bundleIdentifiersEqual(approved.bundleIdentifier.value, plan.bundleIdentifier) || + current.status !== "selected" || + current.projectPath !== approved.projectPath || + current.targetId !== approved.targetId || + current.bundleIdentifier.status !== "resolved" || + !bundleIdentifiersEqual(current.bundleIdentifier.value, plan.bundleIdentifier) + ) { + return false; + } + return prefixEvidenceMatchesApprovedIdentity( + approved.appIdPrefix, + current.appIdPrefix, + plan.appIdPrefix, + ); +} + +async function revalidateLocalTargetBeforeRemoteAccess( + 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 { + 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, + ); + } + + 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( + approved: IOSNativeRemotePlan, + current: IOSNativeRemotePlan, +): boolean { + if ( + current.status === "blocked" || + current.applicationId !== approved.applicationId || + current.instanceId !== approved.instanceId || + !bundleIdentifiersEqual(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; +} + +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( + "The approved Clerk Native Application plan is incomplete. No remote changes were made; rerun clerk init.", + ERROR_CODE.IOS_SETUP_PLAN_INVALID, + ); + } + + // 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); + let observedRegistrationRetryKey: string | undefined; + + if (retryIdentity) { + try { + observedRegistrationRetryKey = + plan.registration === "required" + ? await registrationRetryStore.getOrCreate(retryIdentity) + : await registrationRetryStore.peek(retryIdentity); + } 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.", + ); + } + } + + // 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 () => + reconciledPlan(plan, api), + ); + } catch (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.", + 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, + ); + } + + // 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, + ); + } + if (!observedRegistrationRetryKey) { + throw iosRemoteError( + "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 { + 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 ( + !bundleIdentifiersEqual(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) { + 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 = validateIOSApplications( + await api.listIOSApplications(plan.applicationId, plan.instanceId), + ); + } catch (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.", + ); + } + const exact = registrations.some( + (registration) => + bundleIdentifiersEqual(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") { + let enableError: unknown; + let enabledResponse: unknown; + let enableCompleted = false; + try { + 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) { + 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, + ); + } + } + + if (enableError) { + let current: NativeSettings; + try { + current = validateNativeSettings( + await api.getNativeSettings(plan.applicationId, plan.instanceId), + ); + } catch (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.", + ); + } + if (!current.api_enabled) { + 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.", + ); + } + } + 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) { + 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.", + 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, + ); + } + if (retryIdentity && observedRegistrationRetryKey) { + try { + 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) { + 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, + ); + } + } +} 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..efbc3f316 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/output.ts @@ -0,0 +1,139 @@ +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: "✓", + 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; + /** Exact readiness audit from the shared local setup proposal. */ + nativeReadiness?: IOSNativeReadinessAudit; +} + +export function createIOSDryRunOutput( + inspection: IOSProjectInspectionResult, + plan: IOSSetupPlan, + options: IOSOutputOptions = {}, +): IOSDryRunOutput { + return { + schemaVersion: 1, + mode: "read-only", + status: plan.status, + inspection, + plan, + nativeReadiness: options.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}`, + ); + } + const localPublishableKey = inspection.localPublishableKey; + if (localPublishableKey.state === "valid") { + lines.push( + ` Publishable key: found (${localPublishableKey.instanceType}; ${localPublishableKey.frontendApiHost})`, + ); + } else if (selected && hasSupportedIOSCustomConfigure(selected)) { + lines.push(" Publishable key: custom source (value not inspected)"); + } else { + const keyStatus = + localPublishableKey.state === "invalid" + ? "invalid inline key" + : localPublishableKey.state === "unproven" + ? "configuration needs review (value not inspected)" + : "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 = + options.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..e08a676c2 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plan.test.ts @@ -0,0 +1,859 @@ +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 { 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 while preserving a custom project key source", 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", + "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("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"); + expect(plan.steps.find((step) => step.id === "register-native-application")?.status).toBe( + "review", + ); + 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); + 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([ + { + inlinePublishableKey: undefined, + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "custom", + startupBinding: "app-init", + }, + ]); + expect(inspection.localPublishableKey.state).toBe("unproven"); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + }); + + 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: "custom", + startupBinding: "unproven", + }); + + 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 () => { + 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("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("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); + 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", + ); + }); + + 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 }); + 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 === "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("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 }); + 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) + } + } + } + func send(_ signIn: SignIn) async throws { try await signIn.sendEmailLink() }`, + ); + + const plan = buildIOSSetupPlan(await inspectIOSProject(root)); + expect(plan.steps.find((step) => step.id === "add-authentication-flow")).toMatchObject({ + status: "satisfied", + automatable: false, + }); + }); + + 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, + }); + }); + + 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 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 }); + 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); + const plan = buildIOSSetupPlan(inspection); + + expect(inspection.appTargets[0]?.swift.configureCalls[0]).toMatchObject({ + publishableKeyWiring: "custom", + startupBinding: "unproven", + }); + expect(plan.steps.find((step) => step.id === "configure-publishable-key")).toMatchObject({ + status: "review", + automatable: false, + }); + }); + + 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"); + inspection.appTargets[0]!.swift.configureCalls = [ + { + path: "MyApp/MyAppApp.swift", + publishableKeyWiring: "custom", + startupBinding: "app-init", + }, + ]; + + const plan = buildIOSSetupPlan(inspection); + + expect(plan.steps.find((step) => step.id === "configure-publishable-key")?.status).toBe( + "satisfied", + ); + expect(inspection.localPublishableKey.state).toBe("unproven"); + }); + + 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 }); + 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: "satisfied", + 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("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( + "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 }); + 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 = [ + "webcredentials:native.clerk.example?mode=developer", + ]; + } + + const plan = buildIOSSetupPlan(inspection); + + 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 }); + 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 = ["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 }); + + 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..88db89e62 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/plan.ts @@ -0,0 +1,522 @@ +import type { + IOSAppTarget, + IOSProjectInspectionResult, + IOSSetupPlan, + IOSSetupStep, + IOSSetupStepStatus, + IOSSourceEvidence, + IOSValueResolution, +} from "./types.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 { 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"; +const NATIVE_APPLE_URL = + "https://clerk.com/docs/ios/guides/configure/auth-strategies/sign-in-with-apple"; + +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 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( + 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 }; +} + +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; + /** 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"], + ["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 oneStartupConfigure = + target.swift.evidenceComplete && + !sourceEntryPointIsAmbiguous && + target.swift.configureCalls.length === 1 && + 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 = + oneStartupConfigure && + 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 directConfigBlocker = directConfigBlocked + ? options.directConfigPlan?.blockers.map((blocker) => blocker.message).join(" ") + : undefined; + const configuredStatus: IOSSetupStepStatus = publishableKeyBlocked + ? "blocked" + : directConfigBlocked + ? "blocked" + : configured + ? inlineConfigureValid || customConfigureReady + ? "satisfied" + : "review" + : directConfigAutomationReady + ? "required" + : target.swift.evidenceComplete + ? "required" + : "review"; + steps.push( + step( + "configure-publishable-key", + "Configure Clerk with a publishable key", + configuredStatus, + 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, + ), + ); + + 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 = + directConfigPlanApplies && + options.directConfigPlan?.status === "ready" && + options.directConfigPlan.changes?.environment === "insert"; + const directEnvironmentBlocked = !injected && requiresSwiftUIEnvironment && directConfigBlocked; + const injectedStatus: IOSSetupStepStatus = injected + ? "satisfied" + : directEnvironmentBlocked + ? "blocked" + : requiresSwiftUIEnvironment + ? target.swift.evidenceComplete && provenAppRoot && !hasUnprovenInjection + ? "required" + : "review" + : "review"; + steps.push( + step( + "inject-clerk-environment", + "Inject Clerk into the SwiftUI environment", + injectedStatus, + injected + ? "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."}` + : 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 bundleIdentifiers = distinctResolvedBundleIdentifiers(target); + 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.state === "valid" + ? `webcredentials:${inspection.localPublishableKey.frontendApiHost}` + : undefined; + const expectedDomainIsSelectedTargetRuntime = inlineConfigureValid; + 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.` + : "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..4abff2c42 --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.test.ts @@ -0,0 +1,414 @@ +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)); + const prepared = await prepareIOSPrebuiltAuthMutation(plan); + + 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!"); + 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([ + { + 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")).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); + 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..a4e9c74cc --- /dev/null +++ b/packages/cli-core/src/commands/init/ios/prebuilt-auth.ts @@ -0,0 +1,837 @@ +import { lstat, readFile } from "node:fs/promises"; +import { basename, dirname, relative, resolve } from "node:path"; +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 { + hasIncompleteIOSContainerDiscovery, + 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; + boundary: IOSFileMutationBoundary; + 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, + 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 || + 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, + boundary: IOSFileMutationBoundary, +): IOSPrebuiltAuthFileMutation { + const mutation = { + absolutePath: snapshot.absolutePath, + expectedHash: snapshot.hash, + candidateHash: hashIOSFileBytes(candidateBytes), + mode: snapshot.mode, + } as IOSPrebuiltAuthFileMutation; + Object.defineProperties(mutation, { + boundary: { value: boundary, enumerable: false }, + 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 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({ + 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, + boundary: mutation.boundary, + 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/ios/products.test.ts b/packages/cli-core/src/commands/init/ios/products.test.ts index 4f89e80de..ef9aa0186 100644 --- a/packages/cli-core/src/commands/init/ios/products.test.ts +++ b/packages/cli-core/src/commands/init/ios/products.test.ts @@ -21,7 +21,9 @@ function target(): IOSAppTarget { importsClerkKit: [], importsClerkKitUI: [], configureCalls: [], + appRootEvidence: [], environmentInjections: [], + rootEnvironmentInjections: [], environmentConsumers: [], authFlowReferences: [], openURLHandlers: [], 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/ios/swift-app-root.ts b/packages/cli-core/src/commands/init/ios/swift-app-root.ts new file mode 100644 index 000000000..e991f203b --- /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 }; +} + +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; + 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); + 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*Clerk\s*\.\s*shared\s*$/.test(argumentsSource)) { + found = true; + } else if (/\bClerk\s*\.\s*shared\b/.test(argumentsSource)) { + conflicting = true; + } + } + return { found, conflicting }; +} + +/** + * 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), + }, + }; +} + +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..2deb14d49 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); @@ -724,6 +754,207 @@ 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.status).toBe("partial"); + }); + + 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"); + 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.authFlowReferences).toEqual([{ path: "App.swift" }]); + expect(inspection.openURLHandlers).toEqual([{ path: "App.swift" }]); + 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); + 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([]); + }); + + 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"); + 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.authFlowReferences).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); + 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([]); + + await Bun.write( + secondPath, + `import ClerkKit + import SwiftUI + @main struct Unsupported: App { + var body: some Scene { + WindowGroup { ContentView() } + .defaultSize(width: 1100, height: 800) + Settings { Text("Settings") } + } + }`, + ); + const unsupported = await inspectSwiftSources([ + { absolutePath: secondPath, relativePath: "Second.swift" }, + ]); + expect(unsupported.appRootEvidence).toEqual([]); + expect(unsupported.rootEnvironmentInjections).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); diff --git a/packages/cli-core/src/commands/init/ios/swift.ts b/packages/cli-core/src/commands/init/ios/swift.ts index 2dce01844..9e1e88c58 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,8 +15,9 @@ 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_ENVIRONMENT_INJECTION = - /\.\s*environment\s*\(\s*(?:\\?\.\s*self\s*,\s*)?Clerk\s*\.\s*shared\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_CONSUMER = /@Environment\s*\(\s*Clerk\s*\.\s*self\s*\)/; const CLERK_AUTH_VIEW = /\bAuthView\s*\(/; const CLERK_KIT_IMPORT = @@ -96,6 +98,7 @@ const CLERK_EVIDENCE_PATTERNS = [ CLERK_CONFIGURE_CALL, CLERK_URL_HANDLER, CLERK_NATIVE_AUTH_FLOW, + CLERK_EMAIL_LINK_AUTH_FLOW, CLERK_ENVIRONMENT_INJECTION, CLERK_ENVIRONMENT_CONSUMER, CLERK_AUTH_VIEW, @@ -759,7 +762,9 @@ 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 openURLHandlers: IOSSourceEvidence[] = []; @@ -799,8 +804,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,12 +816,20 @@ export async function inspectSwiftSources( if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_INJECTION)) { environmentInjections.push(evidence); } + if ( + importsClerkModule && + appRoot?.clerkEnvironment.found && + !appRoot.clerkEnvironment.conflicting + ) { + rootEnvironmentInjections.push(evidence); + } if (importsClerkModule && has(sanitized, CLERK_ENVIRONMENT_CONSUMER)) { environmentConsumers.push(evidence); } 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); } @@ -823,6 +838,14 @@ export async function inspectSwiftSources( } } + const hasUniqueProvenAppRoot = + evidenceComplete && + entryPoints.length === 1 && + appRootEvidence.length === 1 && + appRootEvidence[0]?.path === entryPoints[0]?.path; + const provenAppRootEvidence = hasUniqueProvenAppRoot ? appRootEvidence : []; + const provenRootEnvironmentInjections = hasUniqueProvenAppRoot ? rootEnvironmentInjections : []; + const anyClerkEvidence = importsClerkKit.length + importsClerkKitUI.length + @@ -834,7 +857,7 @@ export async function inspectSwiftSources( const status = entryPoints.length > 1 ? "ambiguous" - : configureCalls.length > 0 && environmentInjections.length > 0 + : configureCalls.length > 0 && provenRootEnvironmentInjections.length > 0 ? "complete" : anyClerkEvidence ? "partial" @@ -847,7 +870,9 @@ export async function inspectSwiftSources( importsClerkKit, importsClerkKitUI, configureCalls, + appRootEvidence: provenAppRootEvidence, environmentInjections, + rootEnvironmentInjections: provenRootEnvironmentInjections, environmentConsumers, authFlowReferences, openURLHandlers, diff --git a/packages/cli-core/src/commands/init/ios/types.ts b/packages/cli-core/src/commands/init/ios/types.ts index cf60ac50f..d86b21071 100644 --- a/packages/cli-core/src/commands/init/ios/types.ts +++ b/packages/cli-core/src/commands/init/ios/types.ts @@ -25,6 +25,7 @@ export interface IOSDiagnostic { | "xcode.external-path" | "xcode.generated-project" | "xcode.incomplete-source-membership" + | "xcode.incomplete-container-discovery" | "xcode.interrupted-file-transaction" | "clerk.package-unattributed" | "clerk.invalid-publishable-key"; @@ -119,9 +120,15 @@ 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[]; + /** Broad lexical evidence retained for diagnostics and conflict detection only. */ openURLHandlers: IOSSourceEvidence[]; status: "complete" | "partial" | "absent" | "ambiguous"; } @@ -189,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" diff --git a/packages/cli-core/src/commands/init/strategy.test.ts b/packages/cli-core/src/commands/init/strategy.test.ts index 472c9f9ba..36419637c 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("custom Clerk.configure(...) sources remain unchanged"); + 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..4f8f1ba45 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,137 @@ 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("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"); @@ -1047,7 +1205,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..03644ca89 100644 --- a/packages/cli-core/src/commands/link/index.ts +++ b/packages/cli-core/src/commands/link/index.ts @@ -26,6 +26,17 @@ 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; + /** + * 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 { @@ -40,12 +51,17 @@ 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; } - 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 +91,21 @@ 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, + options.requireExistingAppSelection !== true, + ); const devInstance = app.instances.find((i) => i.environment_type === "development"); const prodInstance = app.instances.find((i) => i.environment_type === "production"); @@ -141,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)}.`, @@ -152,19 +180,30 @@ 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; } } 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 }); } + 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 }); } @@ -184,8 +223,9 @@ async function resolveApp( cwd: string, displayPath: string, 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); @@ -195,6 +235,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..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 []; } @@ -44,6 +46,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 +54,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]; }, }); 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..c66c9ac37 --- /dev/null +++ b/packages/cli-core/src/lib/plapi-native.test.ts @@ -0,0 +1,290 @@ +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 { ERROR_CODE, 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.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 = ""; + 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, + future_field: "preserved", + }, + ]; + 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"); + 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 () => { + 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("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 () => + 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..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 }; @@ -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 })); @@ -313,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 ba8f80546..5d2404dbc 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, @@ -161,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; @@ -229,11 +286,182 @@ 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; +}; + +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; + app_id_prefix: string; + bundle_id: string; + created_at: number; + 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; +}; + +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 readNativeSettingsResponse(response); +} + +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 readNativeSettingsResponse(response); +} + +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 validateIOSApplications(await readIOSApplicationResponse(response)); +} + +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 validateIOSApplication(await readIOSApplicationResponse(response)); +} + +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; + return readApplicationResponse(response); } export async function listApplicationDomains( @@ -277,12 +505,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 +528,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 +539,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..8ca08aa7c 100644 --- a/packages/cli-core/src/test/lib/init-harness.ts +++ b/packages/cli-core/src/test/lib/init-harness.ts @@ -29,6 +29,12 @@ 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 iosDevelopmentKeyMod from "../../commands/init/ios/development-key.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 +51,16 @@ 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 iosDevelopmentKeyModule from "../../commands/init/ios/development-key.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 +85,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 +204,70 @@ export function useInitHarness(): InitHarness { spyOn(loginModule, "login").mockResolvedValue(undefined as never), spyOn(linkModule, "link").mockResolvedValue(undefined), spyOn(pullModule, "pull").mockResolvedValue(undefined), + spyOn(iosDevelopmentKeyModule, "resolveIOSDevelopmentPublicKey").mockResolvedValue({ + applicationId: "app_test", + instanceId: "ins_test", + 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", + 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, + nativeAppleRequested: false, + requiresLinkedApp: false, + requiresDevelopmentKey: false, + requiresExplicitApplication: false, + }), + spyOn(iosApplyModule, "applyIOSPlannedLocalSetup").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}`)); }